Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,19 +173,36 @@
final class Observer: ObservableObject {
@Published var value: Bool = false
var cancellable: AnyCancellable?

init(value: Bool) {
self.value = value
}

func wire(updates: AnyPublisher<Void, Never>?, evaluate: @escaping () -> Bool) {
// compute immediately
let v = evaluate()
if v != value { value = v }
guard cancellable == nil, let updates else { return }
Comment thread
SoundBlaster marked this conversation as resolved.
cancellable =
updates
.receive(on: DispatchQueue.main)
.sink { [weak self] in
guard let self else { return }
let v2 = evaluate()
if v2 != self.value { self.value = v2 }
// Defer publication until the current SwiftUI update has
// completed. Providers can emit while a view is being
// reconciled, and publishing from that callback is undefined.
DispatchQueue.main.async {
guard let self else { return }
let v = evaluate()
if v != self.value { self.value = v }
}
}

// The provider may have changed between initialization and the
// first DynamicProperty.update(). Re-evaluate after wiring so an
// event emitted in that gap is not lost, without publishing from
// inside SwiftUI's update pass.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let v = evaluate()
if v != self.value { self.value = v }
}
}
}

Expand Down Expand Up @@ -216,31 +233,39 @@
provider: Provider,
using specification: Spec
) where Provider.Context == Context, Spec.T == Context {
self.contextFactory = provider.currentContext
self.specification = AnySpecification(specification)
let contextFactory = provider.currentContext
let specification = AnySpecification(specification)
self.contextFactory = contextFactory
self.specification = specification
#if canImport(Combine)
if let p = provider as? ContextUpdatesProviding {
self.updates = p.contextUpdates
} else {
self.updates = nil
}
self._observer = ObservedObject(wrappedValue: Observer())
self._observer = ObservedObject(
wrappedValue: Observer(value: specification.isSatisfiedBy(contextFactory()))
)
#endif
}

public init<Provider: ContextProviding>(
provider: Provider,
predicate: @escaping (Context) -> Bool
) where Provider.Context == Context {
self.contextFactory = provider.currentContext
self.specification = AnySpecification { ctx in predicate(ctx) }
let contextFactory = provider.currentContext
let specification = AnySpecification { ctx in predicate(ctx) }
self.contextFactory = contextFactory
self.specification = specification
#if canImport(Combine)
if let p = provider as? ContextUpdatesProviding {
self.updates = p.contextUpdates
} else {
self.updates = nil
}
self._observer = ObservedObject(wrappedValue: Observer())
self._observer = ObservedObject(
wrappedValue: Observer(value: specification.isSatisfiedBy(contextFactory()))
)
#endif
}

Expand Down
60 changes: 60 additions & 0 deletions Tests/SpecificationKitTests/ObservedSatisfiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@ import XCTest
}
}

#if canImport(Combine)
final class UpdatingContextProvider: ContextProviding, ContextUpdatesProviding {
typealias Context = EvaluationContext

private var context: EvaluationContext
private let updatesSubject = PassthroughSubject<Void, Never>()

init(context: EvaluationContext) {
self.context = context
}

func currentContext() -> EvaluationContext {
context
}

var contextUpdates: AnyPublisher<Void, Never> {
updatesSubject.eraseToAnyPublisher()
}

var contextStream: AsyncStream<Void> {
AsyncStream { _ in }
}

func updateContext(_ context: EvaluationContext) {
self.context = context
updatesSubject.send()
}
}
#endif

// MARK: - Basic Initialization Tests

func testObservedSatisfies_InitWithDefaultProvider() {
Expand Down Expand Up @@ -112,6 +142,7 @@ import XCTest

// When
var observedSatisfiesTrue = ObservedSatisfies(provider: testProvider, using: spec)
XCTAssertTrue(observedSatisfiesTrue.wrappedValue)
observedSatisfiesTrue.update()

// Update provider context
Expand All @@ -134,6 +165,7 @@ import XCTest
// When
var observedSatisfiesTrue = ObservedSatisfies(
provider: testProvider, predicate: predicate)
XCTAssertTrue(observedSatisfiesTrue.wrappedValue)
observedSatisfiesTrue.update()

// Update provider context
Expand All @@ -147,6 +179,34 @@ import XCTest
XCTAssertNotNil(observedSatisfiesFalse)
}

#if canImport(Combine)
func testObservedSatisfies_ReevaluatesWhenContextChangesBeforeFirstUpdate() {
// Given
let provider = UpdatingContextProvider(
context: EvaluationContext(flags: ["feature_enabled": true])
)
var observedSatisfies = ObservedSatisfies(
provider: provider,
predicate: { $0.flag(for: "feature_enabled") }
)
XCTAssertTrue(observedSatisfies.wrappedValue)

// The provider emits before DynamicProperty.update() wires the observer.
provider.updateContext(EvaluationContext(flags: ["feature_enabled": false]))

// When
observedSatisfies.update()

// Then - The deferred first evaluation observes the latest context.
let expectation = expectation(description: "deferred evaluation")
DispatchQueue.main.async {
XCTAssertFalse(observedSatisfies.wrappedValue)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
#endif

// MARK: - Edge Cases

func testObservedSatisfies_WithComplexSpecification() {
Expand Down
Loading