portico/Tests/PorticoTests/TwoWayBindingTests.swift

188 lines
6.4 KiB
Swift

import Testing
@_spi(SGTKInternal) import Gtk
import Adw
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct TwoWayBindingTests {
// MARK: - SwitchRow active
/// Adw.SwitchRow.active propagates both directions via notify.
@Test func switchRowActiveTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let context = MountContext()
let view = AnyView(SwitchRow().active(Binding(box)))
let row = view.makeWidget(context) as! Adw.SwitchRow
#expect(row.getActive() == false)
box.set(true)
#expect(row.getActive() == true)
row.setActive(isActive: false)
#expect(box.peek() == false)
}
@Test func detailedNotifyDoesNotCrossProperties() {
guard Gtk.initCheck() else { return }
let context = MountContext()
let widget = Gtk.Switch()
let active = StateBox(false)
let sensitive = StateBox(true)
var activeSetCount = 0
var sensitiveSetCount = 0
let activeBinding = Binding<Bool>(
get: { active.peek() },
set: { value in
activeSetCount += 1
active.set(value)
}
)
let sensitiveBinding = Binding<Bool>(
get: { sensitive.peek() },
set: { value in
sensitiveSetCount += 1
sensitive.set(value)
}
)
Portico.bindProperty(
widget,
activeBinding,
registry: context.registry,
notifyDetail: "active",
read: { widget.getActive() },
write: { widget.setActive(isActive: $0) }
)
Portico.bindProperty(
widget,
sensitiveBinding,
registry: context.registry,
notifyDetail: "sensitive",
read: { widget.getSensitive() },
write: { widget.setSensitive(sensitive: $0) }
)
// Keep active out of sync without sending a widget notification. A
// broadcast handler would now observe active=true and invoke its setter
// when the unrelated sensitive property changes.
widget.setActive(isActive: true)
active.set(false)
activeSetCount = 0
sensitiveSetCount = 0
widget.setSensitive(sensitive: false)
#expect(activeSetCount == 0)
#expect(sensitiveSetCount == 1)
#expect(active.peek() == false)
#expect(sensitive.peek() == false)
}
@Test func unresolvableDetailFallsBackToBroadcast() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let context = MountContext()
let widget = Gtk.Switch()
Portico.bindProperty(
widget,
Binding(box),
registry: context.registry,
notifyDetail: "not-a-real-property",
read: { widget.getActive() },
write: { widget.setActive(isActive: $0) }
)
widget.setActive(isActive: true)
#expect(box.peek() == true)
}
// MARK: - SpinRow value
/// Adw.SpinRow.value propagates both directions via notify.
@Test func spinRowValueTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(10.0)
let context = MountContext()
let view = AnyView(SpinRow(min: 0, max: 100, step: 5).value(Binding(box)))
let row = view.makeWidget(context) as! Adw.SpinRow
#expect(row.getValue() == 10.0)
box.set(42.0)
#expect(row.getValue() == 42.0)
row.setValue(value: 20.0)
#expect(box.peek() == 20.0)
}
// MARK: - Enum property two-way
/// Gtk.Widget.halign (Gtk.Align enum) binds two-way.
@Test func enumPropertyTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(Gtk.Align.start)
let context = MountContext()
let view = AnyView(Label(str: "x").halign(Binding(box)))
let w = view.makeWidget(context)
#expect(w.getHalign() == Gtk.Align.start)
box.set(.end)
#expect(w.getHalign() == .end)
w.setHalign(align: .center)
#expect(box.peek() == .center)
}
// MARK: - Lifted optional skips nil write-back
/// The lifted Binding<String> overload on String? properties never writes
/// nil back into the binding.
@Test func liftedOptionalSkipsNil() {
guard Gtk.initCheck() else { return }
let box = StateBox("A")
let context = MountContext()
let view = AnyView(Portico.Window().title(Binding(box)))
let win = view.makeWidget(context) as! Adw.Window
#expect(win.getTitle() == "A")
box.set("B")
#expect(win.getTitle() == "B")
win.setTitle(title: "C")
#expect(box.peek() == "C")
win.setTitle(title: nil)
#expect(box.peek() == "C")
}
// MARK: - Non-Equatable stays one-way
/// Gtk.Adjustment is not Equatable, so the binding is one-way only.
@Test func nonEquatableStaysOneWay() {
guard Gtk.initCheck() else { return }
let initial = Gtk.Adjustment(value: 0, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
let other = Gtk.Adjustment(value: 50, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
let box = StateBox(initial)
let context = MountContext()
let view = AnyView(SpinButton(adjustment: nil, climbRate: 0, digits: 0).adjustment(Binding(box)))
let sb = view.makeWidget(context) as! Gtk.SpinButton
box.set(other)
#expect(sb.getAdjustment().getValue() == 50.0)
let third = Gtk.Adjustment(value: 75, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
sb.setAdjustment(adjustment: third)
#expect(box.peek() === other)
}
// MARK: - Teardown stops write-back
/// Tearing down the registry disconnects notify, so widget changes
/// no longer reach the binding.
@Test func teardownStopsWriteBack() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let ctx = MountContext()
let view = AnyView(
SwitchRow().active(Binding(box))
)
let row = view.makeWidget(ctx) as! Adw.SwitchRow
#expect(row.getActive() == false)
ctx.registry.teardown()
// After teardown, widget change should not propagate
row.setActive(isActive: true)
#expect(box.peek() == false)
}
}