portico/Tests/PorticoTests/BindableTests.swift

153 lines
5 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 test_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 }
_ = test_g_main_context_iteration(nil, 0)
}
}
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = test_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
// MARK: - Fixtures
@Observable
private final class Model {
var isEnabled = false
var title = "start"
var amount: Double = 0
}
/// A view that receives its model directly and binds widgets through `$model`.
private struct Editor: View {
@Bindable var model: Model
var body: some View {
VStack {
Portico.SwitchRow().active($model.isEnabled)
Label(str: "").label($model.title)
}
}
}
// MARK: - Tests
@MainActor @Suite(.serialized) struct BindableTests {
/// `$model.isEnabled` is a two-way binding: widget writes propagate to the
/// model synchronously, and model mutations propagate back to widgets
/// through the Observation bridge.
@Test func dynamicMemberBindingIsTwoWay() async {
guard Gtk.initCheck() else { return }
let model = Model()
let ctx = MountContext()
let box = AnyView(Editor(model: model)).makeWidget(ctx) as! Gtk.Box
defer { ctx.registry.teardown() }
let row = Adw.SwitchRow(retaining: box.getFirstChild()!.pointer)
let label = Gtk.Label(retaining: box.getFirstChild()!.getNextSibling()!.pointer)
#expect(row.getActive() == false)
#expect(label.getText() == "start")
// Widget -> model is synchronous.
row.setActive(isActive: true)
#expect(model.isEnabled)
// Model -> widget propagates through Observation.
model.isEnabled = false
await asyncPump(until: { row.getActive() == false })
#expect(row.getActive() == false)
}
/// `$model.title` drives the label independently of `$model.isEnabled`,
/// proving that two bindings into the same model don't interfere.
@Test func dynamicMemberBindingDrivesLabel() async {
guard Gtk.initCheck() else { return }
let model = Model()
let ctx = MountContext()
let box = AnyView(Editor(model: model)).makeWidget(ctx) as! Gtk.Box
defer { ctx.registry.teardown() }
let label = Gtk.Label(retaining: box.getFirstChild()!.getNextSibling()!.pointer)
#expect(label.getText() == "start")
model.title = "next"
await asyncPump(until: { label.getText() == "next" })
#expect(label.getText() == "next")
}
/// `Bindable(model).amount` vends a binding without the property-wrapper
/// declaration syntax, matching `Binding(model, \.amount)` but more
/// concise.
@Test func inlineBindableWrapsObject() async {
guard Gtk.initCheck() else { return }
let model = Model()
let ctx = MountContext()
let row = AnyView(
Portico.SpinRow(min: 0, max: 100, step: 1)
.value(Bindable(model).amount)
).makeWidget(ctx) as! Adw.SpinRow
defer { ctx.registry.teardown() }
#expect(row.getValue() == 0)
// Widget -> model is synchronous.
row.setValue(value: 7)
#expect(model.amount == 7)
// Model -> widget propagates through Observation.
model.amount = 12
await asyncPump(until: { row.getValue() == 12 })
#expect(row.getValue() == 12)
#expect(model.amount == 12) // no write-back echo
}
/// Teardown detaches the Observation bridge: mutations after teardown are
/// inert and do not update widget state.
@Test func teardownStopsBindableUpdates() {
guard Gtk.initCheck() else { return }
let model = Model()
let ctx = MountContext()
let box = AnyView(Editor(model: model)).makeWidget(ctx) as! Gtk.Box
let label = Gtk.Label(retaining: box.getFirstChild()!.getNextSibling()!.pointer)
#expect(label.getText() == "start")
ctx.registry.teardown()
model.title = "after"
pump(until: { false }, turns: 20)
#expect(label.getText() == "start")
}
/// `wrappedValue` and `projectedValue` are real stored members, not
/// swallowed by `@dynamicMemberLookup`.
@Test func wrappedValueIsTheSameInstance() {
let model = Model()
let b = Bindable(model)
#expect(b.wrappedValue === model)
#expect(b.projectedValue.wrappedValue === model)
}
}