portico/Tests/PorticoTests/EnvironmentTests.swift
Brendan Szymanski da91b02a6a Port @Environment and @Observable support
Adds SwiftUI-style @Environment property wrapper with key-path and
object-injection flavors, an @Observable bridge that flushes on a GLib
idle source at highIdle priority (ahead of GTK redraw), targeted widget
updates driven by Observation tracking, and two-way observable bindings
that ride the existing bindProperty path.

New library files (4):
- State/ObservationBridge.swift -- coalesced flush bridge
- State/EnvironmentValues.swift -- scope-local key-to-box map
- State/Environment.swift -- @Environment wrapper, DynamicProperty
- Core/View+Environment.swift -- .environment(...) modifiers

Modified library files (8):
- DependencyTracker: import Observation, observing: param, didArmObservation
- EitherView/ForEach: opt container trackers out of Observation
- NodeRegistry: isEmpty for retention check
- PropertyBinding: hasDependencies || didArmObservation gate
- Binding: readOnly init, observable key-path init
- MountContext: environment field, withEnvironment, inheritance
- AnyView: _resolveDynamicProperties hook before user body eval

New tests (21 across 3 files):
- EnvironmentTests (7): scoping, nesting, projections, ForEach, lazy branches, reuse
- ObservableTests (11): acceptance case, write-back, coalescing, teardown,
  binding echo, re-arm de-dupe, retention, flush ordering
- StateTests (+3): Observation arming unit tests

Example: EnvironmentDemoPage demonstrates the feature end to end.
2026-07-30 16:53:10 -04:00

183 lines
5.8 KiB
Swift

import Testing
@_spi(Portico) import Portico
@_spi(SGTKInternal) import Gtk
// MARK: - Environment keys
private enum AccentKey: EnvironmentKey {
static let defaultValue = "default-accent"
}
extension EnvironmentValues {
fileprivate var accent: String {
get { self[AccentKey.self] }
set { self[AccentKey.self] = newValue }
}
}
private enum ThemeKey: EnvironmentKey {
static let defaultValue = "light"
}
extension EnvironmentValues {
fileprivate var theme: String {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
private enum TagKey: EnvironmentKey {
static let defaultValue = "none"
}
extension EnvironmentValues {
fileprivate var tag: String {
get { self[TagKey.self] }
set { self[TagKey.self] = newValue }
}
}
// MARK: - Probes
private struct AccentLabel: View {
@Environment(\.accent) private var accent
var body: some View {
Label(str: "").label { accent }
}
}
private struct ThemeLabel: View {
@Environment(\.theme) private var theme
var body: some View { Label(str: "").label { theme } }
}
private struct TagLabel: View {
@Environment(\.tag) private var tag
var body: some View { Label(str: "").label { tag } }
}
/// 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 struct EnvironmentTests {
/// Injection is scoped to the subtree the modifier wraps: the wrapped view
/// sees the override, a sibling outside it sees the key's default.
@Test func injectionIsSubtreeScoped() {
guard Gtk.initCheck() else { return }
let inside = AnyView(AccentLabel().environment(\.accent, "blue"))
.makeWidget(MountContext()) as! Gtk.Label
let outside = AnyView(AccentLabel()).makeWidget(MountContext()) as! Gtk.Label
#expect(inside.getText() == "blue")
#expect(outside.getText() == "default-accent")
}
/// Nested injection shadows the outer scope within one tree.
@Test func nestedInjectionShadowsOuter() {
guard Gtk.initCheck() else { return }
let box = AnyView(
VStack {
AccentLabel()
AccentLabel().environment(\.accent, "inner")
}
.environment(\.accent, "outer")
).makeWidget(MountContext()) as! Gtk.Box
#expect(labels(of: box).map { $0.getText() } == ["outer", "inner"])
}
/// `@Environment` is read-only, and its projected binding ignores writes:
/// the slot's value belongs to the ancestor that injected it.
@Test func projectedBindingReadsButDoesNotWrite() {
guard Gtk.initCheck() else { return }
struct Host: View {
@Environment(\.accent) var accent
var binding: Portico.Binding<String> { $accent }
var body: some View { Label(str: "").label($accent) }
}
let host = Host()
let label = AnyView(host.environment(\.accent, "injected"))
.makeWidget(MountContext()) as! Gtk.Label
#expect(label.getText() == "injected")
host.binding.wrappedValue = "ignored"
#expect(label.getText() == "injected")
}
/// `ForEach` rows inherit the environment of the scope that mounted them,
/// including rows inserted after the initial mount.
@Test func forEachRowsInheritEnvironment() {
guard Gtk.initCheck() else { return }
let items = StateBox([1])
let box = AnyView(
ForEach(Portico.Binding(items), id: \.self) { _ in
AccentLabel()
}
.environment(\.accent, "scoped")
).makeWidget(MountContext()) as! Gtk.Box
#expect(labels(of: box).map { $0.getText() } == ["scoped"])
items.set([1, 2])
#expect(labels(of: box).map { $0.getText() } == ["scoped", "scoped"])
}
/// A branch mounted lazily, after the initial mount pass, still resolves
/// against the scope that created it.
@Test func lazilyMountedBranchInheritsEnvironment() {
guard Gtk.initCheck() else { return }
let flag = StateBox(false)
let ctx = MountContext()
let stack = AnyView(
EitherView(Binding(flag), first: { TagLabel() }, second: { Label(str: "off") })
.environment(\.tag, "scoped")
).makeWidget(ctx) as! Gtk.Stack
defer { ctx.registry.teardown() }
#expect(labels(of: stack).map { $0.getText() } == ["off"])
flag.set(true) // mounts the `true` branch for the first time
#expect(labels(of: stack).map { $0.getText() }.contains("scoped"))
}
/// Reusing one instance twice inside a single scope resolves identically
/// and must stay legal.
@Test func sameInstanceTwiceInOneScopeIsFine() {
guard Gtk.initCheck() else { return }
let shared = ThemeLabel()
let box = AnyView(
VStack { shared; shared }.environment(\.theme, "dark")
).makeWidget(MountContext()) as! Gtk.Box
#expect(labels(of: box).map { $0.getText() } == ["dark", "dark"])
}
/// Distinct instances of the same view type in different scopes stay
/// isolated - the intended pattern.
@Test func freshInstancePerScopeIsIsolated() {
guard Gtk.initCheck() else { return }
let a = AnyView(ThemeLabel().environment(\.theme, "one"))
.makeWidget(MountContext()) as! Gtk.Label
let b = AnyView(ThemeLabel().environment(\.theme, "two"))
.makeWidget(MountContext()) as! Gtk.Label
#expect(a.getText() == "one")
#expect(b.getText() == "two")
}
}