417 lines
14 KiB
Swift
417 lines
14 KiB
Swift
@_spi(SGTKInternal) import Adw
|
|
import Observation
|
|
import Testing
|
|
|
|
@_spi(Portico) import Portico
|
|
@_spi(SGTKInternal) import Gtk
|
|
|
|
// MARK: - Main-loop pump
|
|
|
|
@_silgen_name("g_main_context_iteration")
|
|
private nonisolated func observable_g_main_context_iteration(
|
|
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
|
) -> Int32
|
|
|
|
/// Drives the GLib default main context until `condition` holds or `turns` is
|
|
/// exhausted. The Observation bridge flushes on an idle source, so a test that
|
|
/// asserts an observable-driven update must pump first.
|
|
@MainActor private func pump(until condition: () -> Bool, turns: Int = 200) {
|
|
for _ in 0..<turns {
|
|
if condition() { return }
|
|
_ = observable_g_main_context_iteration(nil, 0)
|
|
}
|
|
}
|
|
|
|
/// Async pump that yields so a main-actor observation hop can run.
|
|
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
|
|
for _ in 0..<turns {
|
|
if condition() { return }
|
|
_ = observable_g_main_context_iteration(nil, 0)
|
|
await _Concurrency.Task.yield()
|
|
}
|
|
}
|
|
|
|
// MARK: - Observable model & probes
|
|
|
|
@Observable
|
|
private final class AppState {
|
|
var isFullscreen: Bool = false
|
|
var title: String = "start"
|
|
var unrelated: String = "x"
|
|
}
|
|
|
|
/// Counts `body` evaluations per view, so a test can prove an update did NOT
|
|
/// rebuild the tree.
|
|
private enum BodyCount {
|
|
@MainActor static var counts: [String: Int] = [:]
|
|
@MainActor static func bump(_ name: String) { counts[name, default: 0] += 1 }
|
|
@MainActor static func reset() { counts = [:] }
|
|
}
|
|
|
|
/// One "UI layout" reading the shared model.
|
|
private struct PaneA: View {
|
|
@Environment(AppState.self) private var state
|
|
|
|
var body: some View {
|
|
BodyCount.bump("PaneA")
|
|
return Label(str: "").label { state.isFullscreen ? "A:on" : "A:off" }
|
|
}
|
|
}
|
|
|
|
/// A *different* view type declaring the same `@Environment` variable.
|
|
private struct PaneB: View {
|
|
@Environment(AppState.self) private var state
|
|
|
|
/// Stands in for a button handler: mutates the injected model from B.
|
|
func toggleFromB() { state.isFullscreen.toggle() }
|
|
|
|
var body: some View {
|
|
BodyCount.bump("PaneB")
|
|
return Label(str: "").label { state.isFullscreen ? "B:on" : "B:off" }
|
|
}
|
|
}
|
|
|
|
/// A pane whose widget writes *back* into the model through a two-way binding.
|
|
private struct PaneSwitch: View {
|
|
@Environment(AppState.self) private var state
|
|
|
|
var body: some View {
|
|
Portico.SwitchRow().active(Portico.Binding(state, \.isFullscreen))
|
|
}
|
|
}
|
|
|
|
/// Reports each reactive re-evaluation, for the coalescing test.
|
|
private struct TitleReadout: View {
|
|
@Environment(AppState.self) private var state
|
|
let evaluations: () -> Void
|
|
|
|
var body: some View {
|
|
Label(str: "").label { [state] in
|
|
evaluations()
|
|
return state.title
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Simple readout of a model's `text` property.
|
|
private struct Readout: View {
|
|
@Environment(Model.self) private var model
|
|
var body: some View { Label(str: "").label { model.text } }
|
|
}
|
|
|
|
@Observable
|
|
private final class Model { var text = "before" }
|
|
|
|
/// A deliberately nonisolated observable model for the off-main mutation test.
|
|
@Observable
|
|
private nonisolated final class OffMainModel: @unchecked Sendable {
|
|
var text = "before"
|
|
}
|
|
|
|
@Observable
|
|
private final class Mixed { var observed = 0 }
|
|
|
|
@Observable
|
|
private final class Bench { var amount: Double = 0 }
|
|
|
|
/// Collects a container's children as `Gtk.Label`.
|
|
@MainActor private func labels(of widget: Gtk.Widget) -> [Gtk.Label] {
|
|
var result: [Gtk.Label] = []
|
|
var next = widget.getFirstChild()
|
|
while let child = next {
|
|
result.append(Gtk.Label(retaining: child.pointer))
|
|
next = child.getNextSibling()
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor @Suite(.serialized) struct ObservableTests {
|
|
|
|
/// THE ACCEPTANCE CASE. Two different view types declare the same
|
|
/// `@Environment(AppState.self)`. Mutating the model from either one
|
|
/// updates the widget built by the other, in both directions, with no
|
|
/// `Binding` and no tree rebuild.
|
|
@Test func observableEnvironmentPropagatesBothWays() async {
|
|
guard Gtk.initCheck() else { return }
|
|
BodyCount.reset()
|
|
|
|
let state = AppState()
|
|
let ctx = MountContext()
|
|
let box = AnyView(
|
|
VStack {
|
|
PaneA()
|
|
PaneB()
|
|
}
|
|
.environment(state)
|
|
).makeWidget(ctx) as! Gtk.Box
|
|
defer { ctx.registry.teardown() }
|
|
|
|
let widgets = labels(of: box)
|
|
#expect(widgets.map { $0.getText() } == ["A:off", "B:off"])
|
|
|
|
let identities = widgets.map { $0.pointer }
|
|
let bodiesAfterMount = BodyCount.counts
|
|
|
|
// Direction 1: mutate the model directly (as A's handler would).
|
|
state.isFullscreen = true
|
|
await asyncPump(until: { widgets[0].getText() == "A:on" })
|
|
#expect(widgets.map { $0.getText() } == ["A:on", "B:on"])
|
|
|
|
// Direction 2: mutate through B's own `@Environment` declaration.
|
|
let paneB = PaneB()
|
|
_ = AnyView(paneB.environment(state)).makeWidget(ctx)
|
|
paneB.toggleFromB()
|
|
await asyncPump(until: { widgets[0].getText() == "A:off" })
|
|
#expect(widgets.map { $0.getText() } == ["A:off", "B:off"])
|
|
|
|
// Targeted: same widget objects, zero body re-evaluations.
|
|
#expect(widgets.map { $0.pointer } == identities)
|
|
#expect(BodyCount.counts["PaneA"] == bodiesAfterMount["PaneA"])
|
|
}
|
|
|
|
/// The remaining direction: a widget edit writes back into the model and
|
|
/// reaches the other layout.
|
|
@Test func widgetEditWritesBackThroughTheModel() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let state = AppState()
|
|
let ctx = MountContext()
|
|
let box = AnyView(
|
|
VStack {
|
|
PaneSwitch()
|
|
PaneA()
|
|
}
|
|
.environment(state)
|
|
).makeWidget(ctx) as! Gtk.Box
|
|
defer { ctx.registry.teardown() }
|
|
|
|
let row = Adw.SwitchRow(retaining: box.getFirstChild()!.pointer)
|
|
let readout = Gtk.Label(retaining: box.getFirstChild()!.getNextSibling()!.pointer)
|
|
#expect(row.getActive() == false)
|
|
#expect(readout.getText() == "A:off")
|
|
|
|
// Simulate the user flipping the switch.
|
|
row.setActive(isActive: true)
|
|
#expect(state.isFullscreen) // widget -> model is synchronous
|
|
await asyncPump(until: { readout.getText() == "A:on" })
|
|
#expect(readout.getText() == "A:on")
|
|
|
|
// And model -> widget still works in the same tree.
|
|
state.isFullscreen = false
|
|
await asyncPump(until: { row.getActive() == false })
|
|
#expect(row.getActive() == false)
|
|
#expect(readout.getText() == "A:off")
|
|
}
|
|
|
|
/// A property no widget read must not schedule any work.
|
|
@Test func unreadObservablePropertyDoesNotUpdate() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let state = AppState()
|
|
let ctx = MountContext()
|
|
let label = AnyView(PaneA().environment(state)).makeWidget(ctx) as! Gtk.Label
|
|
defer { ctx.registry.teardown() }
|
|
#expect(label.getText() == "A:off")
|
|
|
|
state.unrelated = "y"
|
|
pump(until: { false }, turns: 20)
|
|
#expect(label.getText() == "A:off")
|
|
}
|
|
|
|
/// Several mutations in one turn coalesce into a single re-evaluation.
|
|
@Test func observableMutationsCoalesce() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let state = AppState()
|
|
let ctx = MountContext()
|
|
var evaluations = 0
|
|
let label = AnyView(
|
|
TitleReadout(evaluations: { evaluations += 1 }).environment(state)
|
|
).makeWidget(ctx) as! Gtk.Label
|
|
defer { ctx.registry.teardown() }
|
|
|
|
#expect(evaluations == 1) // initial mount
|
|
state.title = "a"
|
|
state.title = "b"
|
|
state.title = "c"
|
|
await asyncPump(until: { label.getText() == "c" })
|
|
|
|
#expect(label.getText() == "c")
|
|
#expect(evaluations == 2) // one flush, not three
|
|
}
|
|
|
|
/// Teardown detaches the observation bridge: later mutations are inert.
|
|
@Test func teardownStopsObservableUpdates() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let state = AppState()
|
|
let ctx = MountContext()
|
|
let label = AnyView(PaneA().environment(state)).makeWidget(ctx) as! Gtk.Label
|
|
#expect(label.getText() == "A:off")
|
|
|
|
ctx.registry.teardown()
|
|
state.isFullscreen = true
|
|
pump(until: { false }, turns: 20)
|
|
#expect(label.getText() == "A:off")
|
|
}
|
|
|
|
/// `Binding(model, \.keyPath)` rides the existing `bindProperty` path, so a
|
|
/// widget bound to an observable connects the same single `notify` handler
|
|
/// a `@State` binding does - and terminates the echo after one hop rather
|
|
/// than oscillating.
|
|
@Test func twoWayObservableBindingDoesNotEcho() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let model = Bench()
|
|
let ctx = MountContext()
|
|
let row = AnyView(
|
|
Portico.SpinRow(min: 0, max: 100, step: 1)
|
|
.value(Portico.Binding(model, \.amount))
|
|
).makeWidget(ctx) as! Adw.SpinRow
|
|
defer { ctx.registry.teardown() }
|
|
|
|
#expect(row.getValue() == 0)
|
|
|
|
// widget -> model, synchronous, exactly once
|
|
row.setValue(value: 7)
|
|
#expect(model.amount == 7)
|
|
|
|
// model -> widget, on the coalesced flush, exactly once
|
|
model.amount = 12
|
|
await asyncPump(until: { row.getValue() == 12 })
|
|
#expect(row.getValue() == 12)
|
|
#expect(model.amount == 12) // no write-back echo changed it
|
|
}
|
|
|
|
/// A tracker that reads BOTH a `StateBox` and an `@Observable` re-arms
|
|
/// observation on every StateBox-driven re-run, and Observation keeps each
|
|
/// stale registration until it fires. The bridge de-duplicates by tracker,
|
|
/// so the fan-out costs one flush, not one per stale registration.
|
|
@Test func staleRearmsCollapseToOneReevaluation() async {
|
|
let box = StateBox(0)
|
|
let model = Mixed()
|
|
var runs = 0
|
|
|
|
let tracker = DependencyTracker {
|
|
runs += 1
|
|
_ = box.get()
|
|
_ = model.observed
|
|
}
|
|
tracker.run()
|
|
#expect(runs == 1)
|
|
|
|
// 50 StateBox writes -> 50 re-runs -> 50 live observation registrations.
|
|
for i in 1...50 { box.set(i) }
|
|
#expect(runs == 51)
|
|
|
|
// One observable mutation fires all 50 stale onChange closures; the
|
|
// bridge collapses them into a single queued re-evaluation.
|
|
model.observed = 1
|
|
await asyncPump(until: { runs == 52 })
|
|
#expect(runs == 52)
|
|
|
|
// And the stale registrations are gone: the next mutation behaves the
|
|
// same, not worse.
|
|
model.observed = 2
|
|
await asyncPump(until: { runs == 53 })
|
|
#expect(runs == 53)
|
|
tracker.teardown()
|
|
}
|
|
|
|
/// End to end: a non-static interpolation reading nothing reactive leaves
|
|
/// no tracker behind, while one reading an observable stays live.
|
|
@Test func interpolationRetentionIsExact() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let inert = 41
|
|
let ctxInert = MountContext()
|
|
_ = AnyView(Label(str: "").label("value \(inert)")).makeWidget(ctxInert)
|
|
#expect(ctxInert.registry.isEmpty)
|
|
|
|
let model = Model()
|
|
let ctxLive = MountContext()
|
|
let live = AnyView(Label(str: "").label("value \(model.text)"))
|
|
.makeWidget(ctxLive) as! Gtk.Label
|
|
defer { ctxLive.registry.teardown() }
|
|
#expect(ctxLive.registry.isEmpty == false)
|
|
#expect(live.getText() == "value before")
|
|
|
|
model.text = "after"
|
|
await asyncPump(until: { live.getText() == "value after" })
|
|
#expect(live.getText() == "value after")
|
|
}
|
|
|
|
/// A mutation is NOT visible in the widget on the same turn: Observation's
|
|
/// `onChange` is a willSet hook, so the new value is not yet readable and
|
|
/// the flush has to be deferred.
|
|
@Test func observableUpdateIsDeferredNotSynchronous() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let model = Model()
|
|
let ctx = MountContext()
|
|
let label = AnyView(Readout().environment(model)).makeWidget(ctx) as! Gtk.Label
|
|
defer { ctx.registry.teardown() }
|
|
#expect(label.getText() == "before")
|
|
|
|
model.text = "after"
|
|
#expect(label.getText() == "before") // same turn: still stale
|
|
await asyncPump(until: { label.getText() == "after" })
|
|
#expect(label.getText() == "after")
|
|
}
|
|
|
|
/// ...but it always lands BEFORE anything at GTK's redraw priority, so no
|
|
/// frame can be composited from stale widget state.
|
|
@Test func flushRunsBeforeGtkRedrawPriority() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let model = Model()
|
|
let ctx = MountContext()
|
|
let label = AnyView(Readout().environment(model)).makeWidget(ctx) as! Gtk.Label
|
|
defer { ctx.registry.teardown() }
|
|
|
|
var textSeenAtRedraw: String?
|
|
var textSeenAtResize: String?
|
|
|
|
model.text = "after"
|
|
await _Concurrency.Task.yield()
|
|
Idle(priority: SourcePriority(rawValue: 110)) { textSeenAtResize = label.getText() }
|
|
Idle(priority: SourcePriority(rawValue: 120)) { textSeenAtRedraw = label.getText() }
|
|
await asyncPump(until: { textSeenAtRedraw != nil })
|
|
|
|
#expect(textSeenAtResize == "after")
|
|
#expect(textSeenAtRedraw == "after")
|
|
}
|
|
|
|
/// A `StateBox` write, by contrast, is synchronous - the existing
|
|
/// contract is unchanged by any of this.
|
|
@Test func stateBoxWriteRemainsSynchronous() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let box = StateBox("before")
|
|
let ctx = MountContext()
|
|
let label = AnyView(Label(str: "").label(Portico.Binding(box)))
|
|
.makeWidget(ctx) as! Gtk.Label
|
|
#expect(label.getText() == "before")
|
|
box.set("after")
|
|
#expect(label.getText() == "after") // no pump needed
|
|
}
|
|
/// An off-main observable mutation hops to the main actor before the
|
|
/// observation bridge updates the mounted label.
|
|
@Test func offMainObservableMutationDoesNotTrap() async {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let model = OffMainModel()
|
|
let ctx = MountContext()
|
|
let label = AnyView(
|
|
Portico.Label(str: "").label { model.text }
|
|
).makeWidget(ctx) as! Gtk.Label
|
|
defer { ctx.registry.teardown() }
|
|
#expect(label.getText() == "before")
|
|
|
|
await _Concurrency.Task.detached { model.text = "after" }.value
|
|
await asyncPump(until: { label.getText() == "after" })
|
|
#expect(label.getText() == "after")
|
|
}
|
|
}
|