Add onChange view modifiers
This commit is contained in:
parent
dcaa660b78
commit
44f95fe0ad
2 changed files with 449 additions and 0 deletions
221
Tests/PorticoTests/OnChangeTests.swift
Normal file
221
Tests/PorticoTests/OnChangeTests.swift
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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
|
||||
pumpOnChange(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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue