portico/Tests/PorticoTests/StateTests.swift

238 lines
6.9 KiB
Swift

import Observation
import Testing
@_spi(Portico) import Portico
@MainActor @Suite struct StateTests {
// MARK: - StateBox
@Test func stateBoxNotifiesSubscribers() {
let box = StateBox(1)
var seen: [Int] = []
let token = box.subscribe { seen.append($0) }
withExtendedLifetime(token) {
box.set(2)
box.set(3)
#expect(seen == [2, 3])
}
}
@Test func reentrantSetDoesNotClobberOuterNotify() {
let box = StateBox(0)
var log: [Int] = []
var didReenter = false
var tokens: [SubscriptionToken] = []
for _ in 0..<3 {
tokens.append(box.subscribe { value in
log.append(value)
if !didReenter {
didReenter = true
box.set(99)
}
})
}
withExtendedLifetime(tokens) {
box.set(1)
#expect(log.count == 6)
#expect(log.filter { $0 == 1 }.count == 3)
#expect(log.filter { $0 == 99 }.count == 3)
let nestedIndexes = log.indices.filter { log[$0] == 99 }
#expect(nestedIndexes.count == 3)
#expect(nestedIndexes.last! - nestedIndexes.first! == 2)
#expect(box.peek() == 99)
}
}
@Test func setIfChangedSkipsEqualWrites() {
let box = StateBox(7)
var callCount = 0
let token = box.subscribe { _ in callCount += 1 }
let binding = Binding(box)
withExtendedLifetime(token) {
binding.setIfChanged(7)
#expect(callCount == 0)
#expect(box.peek() == 7)
binding.setIfChanged(8)
#expect(callCount == 1)
#expect(box.peek() == 8)
}
}
/// Dropping a token cancels the subscription it owns.
@Test func droppedTokenCancelsSubscription() {
let box = StateBox(0)
var callCount = 0
do { _ = box.subscribe { _ in callCount += 1 } }
box.set(1)
#expect(callCount == 0)
}
/// An observing tracker dropped without `teardown()` unregisters itself.
@Test func droppedObservingTrackerUnregisters() {
let before = _porticoObservationRegistrationCount()
for _ in 0..<1000 {
let tracker = DependencyTracker { }
tracker.run()
}
#expect(_porticoObservationRegistrationCount() == before)
}
@Test func cancelStopsNotifications() {
let box = StateBox(0)
var callCount = 0
let token = box.subscribe { _ in callCount += 1 }
token.cancel()
box.set(9)
#expect(callCount == 0)
}
@Test func cancelIsIdempotent() {
let box = StateBox(0)
let token = box.subscribe { _ in }
token.cancel()
token.cancel() // must not crash
}
@Test func peekDoesNotTrack() {
let box = StateBox("a")
var evaluateCount = 0
let tracker = DependencyTracker { _ = box.peek(); evaluateCount += 1 }
tracker.run()
#expect(evaluateCount == 1)
box.set("b")
// Tracker registered as weak dependent only if get() was called.
// Since peek() does not register, the tracker should NOT reevaluate.
#expect(evaluateCount == 1)
}
@Test func trackerReevaluatesOnChange() {
let box = StateBox(0)
var log: [Int] = []
let tracker = DependencyTracker { log.append(box.get()) }
tracker.run()
#expect(log == [0])
box.set(5)
#expect(log == [0, 5])
}
@Test func trackerSeesLatestValueOnReevaluation() {
let box = StateBox(0)
var lastSeen = 0
let tracker = DependencyTracker { lastSeen = box.get() }
tracker.run()
#expect(lastSeen == 0)
box.set(42)
#expect(lastSeen == 42)
}
@Test func boxStronglyOwnsTracker() {
let box = StateBox(0)
var runs = 0
do {
let t = DependencyTracker { _ = box.get(); runs += 1 }
t.run()
}
#expect(runs == 1)
box.set(1)
#expect(runs == 2, "box must keep the tracker alive after the external ref drops")
}
// MARK: - Binding
@Test func bindingTracksAndWrites() {
let box = StateBox("a")
let b = Binding(box)
#expect(b.wrappedValue == "a")
b.wrappedValue = "b"
#expect(box.peek() == "b")
}
@Test func bindingFromStateBoxTracksInTracker() {
let box = StateBox("start")
let b = Binding(box)
var log: [String] = []
let tracker = DependencyTracker { log.append(b.wrappedValue) }
tracker.run()
#expect(log == ["start"])
box.set("changed")
#expect(log == ["start", "changed"])
}
@Test func customBindingSubscribeIsInert() {
let b = Binding(get: { 42 }, set: { _ in })
var called = false
let token = b.subscribe { _ in called = true }
withExtendedLifetime(token) {
// Custom bindings never fire subscriptions
#expect(!called)
}
}
// MARK: - State property wrapper
@Test func statePropertyWrapper() {
struct H { @State var x = 0 }
let h = H()
h.x = 5
#expect(h.x == 5)
#expect(h.$x.wrappedValue == 5)
}
@Test func stateProjectedBindingChangesBox() {
struct H { @State var count = 0 }
let h = H()
h.$count.wrappedValue = 10
#expect(h.count == 10)
}
@Test func stateNonmutatingSetThroughStructCopy() {
struct H { @State var value = "old" }
let original = H()
let copy = original // same StateBox
copy.value = "new"
#expect(original.value == "new") // shared reference
}
// MARK: - Observation arming
/// A body that touches no observable must NOT report an armed observation,
/// so `bindInterpolation` can still drop it.
@Test func bodyWithNoObservableReadIsNotArmed() {
var runs = 0
let tracker = DependencyTracker { runs += 1 }
tracker.run()
#expect(runs == 1)
#expect(tracker.didArmObservation == false)
#expect(tracker.hasDependencies == false)
}
/// A body that reads an `@Observable` property must report armed, so it is
/// retained even with zero StateBox edges.
@Test func bodyReadingObservableIsArmed() {
let model = ObservedModel()
let tracker = DependencyTracker { _ = model.text }
tracker.run()
#expect(tracker.didArmObservation)
#expect(tracker.hasDependencies == false)
}
/// A StateBox-only body arms nothing, keeping the native path free of
/// Observation retention.
@Test func stateBoxOnlyBodyIsNotArmed() {
let box = StateBox(1)
let tracker = DependencyTracker { _ = box.get() }
tracker.run()
#expect(tracker.didArmObservation == false)
#expect(tracker.hasDependencies)
}
}
@Observable
fileprivate final class ObservedModel { var text = "a" }