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