238 lines
6.9 KiB
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" }
|