216 lines
8.5 KiB
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()
|
|
}
|
|
}
|