229 lines
7.2 KiB
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)
|
|
}
|
|
}
|