portico/Tests/PorticoTests/LifecycleTests.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")
}
}