portico/Tests/PorticoTests/PresentationTests.swift

216 lines
8.5 KiB
Swift

import Adw
import Testing
import Gtk
@_spi(Portico) import Portico
// MARK: - Main-loop pump
@_silgen_name("g_main_context_iteration")
private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib default main context until `condition` holds or `limit`
/// elapses.
///
/// Iterates in blocking mode so real time can pass while libadwaita's toast
/// timeout source is pending; a non-repeating watchdog guarantees the loop
/// wakes and terminates even if no other source ever fires.
@MainActor private func pump(until condition: () -> Bool, limit: Duration) {
let expired = Box()
let watchdog = Timeout(interval: limit, repeats: false) { expired.value = true }
while !condition() && !expired.value {
_ = test_g_main_context_iteration(nil, 1)
}
if !expired.value { watchdog.remove() }
}
/// Mutable flag reachable from an escaping `@MainActor` closure.
@MainActor private final class Box {
var value = false
}
@MainActor @Suite(.serialized) struct PresentationTests {
@Test func toastInitializerConfiguresProperties() {
guard Gtk.initCheck() else { return }
let toast = Toast(title: "Saved", timeout: .seconds(2), priority: .high, buttonLabel: "Undo", useMarkup: true) {}
#expect(toast.getTitle() == "Saved")
#expect(toast.getTimeout() == 2)
#expect(toast.getPriority() == .high)
#expect(toast.getButtonLabel() == "Undo")
#expect(toast.getUseMarkup())
}
@Test func toastManagerQueuesAndFlushes() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
manager.addToast(Toast(title: "One"))
manager.addToast(Toast(title: "Two"))
#expect(manager._pendingCount == 2)
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
#expect(manager._attachedOverlay != nil)
#expect(manager._pendingCount == 0)
context.registry.teardown()
#expect(manager._attachedOverlay == nil)
}
@Test func alertResponsesMount() {
guard Gtk.initCheck() else { return }
let view = AlertDialog(heading: "Heading", body: "Body")
.responses([AlertResponse(id: "ok", label: "OK", appearance: .suggested)])
.defaultResponse("ok")
let widget = AnyView(view).makeWidget(MountContext())
#expect(widget is Adw.AlertDialog)
let dialog = widget as! Adw.AlertDialog
#expect(dialog.getHeading() == "Heading")
#expect(dialog.getDefaultResponse() == "ok")
}
@Test func dialogModifierReturnsHostWidget() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let widget = AnyView(VStack {}.dialog(isPresented: Binding(box)) { Label(str: "content") }).makeWidget(MountContext())
#expect(widget is Gtk.Box)
}
@Test func dialogParametersApplyStaticValues() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let show = Binding(box)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, title: "Details", contentWidth: 360, canClose: false) {
Label(str: "content")
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
pump(until: { host.getMapped() }, limit: .seconds(2))
show.wrappedValue = true
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
let dialog = window.getVisibleDialog()
#expect(dialog?.getTitle() == "Details")
#expect(dialog?.getContentWidth() == 360)
#expect(dialog?.getCanClose() == false)
window.close()
}
@Test func dialogParameterBindingsTrackChanges() {
guard Gtk.initCheck() else { return }
let showBox = StateBox(false)
let titleBox = StateBox("First")
let show = Binding(showBox)
let title = Binding(titleBox)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, title: title) {
Label(str: "content")
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
pump(until: { host.getMapped() }, limit: .seconds(2))
show.wrappedValue = true
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
title.wrappedValue = "Second"
pump(until: { window.getVisibleDialog()?.getTitle() == "Second" }, limit: .seconds(2))
#expect(window.getVisibleDialog()?.getTitle() == "Second")
window.close()
}
@Test func dialogOverloadAcceptsConfiguredDialog() {
guard Gtk.initCheck() else { return }
let view = Dialog()
.title("Configured")
.contentWidth(320)
.presentationMode(DialogPresentationMode.bottomSheet)
let widget = AnyView(view).makeWidget(MountContext())
#expect(widget is Adw.Dialog)
let dialog = widget as! Adw.Dialog
#expect(dialog.getTitle() == "Configured")
#expect(dialog.getContentWidth() == 320)
#expect(dialog.getPresentationMode() == .bottomSheet)
let host = AnyView(VStack {}.dialog(isPresented: Binding(StateBox(false)), Dialog())).makeWidget(MountContext())
#expect(host is Gtk.Box)
}
// MARK: - Toast ownership regressions
//
// `adw_toast_overlay_add_toast()` is `(transfer full)`: it consumes a
// reference. gtk-swift's generated wrapper used to pass the pointer
// without compensating, so the Swift `Toast` wrapper's `deinit` released a
// reference it no longer owned. The toast was then finalized early and
// libadwaita dereferenced freed memory when draining its queue.
//
// Both tests deliberately drop the Swift wrapper while libadwaita still
// owns the toast. Against the unfixed binding they abort the test process
// inside libadwaita rather than failing an expectation.
@Test func queuedToastsSurviveDismissAfterWrapperRelease() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
// Each wrapper is released at the end of its statement, leaving the
// toast owned solely by libadwaita's queue.
for index in 0..<5 {
manager.addToast(Toast(title: "queued \(index)"))
}
manager.dismissAll()
pump(until: { false }, limit: .milliseconds(150))
#expect(manager._attachedOverlay != nil)
}
@Test func toastSurvivesNaturalTimeoutAfterWrapperRelease() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
let dismissed = Box()
do {
let toast = Toast(title: "expires", timeout: .seconds(1)) {}
_ = toast.connectDismissed { _ in dismissed.value = true }
manager.addToast(toast)
}
pump(until: { dismissed.value }, limit: .seconds(5))
#expect(dismissed.value)
}
/// Out-of-range integer dialog properties clamp instead of trapping.
@Test func oversizedIntDialogPropertyClamps() {
guard case .constant(let high) = Int.max._dialogPropertyValue else {
#expect(Bool(false), "Int source must be a constant")
return
}
#expect(high == Int32.max)
guard case .constant(let low) = Int.min._dialogPropertyValue else {
#expect(Bool(false), "Int source must be a constant")
return
}
#expect(low == Int32.min)
}
/// An oversized content width still permits dialog presentation.
@Test func dialogWithOversizedContentWidthPresents() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let show = Binding(box)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, contentWidth: Int.max) {
Label(str: "content")
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
pump(until: { host.getMapped() }, limit: .seconds(2))
show.wrappedValue = true
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
#expect(window.getVisibleDialog() != nil)
window.close()
context.registry.teardown()
}
}