153 lines
5 KiB
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)
|
|
}
|
|
}
|