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() } }