portico/Tests/PorticoTests/OnChangeTests.swift

229 lines
7.2 KiB
Swift

import Observation
import Testing
@_spi(Portico) import Portico
@_spi(SGTKInternal) import Gtk
@_silgen_name("g_main_context_iteration")
private nonisolated func onChange_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib default main context until `condition` holds or `turns` is exhausted.
/// Pumps GLib so coalesced Observation callbacks can reach the test action.
@MainActor private func pumpOnChange(until condition: () -> Bool, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }
_ = onChange_g_main_context_iteration(nil, 0)
}
}
@MainActor private func asyncPumpOnChange(until condition: () -> Bool, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = onChange_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
/// Observable model used to verify closure-backed Observation updates.
@Observable
private final class OnChangeModel {
var number = 0
}
/// Equatable old/new pair used by the change assertions.
private struct OnChangePair: Equatable {
let old: Int
let new: Int
}
/// Regression tests for binding-backed and dependency-tracked onChange modifiers.
@MainActor @Suite(.serialized) struct OnChangeTests {
@Test func bindingFiresWithOldAndNewValues() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let binding = Binding(box)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: binding) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
box.set(5)
#expect(pairs == [OnChangePair(old: 0, new: 1), OnChangePair(old: 1, new: 5)])
}
@Test func bindingEqualWriteDoesNotFire() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
box.set(1)
#expect(fires == 1)
}
@Test func initialTrueFiresAtMountWithIdenticalValues() {
guard Gtk.initCheck() else { return }
let box = StateBox(7)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box), initial: true) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(pairs == [OnChangePair(old: 7, new: 7)])
}
@Test func initialFalseDoesNotFireAtMount() {
guard Gtk.initCheck() else { return }
let box = StateBox(7)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(fires == 0)
}
@Test func zeroArityOverloadFires() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
#expect(fires == 1)
}
@Test func trackedClosureFiresForState() {
guard Gtk.initCheck() else { return }
let box = StateBox(1)
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { box.get() }) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(2)
#expect(pairs == [OnChangePair(old: 1, new: 2)])
}
@Test func trackedClosureFiresForObservable() async {
guard Gtk.initCheck() else { return }
let model = OnChangeModel()
var pairs: [OnChangePair] = []
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { model.number }) { old, new in
pairs.append(OnChangePair(old: old, new: new))
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
model.number = 1
await asyncPumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
#expect(pairs == [OnChangePair(old: 0, new: 1)])
}
@Test func teardownStopsOnChange() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: Binding(box)) { fires += 1 })
.makeWidget(ctx)
ctx.registry.teardown()
box.set(9)
pumpOnChange(until: { false }, turns: 20)
#expect(fires == 0)
}
@Test func customBindingNeverFires() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let custom = Binding<Int>(get: { box.peek() }, set: { box.set($0) })
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: custom) { fires += 1 })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
custom.wrappedValue = 1
#expect(fires == 0)
}
@Test func nonReactiveClosureLeavesNoResidue() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: { 1 }) { _, _ in })
.makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(ctx.registry.isEmpty)
}
@Test func actionReadsDoNotBecomeTriggers() {
guard Gtk.initCheck() else { return }
let source = StateBox(0)
let actionSource = StateBox(100)
var evaluations = 0
var fires = 0
let ctx = MountContext()
_ = AnyView(Label(str: "x").onChange(of: {
evaluations += 1
return source.get()
}) {
_ = actionSource.get()
fires += 1
}).makeWidget(ctx)
defer { ctx.registry.teardown() }
#expect(evaluations == 1)
source.set(1)
#expect(evaluations == 2)
#expect(fires == 1)
actionSource.set(101)
#expect(evaluations == 2)
#expect(fires == 1)
}
@Test func widgetViewOverloadPreservesSelf() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let view: Portico.Label = Portico.Label(str: "x")
.onChange(of: Binding(box)) { _, _ in }
.label("done")
let ctx = MountContext()
let widget = AnyView(view).makeWidget(ctx) as! Gtk.Label
defer { ctx.registry.teardown() }
#expect(widget.getText() == "done")
}
@Test func viewOverloadWorksOnContainer() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
var fires = 0
let view = VStack { Label(str: "x") }
.onChange(of: Binding(box)) { fires += 1 }
let ctx = MountContext()
_ = AnyView(view).makeWidget(ctx)
defer { ctx.registry.teardown() }
box.set(1)
#expect(fires == 1)
}
}