215 lines
8 KiB
Swift
215 lines
8 KiB
Swift
import Testing
|
|
import Gtk
|
|
@_spi(Portico) import Portico
|
|
|
|
@_silgen_name("g_main_context_iteration")
|
|
private nonisolated func test_g_main_context_iteration(
|
|
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
|
) -> Int32
|
|
|
|
@MainActor @Suite(.serialized) struct LifecycleTests {
|
|
|
|
/// Pumps the default main context until `condition` is true OR `timeout`
|
|
/// elapses. Returns `true` if the watchdog fired (timeout elapsed without
|
|
/// `condition` becoming true).
|
|
private func pump(
|
|
until condition: () -> Bool, timeout: Duration = .seconds(2)
|
|
) -> Bool {
|
|
var expired = false
|
|
let watchdog = Timeout(interval: timeout, repeats: false) { expired = true }
|
|
defer { watchdog.remove() }
|
|
while !condition() && !expired {
|
|
_ = test_g_main_context_iteration(nil, 1)
|
|
}
|
|
return expired
|
|
}
|
|
|
|
/// Async analogue of `pump(until:)`, for conditions driven by a real
|
|
/// `Task` (as `View.task(priority:_:)` now creates) rather than a GLib
|
|
/// source directly. `pump(until:)`'s busy loop never suspends, so it
|
|
/// starves any `Task {}` scheduled on the default Swift concurrency main
|
|
/// executor - MainActor is a serial executor and a non-suspending loop
|
|
/// holds its turn indefinitely. `await Task.yield()` gives the pending
|
|
/// task a chance to run each iteration; the non-blocking GLib iteration
|
|
/// keeps driving GTK map/unmap signals concurrently.
|
|
private func asyncPump(
|
|
until condition: () -> Bool, timeout: Duration = .seconds(2)
|
|
) async -> Bool {
|
|
var expired = false
|
|
let watchdog = Timeout(interval: timeout, repeats: false) { expired = true }
|
|
defer { watchdog.remove() }
|
|
while !condition() && !expired {
|
|
_ = test_g_main_context_iteration(nil, 0)
|
|
await _Concurrency.Task.yield()
|
|
}
|
|
return expired
|
|
}
|
|
|
|
/// Mounts `view` into a presented `Gtk.Window` and returns the window.
|
|
/// The caller retains `context` for the lifetime of reactive subscriptions.
|
|
private func mountInPresentedWindow<V: View>(_ view: V, context: MountContext) -> Gtk.Window {
|
|
let w = Gtk.Window()
|
|
w.setChild(child: AnyView(view).makeWidget(context))
|
|
w.present()
|
|
return w
|
|
}
|
|
|
|
// MARK: - onAppear
|
|
|
|
@Test func onAppearFiresOnMap() {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var appeared = 0
|
|
let context = MountContext()
|
|
_ = mountInPresentedWindow(Label(str: "x").onAppear { appeared += 1 }, context: context)
|
|
let timedOut = pump(until: { appeared > 0 })
|
|
#expect(!timedOut, "main loop pump timed out")
|
|
#expect(appeared == 1)
|
|
}
|
|
|
|
// MARK: - onDisappear
|
|
|
|
@Test func onDisappearFiresOnUnmap() {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var disappeared = 0
|
|
let context = MountContext()
|
|
let window = mountInPresentedWindow(
|
|
Label(str: "x").onDisappear { disappeared += 1 }, context: context
|
|
)
|
|
// Wait for the window to map.
|
|
let timedOut = pump(until: { window.getMapped() })
|
|
#expect(!timedOut, "wait for map timed out")
|
|
window.setVisible(visible: false)
|
|
// Pump non-blocking to let unmap fire.
|
|
for _ in 0..<100 {
|
|
_ = test_g_main_context_iteration(nil, 0)
|
|
}
|
|
#expect(disappeared == 1)
|
|
}
|
|
|
|
// MARK: - task(every:) stops on unmap
|
|
|
|
@Test func taskEveryStopsOnUnmap() {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var ticks = 0
|
|
let context = MountContext()
|
|
let window = mountInPresentedWindow(
|
|
Label(str: "x").task(every: .milliseconds(1)) { ticks += 1 }, context: context
|
|
)
|
|
let timedOut = pump(until: { ticks >= 2 })
|
|
#expect(!timedOut, "main loop pump timed out")
|
|
window.setVisible(visible: false)
|
|
// The timeout may have just fired. Let a small interval pass and
|
|
// verify no further ticks arrive (watchdog expiring = no more ticks).
|
|
let stopped = ticks
|
|
let quiet = pump(until: { ticks > stopped }, timeout: .milliseconds(100))
|
|
#expect(quiet, "timeout kept firing after unmap")
|
|
}
|
|
|
|
// MARK: - task(priority:) cancelled on teardown (never mapped)
|
|
|
|
@Test func taskCancelledOnTeardown() {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var ran = false
|
|
let ctx = MountContext()
|
|
_ = AnyView(Label(str: "x").task { ran = true }).makeWidget(ctx)
|
|
ctx.registry.teardown()
|
|
for _ in 0..<100 {
|
|
_ = test_g_main_context_iteration(nil, 0)
|
|
}
|
|
#expect(!ran)
|
|
}
|
|
|
|
// MARK: - task(priority:) runs a real async action
|
|
|
|
@Test func taskRunsAsyncActionToCompletion() async {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var stage = 0
|
|
let context = MountContext()
|
|
_ = mountInPresentedWindow(
|
|
Label(str: "x").task {
|
|
stage = 1
|
|
try? await _Concurrency.Task.sleep(for: .milliseconds(5))
|
|
stage = 2
|
|
}, context: context
|
|
)
|
|
let timedOut = await asyncPump(until: { stage == 2 })
|
|
#expect(!timedOut, "async pump timed out waiting for the task to suspend and resume")
|
|
}
|
|
|
|
// MARK: - task(priority:) cancelled on unmap
|
|
|
|
@Test func taskCancelsOnUnmap() async {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var started = false
|
|
var wasCancelled = false
|
|
let context = MountContext()
|
|
let window = mountInPresentedWindow(
|
|
Label(str: "x").task {
|
|
started = true
|
|
do {
|
|
try await _Concurrency.Task.sleep(for: .seconds(5))
|
|
} catch is CancellationError {
|
|
wasCancelled = true
|
|
} catch {
|
|
}
|
|
}, context: context
|
|
)
|
|
let startTimedOut = await asyncPump(until: { started })
|
|
#expect(!startTimedOut, "task never started")
|
|
window.setVisible(visible: false)
|
|
let timedOut = await asyncPump(until: { wasCancelled }, timeout: .milliseconds(500))
|
|
#expect(!timedOut, "task was never cancelled after unmap")
|
|
}
|
|
|
|
/// A rapid unmap/remap never runs two copies of the task action at once.
|
|
@Test func remapDoesNotOverlapTaskBodies() async {
|
|
guard Gtk.initCheck() else {
|
|
#expect(Bool(false), "GTK not initialized - tests need a display")
|
|
return
|
|
}
|
|
var active = 0
|
|
var peak = 0
|
|
var entries = 0
|
|
let context = MountContext()
|
|
let window = mountInPresentedWindow(
|
|
Label(str: "x").task {
|
|
entries += 1
|
|
active += 1
|
|
peak = max(peak, active)
|
|
try? await _Concurrency.Task.sleep(for: .milliseconds(50))
|
|
active -= 1
|
|
}, context: context
|
|
)
|
|
#expect(!(await asyncPump(until: { entries >= 1 })), "task never started")
|
|
|
|
var expected = 1
|
|
for cycle in 1...3 {
|
|
window.setVisible(visible: false)
|
|
_ = await asyncPump(until: { false }, timeout: .milliseconds(30))
|
|
window.setVisible(visible: true)
|
|
expected += 1
|
|
let restarted = await asyncPump(until: { entries >= expected })
|
|
#expect(!restarted, "remap \(cycle) never restarted the task")
|
|
}
|
|
_ = await asyncPump(until: { active == 0 }, timeout: .seconds(1))
|
|
#expect(peak == 1, "two task bodies overlapped")
|
|
}
|
|
}
|