diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8fd807d..c585eb1 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", "state": { "branch": null, - "revision": "ce9c0d897db8a840c39de64caaa9b60119cf4be8", - "version": "0.8.1" + "revision": "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version": "0.14.1" } }, { diff --git a/Package.swift b/Package.swift index 53f1edc..3c133fb 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ReactiveX/RxSwift", from: "5.1.1"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.8.1"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.14.0"), .package(name: "Benchmark", url: "https://github.com/google/swift-benchmark", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.8.5"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.9.1"), diff --git a/Sources/RxComposableArchitecture/Effect.swift b/Sources/RxComposableArchitecture/Effect.swift index 67f10a1..b60ef2c 100644 --- a/Sources/RxComposableArchitecture/Effect.swift +++ b/Sources/RxComposableArchitecture/Effect.swift @@ -37,7 +37,7 @@ public struct Effect { enum Operation { case none case observable(Observable) - case run(TaskPriority? = nil, @Sendable (Send) async -> Void) + case run(TaskPriority? = nil, @Sendable (Send) async -> Void) } @usableFromInline @@ -198,8 +198,8 @@ extension Effect { /// - Returns: An effect wrapping the given asynchronous work. public static func run( priority: TaskPriority? = nil, - operation: @escaping @Sendable (Send) async throws -> Void, - catch handler: (@Sendable (Error, Send) async -> Void)? = nil, + operation: @escaping @Sendable (Send) async throws -> Void, + catch handler: (@Sendable (Error, Send) async -> Void)? = nil, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line @@ -269,66 +269,64 @@ extension Effect { } } -extension Effect { - /// A type that can send actions back into the system when used from - /// ``Effect/run(priority:operation:catch:file:fileID:line:)``. - /// - /// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function - /// rather than calling methods on it: - /// - /// ```swift - /// return .run { send in - /// send(.started) - /// defer { send(.finished) } - /// for await event in self.events { - /// send(.event(event)) - /// } - /// } - /// ``` - /// - /// You can also send actions with animation: - /// - /// ```swift - /// send(.started, animation: .spring()) - /// defer { send(.finished, animation: .default) } - /// ``` - /// - /// See ``Effect/run(priority:operation:catch:file:fileID:line:)`` for more information on how to - /// use this value to construct effects that can emit any number of times in an asynchronous - /// context. - /// - /// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622 - @MainActor - public struct Send { - public let send: @MainActor (Action) -> Void - - public init(send: @escaping @MainActor (Action) -> Void) { - self.send = send - } +/// A type that can send actions back into the system when used from +/// ``Effect/run(priority:operation:catch:file:fileID:line:)``. +/// +/// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function +/// rather than calling methods on it: +/// +/// ```swift +/// return .run { send in +/// send(.started) +/// defer { send(.finished) } +/// for await event in self.events { +/// send(.event(event)) +/// } +/// } +/// ``` +/// +/// You can also send actions with animation: +/// +/// ```swift +/// send(.started, animation: .spring()) +/// defer { send(.finished, animation: .default) } +/// ``` +/// +/// See ``Effect/run(priority:operation:catch:file:fileID:line:)`` for more information on how to +/// use this value to construct effects that can emit any number of times in an asynchronous +/// context. +/// +/// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622 +@MainActor +public struct Send { + public let send: @MainActor (Action) -> Void - /// Sends an action back into the system from an effect. - /// - /// - Parameter action: An action. - public func callAsFunction(_ action: Action) { - guard !Task.isCancelled else { return } - self.send(action) - } + public init(send: @escaping @MainActor (Action) -> Void) { + self.send = send + } - // TODO: Daniel: Should we enable this in our fork? - // a little notes: since this func is related to SwiftUI, we can revisit later on - // - // /// Sends an action back into the system from an effect with animation. - // /// - // /// - Parameters: - // /// - action: An action. - // /// - animation: An animation. - // public func callAsFunction(_ action: Action, animation: Animation?) { - // guard !Task.isCancelled else { return } - // withAnimation(animation) { - // self(action) - // } - // } + /// Sends an action back into the system from an effect. + /// + /// - Parameter action: An action. + public func callAsFunction(_ action: Action) { + guard !Task.isCancelled else { return } + self.send(action) } + + // TODO: Daniel: Should we enable this in our fork? + // a little notes: since this func is related to SwiftUI, we can revisit later on + // + // /// Sends an action back into the system from an effect with animation. + // /// + // /// - Parameters: + // /// - action: An action. + // /// - animation: An animation. + // public func callAsFunction(_ action: Action, animation: Animation?) { + // guard !Task.isCancelled else { return } + // withAnimation(animation) { + // self(action) + // } + // } } // MARK: - Composing Effects @@ -472,7 +470,7 @@ extension Effect { return .init( operation: .run(priority) { send in await operation( - Send { action in + Send { action in send(transform(action)) } ) diff --git a/Sources/RxComposableArchitecture/Internal/Deprecated.swift b/Sources/RxComposableArchitecture/Internal/Deprecated.swift index 089d03d..13b66f6 100644 --- a/Sources/RxComposableArchitecture/Internal/Deprecated.swift +++ b/Sources/RxComposableArchitecture/Internal/Deprecated.swift @@ -7,15 +7,6 @@ import Darwin -// MARK: - Deprecated after 0.50.4 - -@available( - *, - deprecated, - message: "Use 'Effect.Send' instead." -) -public typealias Send = Effect.Send - // MARK: - Deprecated after 0.42.0: /// This API has been soft-deprecated in favor of ``ReducerProtocol``. diff --git a/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift index 8975163..2ea39b0 100644 --- a/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift +++ b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift @@ -40,5 +40,9 @@ extension Store { reducer: Reduce(reducer, environment: environment), useNewScope: useNewScope ) + + /// We mark this flag as false, since this init is for old style reducer + /// + self.isReducerProtocolStore = false } } diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift index b8e33e0..f950e76 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift @@ -10,6 +10,7 @@ extension ReducerProtocol { /// - Parameter printer: A printer for printing debug messages. /// - Returns: A reducer that prints debug messages for all received actions. @inlinable + @warn_unqualified_access public func _printChanges( _ printer: _ReducerPrinter? = .customDump ) -> _PrintChangesReducer { diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift index 2f6097c..f1ceb7b 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift @@ -68,6 +68,7 @@ extension ReducerProtocol { /// - value: The new value to set for the item specified by `keyPath`. /// - Returns: A reducer that has the given value set in its dependencies. @inlinable + @warn_unqualified_access public func dependency( _ keyPath: WritableKeyPath, _ value: Value @@ -119,6 +120,7 @@ extension ReducerProtocol { /// - transform: A closure that is handed a mutable instance of the value specified by the key /// path. @inlinable + @warn_unqualified_access public func transformDependency( _ keyPath: WritableKeyPath, transform: @escaping (inout V) -> Void diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift index 9828446..626dc1c 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift @@ -50,6 +50,7 @@ extension ReducerProtocol { /// state. /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable + @warn_unqualified_access public func forEach( _ toElementsState: WritableKeyPath>, action toElementAction: CasePath, diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift index ecd2131..5eda0de 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift @@ -46,6 +46,7 @@ extension ReducerProtocol { /// present /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable + @warn_unqualified_access public func ifCaseLet( _ toCaseState: CasePath, action toCaseAction: CasePath, diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift index 0a3397a..c486969 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift @@ -43,6 +43,7 @@ extension ReducerProtocol { /// state. /// - Returns: A reducer that combines the child reducer with the parent reducer. @inlinable + @warn_unqualified_access public func ifLet( _ toWrappedState: WritableKeyPath, action toWrappedAction: CasePath, diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift index 0c4ba97..48caf39 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift @@ -29,7 +29,7 @@ /// ``` /// /// A parent reducer with a domain that holds onto the child domain can use -/// ``init(state:action:_:)`` to embed the child reducer in its +/// ``init(state:action:child:)`` to embed the child reducer in its /// ``ReducerProtocol/body-swift.property-7foai``: /// /// ```swift @@ -58,7 +58,7 @@ /// ## Enum state /// /// The ``Scope`` reducer also works when state is modeled as an enum, not just a struct. In that -/// case you can use ``init(state:action:_:file:fileID:line:)`` to specify a case path that +/// case you can use ``init(state:action:child:file:fileID:line:)`` to specify a case path that /// identifies the case of state you want to scope to. /// /// For example, if your state was modeled as an enum for unloaded/loading/loaded, you could @@ -96,7 +96,8 @@ /// For an alternative to using ``Scope`` with state case paths that enforces the order, check out /// the ``ifCaseLet(_:action:then:file:fileID:line:)`` operator. public struct Scope: ReducerProtocol { - public enum StatePath { + @usableFromInline + enum StatePath { case keyPath(WritableKeyPath) case optionalPath( OptionalPath, @@ -105,10 +106,15 @@ public struct Scope: ReducerP line: UInt ) } - - public let toChildState: StatePath - public let toChildAction: CasePath - public let child: Child + + @usableFromInline + let toChildState: StatePath + + @usableFromInline + let toChildAction: CasePath + + @usableFromInline + let child: Child @usableFromInline init( diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift index 55be972..77da98e 100644 --- a/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift @@ -21,6 +21,7 @@ extension ReducerProtocol { /// - log: An `OSLog` to use for signposts. /// - Returns: A reducer that has been enhanced with instrumentation. @inlinable + @warn_unqualified_access public func signpost( _ prefix: String = "", log: OSLog = OSLog( diff --git a/Sources/RxComposableArchitecture/Store.swift b/Sources/RxComposableArchitecture/Store.swift index 8106d83..bc97984 100644 --- a/Sources/RxComposableArchitecture/Store.swift +++ b/Sources/RxComposableArchitecture/Store.swift @@ -3,6 +3,10 @@ import RxRelay import RxSwift public final class Store { + /// Notes: we are not using @_spi(Internals) for limiting access of the `State` Properties + /// since there are many of our code that still access it, so we leave it as it is for now + /// + /// reference PR: https://github.com/pointfreeco/swift-composable-architecture/pull/1954/files public private(set) var state: State { get { relay.value } set { relay.accept(newValue) } @@ -34,6 +38,12 @@ public final class Store { return relay.asObservable() } + /// Notes: We use this internal flag to gradually enforce new send from Store initialization for ReducerProtocol + /// + /// by default the value is `true`, we will set to false when the store being init from old reducer + /// + internal var isReducerProtocolStore: Bool = true + public init( initialState: R.State, reducer: R, @@ -297,9 +307,19 @@ public final class Store { @discardableResult public func send(_ action: Action, originatingFrom originatingAction: Action? = nil) -> Task? { - if useNewScope { + + if isReducerProtocolStore || useNewScope { + /// here we add extra assertion for make sure all ReducerProtocol Store will be always using newSend mechanism + /// + #if DEBUG + if isReducerProtocolStore && !useNewScope { + assertionFailure("ReducerProtocol Store not support old send action, remove overriden useNewScope params") + } + #endif + return newSend(action, originatingFrom: originatingAction) } + self.threadCheck(status: .send(action, originatingAction: originatingAction)) if !isSending { synchronousActionsToSend.append(action) @@ -404,31 +424,35 @@ public final class Store { var didComplete = false let boxedTask = TaskBox?>(wrappedValue: nil) var disposeKey: CompositeDisposable.DisposeKey? - let effectDisposable = observable - .do(onDispose: { [weak self] in - self?.threadCheck(status: .effectCompletion(action)) - if let disposeKey = disposeKey { - self?.effectDisposables.remove(for: disposeKey) - } - }) - .subscribe( - onNext: { [weak self] effectAction in - if let task = self?.send(effectAction, originatingFrom: action) { - tasks.wrappedValue.append(task) - } - }, - onError: { - assertionFailure("Error during effect handling: \($0.localizedDescription)") - }, - onCompleted: { [weak self] in + let effectDisposable = withEscapedDependencies { [weak self] continuation in + return observable + .do(onDispose: { [weak self] in self?.threadCheck(status: .effectCompletion(action)) - boxedTask.wrappedValue?.cancel() - didComplete = true if let disposeKey = disposeKey { self?.effectDisposables.remove(for: disposeKey) } - } - ) + }) + .subscribe( + onNext: { [weak self] effectAction in + if let task = continuation.yield({ + self?.send(effectAction, originatingFrom: action) + }) { + tasks.wrappedValue.append(task) + } + }, + onError: { + assertionFailure("Error during effect handling: \($0.localizedDescription)") + }, + onCompleted: { [weak self] in + self?.threadCheck(status: .effectCompletion(action)) + boxedTask.wrappedValue?.cancel() + didComplete = true + if let disposeKey = disposeKey { + self?.effectDisposables.remove(for: disposeKey) + } + } + ) + } if !didComplete { let task = Task { @MainActor in @@ -441,39 +465,43 @@ public final class Store { } case let .run(priority, operation): - tasks.wrappedValue.append( - Task(priority: priority) { @MainActor [weak self] in - #if DEBUG - var isCompleted = false - defer { isCompleted = true } - #endif - await operation( - Effect.Send { - #if DEBUG - if isCompleted { - runtimeWarn( - """ - An action was sent from a completed effect: - Action: - \(debugCaseOutput($0)) - Effect returned from: - \(debugCaseOutput(action)) - Avoid sending actions using the 'send' argument from 'EffectTask.run' after \ - the effect has completed. This can happen if you escape the 'send' argument in \ - an unstructured context. - To fix this, make sure that your 'run' closure does not return until you're \ - done calling 'send'. - """ - ) - } - #endif - if let task = self?.send($0, originatingFrom: action) { - tasks.wrappedValue.append(task) + withEscapedDependencies { [weak self] continuation in + tasks.wrappedValue.append( + Task(priority: priority) { @MainActor [weak self] in + #if DEBUG + var isCompleted = false + defer { isCompleted = true } + #endif + await operation( + Send { effectAction in + #if DEBUG + if isCompleted { + runtimeWarn( + """ + An action was sent from a completed effect: + Action: + \(debugCaseOutput(effectAction)) + Effect returned from: + \(debugCaseOutput(action)) + Avoid sending actions using the 'send' argument from 'EffectTask.run' after \ + the effect has completed. This can happen if you escape the 'send' argument in \ + an unstructured context. + To fix this, make sure that your 'run' closure does not return until you're \ + done calling 'send'. + """ + ) + } + #endif + if let task = continuation.yield({ + self?.send(effectAction, originatingFrom: action) + }) { + tasks.wrappedValue.append(task) + } } - } - ) - } - ) + ) + } + ) + } } } diff --git a/Sources/RxComposableArchitecture/TestSupport/TestStore.swift b/Sources/RxComposableArchitecture/TestSupport/TestStore.swift index 53882b4..8b7c16d 100644 --- a/Sources/RxComposableArchitecture/TestSupport/TestStore.swift +++ b/Sources/RxComposableArchitecture/TestSupport/TestStore.swift @@ -1,4 +1,5 @@ #if DEBUG +@_spi(Internals) import CasePaths import Foundation import RxSwift import XCTestDynamicOverlay @@ -576,7 +577,7 @@ public final class TestStore ScopedState, failingWhenNothingChange: Bool = true, - useNewScope: Bool = false + useNewScope: Bool = StoreConfig.default.useNewScope() ) { self._environment = _environment self.file = file @@ -1024,7 +1029,11 @@ extension TestStore where ScopedState: Equatable { let previousState = self.reducer.state let task = self.store .send(.init(origin: .send(self.fromScopedAction(action)), file: file, line: line)) - await self.reducer.effectDidSubscribe.stream.first(where: { _ in true }) + + for await _ in self.reducer.effectDidSubscribe.stream { + break + } + do { let currentState = self.state self.reducer.state = previousState @@ -1150,6 +1159,13 @@ extension TestStore where ScopedState: Equatable { ) throws { let current = expected var expected = expected + let updateStateToExpectedResult = updateStateToExpectedResult.map { original in + { (state: inout ScopedState) in + try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { + try original(&state) + } + } + } switch self.exhaustivity { case .on: @@ -1709,6 +1725,14 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { file: StaticString, line: UInt ) { + let updateStateToExpectedResult = updateStateToExpectedResult.map { original in + { (state: inout ScopedState) in + try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { + try original(&state) + } + } + } + guard !self.reducer.receivedActions.isEmpty else { XCTFail( failureMessage(), diff --git a/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift b/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift index 64bd622..2ee6be7 100644 --- a/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift +++ b/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift @@ -10,7 +10,7 @@ import XCTest @testable import RxComposableArchitecture -/// All Test cases in here using `useNewScope: false` on both Store(...) and TestStore(...) +/// All Test cases in here using `useNewScope: false` and `old style reducer` on both Store(...) and TestStore(...) /// internal final class StoreOldScopeTest: XCTestCase { private let disposeBag = DisposeBag() @@ -18,7 +18,8 @@ internal final class StoreOldScopeTest: XCTestCase { internal func testCancellableIsRemovedOnImmediatelyCompletingEffect() { let store = Store( initialState: (), - reducer: EmptyReducer(), + reducer: AnyReducer { _, _, _ in .none }, + environment: (), useNewScope: false ) @@ -29,6 +30,20 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(store.effectDisposables.count, 0) } + internal func testCancellableIsRemovedOnImmediatelyCompletingEffect_withUsingNewScope() { + let store = Store( + initialState: (), + reducer: AnyReducer { _, _, _ in .none }, + environment: () + ) + + XCTAssertEqual(store.effectDisposables.count, 0) + + _ = store.send(()) + + XCTAssertEqual(store.effectDisposables.count, 0) + } + internal func testCancellableIsRemovedWhenEffectCompletes() { let scheduler = TestScheduler(initialClock: 0) let effect = Effect(value: ()) @@ -37,18 +52,19 @@ internal final class StoreOldScopeTest: XCTestCase { enum Action { case start, end } - let reducer = Reduce({ _, action in + let reducer = AnyReducer{ _, action, _ in switch action { case .start: return effect.map { .end } case .end: return .none } - }) + } let store = Store( initialState: (), reducer: reducer, + environment: (), useNewScope: false ) @@ -63,8 +79,42 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(store.effectDisposables.count, 0) } + internal func testCancellableIsRemovedWhenEffectCompletes_withUsingNewScope() { + let scheduler = TestScheduler(initialClock: 0) + let effect = Effect(value: ()) + .delay(.seconds(1), scheduler: scheduler) + .eraseToEffect() + + enum Action { case start, end } + + let reducer = AnyReducer{ _, action, _ in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } + } + + let store = Store( + initialState: (), + reducer: reducer, + environment: () + ) + + XCTAssertEqual(store.effectDisposables.count, 0) + + _ = store.send(.start) + + XCTAssertEqual(store.effectDisposables.count, 1) + + scheduler.advance(by: .seconds(2)) + + XCTAssertEqual(store.effectDisposables.count, 0) + } + internal func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reduce({ state, _ in + let counterReducer = AnyReducer({ state, _, _ in state += 1 return .none }) @@ -72,6 +122,7 @@ internal final class StoreOldScopeTest: XCTestCase { let parentStore = Store( initialState: 0, reducer: counterReducer, + environment: (), useNewScope: false ) let childStore = parentStore.scope(state: String.init) @@ -88,8 +139,33 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(values, ["0", "1"]) } + internal func testScopedStoreReceivesUpdatesFromParent_withUsingNewScope() { + let counterReducer = AnyReducer({ state, _, _ in + state += 1 + return .none + }) + + let parentStore = Store( + initialState: 0, + reducer: counterReducer, + environment: () + ) + let childStore = parentStore.scope(state: String.init) + + var values: [String] = [] + childStore.subscribe { $0 } + .subscribe(onNext: { values.append($0) }) + .disposed(by: disposeBag) + + XCTAssertEqual(values, ["0"]) + + _ = parentStore.send(()) + + XCTAssertEqual(values, ["0", "1"]) + } + internal func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reduce({ state, _ in + let counterReducer = AnyReducer({ state, _, _ in state += 1 return .none }) @@ -97,6 +173,7 @@ internal final class StoreOldScopeTest: XCTestCase { let parentStore = Store( initialState: 0, reducer: counterReducer, + environment: (), useNewScope: false ) let childStore = parentStore.scope(state: String.init) @@ -114,24 +191,75 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(values, [0, 1]) } + internal func testParentStoreReceivesUpdatesFromChild_withUsingNewScope() { + let counterReducer = AnyReducer({ state, _, _ in + state += 1 + return .none + }) + + let parentStore = Store( + initialState: 0, + reducer: counterReducer, + environment: () + ) + let childStore = parentStore.scope(state: String.init) + + var values: [Int] = [] + + parentStore.subscribe { $0 } + .subscribe(onNext: { values.append($0) }) + .disposed(by: disposeBag) + + XCTAssertEqual(values, [0]) + + _ = childStore.send(()) + + XCTAssertEqual(values, [0, 1]) + } + internal func testScopeCallCount() { - let counterReducer = Reduce({ state, action in + let counterReducer = AnyReducer({ state, action, _ in state += 1 return .none }) var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer, useNewScope: false) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) + _ = Store( + initialState: 0, + reducer: counterReducer, + environment: (), + useNewScope: false + ) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) XCTAssertEqual(numCalls1, 2) } + internal func testScopeCallCount_withUsingNewScope() { + let counterReducer = AnyReducer({ state, action, _ in + state += 1 + return .none + }) + + var numCalls1 = 0 + _ = Store( + initialState: 0, + reducer: counterReducer, + environment: () + ) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + + XCTAssertEqual(numCalls1, 1) + } + internal func testScopeCallCount2() { - let counterReducer = Reduce({ state, _ in + let counterReducer = AnyReducer({ state, _, _ in state += 1 return .none }) @@ -140,19 +268,24 @@ internal final class StoreOldScopeTest: XCTestCase { var numCalls2 = 0 var numCalls3 = 0 - let store = Store(initialState: 0, reducer: counterReducer, useNewScope: false) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) + let store = Store( + initialState: 0, + reducer: counterReducer, + environment: (), + useNewScope: false + ) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) XCTAssertEqual(numCalls1, 2) XCTAssertEqual(numCalls2, 2) @@ -177,6 +310,57 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(numCalls3, 14) } + internal func testScopeCallCount2_withUsingNewScope() { + let counterReducer = AnyReducer({ state, _, _ in + state += 1 + return .none + }) + + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 + + let store = Store( + initialState: 0, + reducer: counterReducer, + environment: () + ) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) + + XCTAssertEqual(numCalls1, 1) + XCTAssertEqual(numCalls2, 1) + XCTAssertEqual(numCalls3, 1) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 2) + XCTAssertEqual(numCalls2, 2) + XCTAssertEqual(numCalls3, 2) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 3) + XCTAssertEqual(numCalls2, 3) + XCTAssertEqual(numCalls3, 3) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 4) + XCTAssertEqual(numCalls2, 4) + XCTAssertEqual(numCalls3, 4) + } + internal func testScopeAtIndexCallCount2() { struct Item: Identifiable, Equatable { var id: Int @@ -188,7 +372,7 @@ internal final class StoreOldScopeTest: XCTestCase { enum Action { case item(id: Int, action: ItemAction) } - let itemReducer = Reduce, Action>({ state, action in + let itemReducer = AnyReducer, Action, Void>({ state, action, _ in switch action { case let .item(id, .didTap): state[id: id]!.qty += 1 @@ -206,6 +390,7 @@ internal final class StoreOldScopeTest: XCTestCase { let store = Store( initialState: IdentifiedArrayOf(mock), reducer: itemReducer, + environment: (), useNewScope: false ) .scope(state: { (item: IdentifiedArrayOf) -> IdentifiedArrayOf in @@ -229,6 +414,58 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(store.state.qty, 3) } + internal func testScopeAtIndexCallCount2_withUsingNewScope() { + struct Item: Identifiable, Equatable { + var id: Int + var qty: Int + } + enum ItemAction { + case didTap + } + enum Action { + case item(id: Int, action: ItemAction) + } + let itemReducer = AnyReducer, Action, Void>({ state, action, _ in + switch action { + case let .item(id, .didTap): + state[id: id]!.qty += 1 + } + return .none + }) + + var numCalls1 = 0 + var numCalls2 = 0 + + let mock = (1...3).map { + Item(id: $0, qty: 1) + } + + let store = Store( + initialState: IdentifiedArrayOf(mock), + reducer: itemReducer, + environment: () + ) + .scope(state: { (item: IdentifiedArrayOf) -> IdentifiedArrayOf in + numCalls1 += 1 + return item + }) + .scope(at: 1, action: Action.item)! + .scope(state: { (item: Item) -> Item in + numCalls2 += 1 + return item + }) + + _ = store.send((1, .didTap)) + XCTAssertEqual(numCalls1, 2) + XCTAssertEqual(numCalls2, 2) + XCTAssertEqual(store.state.qty, 2) + + _ = store.send((1, .didTap)) + XCTAssertEqual(numCalls1, 3) + XCTAssertEqual(numCalls2, 3) + XCTAssertEqual(store.state.qty, 3) + } + internal func testSynchronousEffectsSentAfterSinking() { enum Action { case tap @@ -237,7 +474,7 @@ internal final class StoreOldScopeTest: XCTestCase { case end } var values: [Int] = [] - let counterReducer = Reduce({ state, action in + let counterReducer = AnyReducer({ state, action, _ in switch action { case .tap: return .merge( @@ -257,7 +494,51 @@ internal final class StoreOldScopeTest: XCTestCase { } }) - let store = Store(initialState: (), reducer: counterReducer, useNewScope: false) + let store = Store( + initialState: (), + reducer: counterReducer, + environment: (), + useNewScope: false + ) + + _ = store.send(.tap) + + XCTAssertEqual(values, [1, 2, 3, 4]) + } + + internal func testSynchronousEffectsSentAfterSinking_usingNewSend() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = AnyReducer({ state, action, _ in + switch action { + case .tap: + return .merge( + Effect(value: .next1), + Effect(value: .next2), + .fireAndForget { values.append(1) } + ) + case .next1: + return .merge( + Effect(value: .end), + .fireAndForget { values.append(2) } + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } + } + }) + + let store = Store( + initialState: (), + reducer: counterReducer, + environment: () + ) _ = store.send(.tap) @@ -266,7 +547,29 @@ internal final class StoreOldScopeTest: XCTestCase { internal func testLotsOfSynchronousActions() { enum Action { case incr, noop } - let reducer = Reduce({ state, action in + let reducer = AnyReducer({ state, action, _ in + switch action { + case .incr: + state += 1 + return state >= 10000 ? Effect(value: .noop) : Effect(value: .incr) + case .noop: + return .none + } + }) + + let store = Store( + initialState: 0, + reducer: reducer, + environment: (), + useNewScope: false + ) + _ = store.send(.incr) + XCTAssertEqual(store.state, 10000) + } + + internal func testLotsOfSynchronousActions_usingNewScope() { + enum Action { case incr, noop } + let reducer = AnyReducer({ state, action, _ in switch action { case .incr: state += 1 @@ -276,7 +579,11 @@ internal final class StoreOldScopeTest: XCTestCase { } }) - let store = Store(initialState: 0, reducer: reducer, useNewScope: false) + let store = Store( + initialState: 0, + reducer: reducer, + environment: () + ) _ = store.send(.incr) XCTAssertEqual(store.state, 10000) } @@ -286,7 +593,7 @@ internal final class StoreOldScopeTest: XCTestCase { var count: Int? } - let appReducer = Reduce({ state, action in + let appReducer = AnyReducer({ state, action, _ in state.count = action return .none }) @@ -294,6 +601,7 @@ internal final class StoreOldScopeTest: XCTestCase { let parentStore = Store( initialState: AppState(), reducer: appReducer, + environment: (), useNewScope: false ) @@ -335,10 +643,64 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) } + internal func testIfLetAfterScope_withUsingNewScope() { + struct AppState { + var count: Int? + } + + let appReducer = AnyReducer({ state, action, _ in + state.count = action + return .none + }) + + let parentStore = Store( + initialState: AppState(), + reducer: appReducer, + environment: () + ) + + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] + + parentStore + .scope(state: { $0.count }) + .ifLet( + then: { store in + stores.append(store) + outputs.append(store.state) + }, + else: { + outputs.append(nil) + } + ) + .disposed(by: disposeBag) + + XCTAssertEqual(outputs, [nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } + internal func testIfLetTwo() { let parentStore = Store( initialState: 0, - reducer: Reduce({ state, action in + reducer: AnyReducer({ state, action, _ in if action { state? += 1 return .none @@ -348,6 +710,7 @@ internal final class StoreOldScopeTest: XCTestCase { .eraseToEffect() } }), + environment: (), useNewScope: false ) @@ -368,6 +731,39 @@ internal final class StoreOldScopeTest: XCTestCase { .disposed(by: disposeBag) } + internal func testIfLetTwo_withUsingNewScope() { + let parentStore = Store( + initialState: 0, + reducer: AnyReducer({ state, action, _ in + if action { + state? += 1 + return .none + } else { + return Observable.just(true) + .observeOn(MainScheduler.instance) + .eraseToEffect() + } + }), + environment: () + ) + + parentStore.ifLet { childStore in + childStore + .observable + .subscribe() + .disposed(by: self.disposeBag) + + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertEqual(childStore.state, 3) + } + .disposed(by: disposeBag) + } + internal func testActionQueuing() { let subject = PublishSubject() @@ -379,7 +775,7 @@ internal final class StoreOldScopeTest: XCTestCase { let store = TestStore( initialState: 0, - reducer: Reduce({ state, action in + reducer: AnyReducer({ state, action, _ in switch action { case .incrementTapped: subject.onNext(()) @@ -392,7 +788,49 @@ internal final class StoreOldScopeTest: XCTestCase { state += 1 return .none } - }) + }), + environment: (), + useNewScope: false + ) + store.send(.initialize) + store.send(.incrementTapped) + store.receive(.doIncrement) { + $0 = 1 + } + store.send(.incrementTapped) + store.receive(.doIncrement) { + $0 = 2 + } + subject.onCompleted() + } + + internal func testActionQueuing_withUsingNewScope() { + let subject = PublishSubject() + + enum Action: Equatable { + case incrementTapped + case initialize + case doIncrement + } + + let store = TestStore( + initialState: 0, + reducer: AnyReducer({ state, action, _ in + switch action { + case .incrementTapped: + subject.onNext(()) + return .none + + case .initialize: + return subject.map { .doIncrement }.eraseToEffect() + + case .doIncrement: + state += 1 + return .none + } + }), + environment: (), + useNewScope: true ) store.send(.initialize) store.send(.incrementTapped) @@ -409,7 +847,7 @@ internal final class StoreOldScopeTest: XCTestCase { internal func testCoalesceSynchronousActions() { let store = Store( initialState: 0, - reducer: Reduce({ state, action in + reducer: AnyReducer({ state, action, _ in switch action { case 0: return .merge( @@ -422,6 +860,7 @@ internal final class StoreOldScopeTest: XCTestCase { return .none } }), + environment: (), useNewScope: false ) @@ -437,6 +876,37 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(emissions, [0, 1, 2, 3]) } + internal func testCoalesceSynchronousActions_withUsingNewScope() { + let store = Store( + initialState: 0, + reducer: AnyReducer({ state, action, _ in + switch action { + case 0: + return .merge( + Effect(value: 1), + Effect(value: 2), + Effect(value: 3) + ) + default: + state = action + return .none + } + }), + environment: () + ) + + var emissions: [Int] = [] + store.subscribe { $0 } + .subscribe { emissions.append($0) } + .disposed(by: disposeBag) + + XCTAssertEqual(emissions, [0]) + + _ = store.send(0) + + XCTAssertEqual(emissions, [0, 3]) + } + internal func testSyncEffectsFromEnvironment() { enum Action: Equatable { // subscribes to a long living effect, potentially feeding data @@ -486,6 +956,54 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(parentStore.state, 2) } + internal func testSyncEffectsFromEnvironment_withUsingNewScope() { + enum Action: Equatable { + // subscribes to a long living effect, potentially feeding data + // back into the store + case onAppear + + // Talks to the environment, eventually feeding data back into the store + case onUserAction + + // External event coming in from the environment, updating state + case externalAction + } + + struct Environment { + var externalEffects = PublishSubject() + } + + let counterReducer = AnyReducer { state, action, env in + switch action { + case .onAppear: + return env.externalEffects.eraseToEffect() + case .onUserAction: + return .fireAndForget { + // This would actually do something async in the environment + // that feeds back eventually via the `externalEffectPublisher` + // Here we send an action sync, which could e.g. happen for an error case, .. + env.externalEffects.onNext(.externalAction) + } + case .externalAction: + state += 1 + } + return .none + } + let parentStore = Store( + initialState: 1, + reducer: counterReducer, + environment: Environment() + ) + + // subscribes to a long living publisher of actions + _ = parentStore.send(.onAppear) + + _ = parentStore.send(.onUserAction) + + // State should be at 2 now + XCTAssertEqual(parentStore.state, 2) + } + internal func testBufferedActionProcessing() { struct ChildState: Equatable { var count: Int? @@ -502,35 +1020,112 @@ internal final class StoreOldScopeTest: XCTestCase { } var handledActions: [ParentAction] = [] - let parentReducer = Reduce({ state, action in - handledActions.append(action) + let parentReducer = AnyReducer.combine( + AnyReducer{ state, action, _ in + state.count = action + return .none + } + .optional() + .pullback( + state: \ParentState.child, + action: /ParentAction.child, + environment: {} + ), + AnyReducer({ state, action, _ in + handledActions.append(action) - switch action { - case .button: - state.child = .init(count: nil) - return .none + switch action { + case .button: + state.child = .init(count: nil) + return .none - case .child(let childCount): - state.count = childCount - return .none + case .child(let childCount): + state.count = childCount + return .none + } + }) + ) + + let parentStore = Store( + initialState: ParentState(), + reducer: parentReducer, + environment: (), + useNewScope: false + ) + + parentStore + .scope( + state: \ParentState.child, + action: ParentAction.child + ) + .ifLet { childStore in + childStore.send(2) } - }) - .ifLet(\.child, action: /ParentAction.child) { - Reduce({ state, action in - state.count = action - return .none - }) + .disposed(by: disposeBag) + + XCTAssertEqual(handledActions, []) + + _ = parentStore.send(ParentAction.button) + + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } + + internal func testBufferedActionProcessing_withUsingNewScope() { + struct ChildState: Equatable { + var count: Int? } - let parentStore = Store( + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } + + enum ParentAction: Equatable { + case button + case child(Int?) + } + + var handledActions: [ParentAction] = [] + let parentReducer = AnyReducer.combine( + AnyReducer{ state, action, _ in + state.count = action + return .none + } + .optional() + .pullback( + state: \ParentState.child, + action: /ParentAction.child, + environment: {} + ), + AnyReducer({ state, action, _ in + handledActions.append(action) + + switch action { + case .button: + state.child = .init(count: nil) + return .none + + case .child(let childCount): + state.count = childCount + return .none + } + }) + ) + + let parentStore = Store( initialState: ParentState(), reducer: parentReducer, - useNewScope: false + environment: () ) parentStore .scope( - state: \.child, + state: \ParentState.child, action: ParentAction.child ) .ifLet { childStore in @@ -540,7 +1135,7 @@ internal final class StoreOldScopeTest: XCTestCase { XCTAssertEqual(handledActions, []) - _ = parentStore.send(.button) + _ = parentStore.send(ParentAction.button) XCTAssertEqual( handledActions, diff --git a/Tests/RxComposableArchitectureTests/StoreTests.swift b/Tests/RxComposableArchitectureTests/StoreTests.swift index 396a4b4..867bcb3 100644 --- a/Tests/RxComposableArchitectureTests/StoreTests.swift +++ b/Tests/RxComposableArchitectureTests/StoreTests.swift @@ -599,6 +599,80 @@ internal final class StoreTests: XCTestCase { store.send(true) } + + /// NOTES: we only take this one test changes since for the publisher ones we no need it + /// + func testStoreVsTestStore() async { + struct Feature: ReducerProtocol { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case tap + case response1(Int) + case response2(Int) + case response3(Int) + } + @Dependency(\.count) var count + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .tap: + return withDependencies { + $0.count.value += 1 + } operation: { + .task { .response1(self.count.value) } + } + case let .response1(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .task { .response2(self.count.value) } + } + case let .response2(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .task { .response3(self.count.value) } + } + case let .response3(count): + state.count = count + return .none + } + } + } + + let testStore = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) + await testStore.send(.tap) + await testStore.receive(.response1(1)) { + $0.count = 1 + } + await testStore.receive(.response2(1)) + await testStore.receive(.response3(1)) + + let store = Store( + initialState: Feature.State(), + reducer: Feature() + ) + await store.send(.tap)?.value + XCTAssertEqual(store.state.count, testStore.state.count) + } +} + +private struct Count: TestDependencyKey { + var value: Int + static let liveValue = Count(value: 0) + static let testValue = Count(value: 0) +} +extension DependencyValues { + fileprivate var count: Count { + get { self[Count.self] } + set { self[Count.self] = newValue } + } } /// we use CounterFeature reducer on this file test scope only diff --git a/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index 7aceb6f..282cec9 100644 --- a/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -638,6 +638,75 @@ final class TestStoreNonExhaustiveTests: XCTestCase { await store.receive(/NonExhaustiveReceive.Action.response2) } + func testXCTModifyExhaustive() async { + struct State: Equatable { + var child: Int? = 0 + var count = 0 + } + + enum Action: Equatable { case tap, response } + + let store = TestStore( + initialState: State(), + reducer: Reduce { state, action in + switch action { + case .tap: + state.count += 1 + return Effect(value: .response) + case .response: + state.count += 1 + return .none + } + } + ) + + await store.send(.tap) { state in + state.count = 1 + XCTExpectFailure { + XCTModify(&state.child, case: /.some) { _ in } + } issueMatcher: { + $0.compactDescription == """ + XCTModify failed: expected "Int" value to be modified but it was unchanged. + """ + } + } + + await store.receive(.response) { state in + state.count = 2 + XCTExpectFailure { + XCTModify(&state.child, case: /.some) { _ in } + } issueMatcher: { + $0.compactDescription == """ + XCTModify failed: expected "Int" value to be modified but it was unchanged. + """ + } + } + } + + func testXCTModifyNonExhaustive() async { + enum Action { case tap, response } + + let store = TestStore( + initialState: Optional(1), + reducer: Reduce { state, action in + switch action { + case .tap: + return Effect(value: .response) + case .response: + return .none + } + } + ) + store.exhaustivity = .off + + await store.send(.tap) { + XCTModify(&$0, case: /.some) { _ in } + } + await store.receive(.response) { + XCTModify(&$0, case: /.some) { _ in } + } + } + // This example comes from Krzysztof Zabłocki's blog post: // https://www.merowing.info/exhaustive-testing-in-tca/ func testKrzysztofExample1() { diff --git a/Tests/RxComposableArchitectureTests/TestStoreTests.swift b/Tests/RxComposableArchitectureTests/TestStoreTests.swift index a2ac2db..bab92e0 100644 --- a/Tests/RxComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/RxComposableArchitectureTests/TestStoreTests.swift @@ -60,8 +60,7 @@ internal class TestStoreTests: XCTestCase { let store = TestStore( initialState: State(), - reducer: reducer, - useNewScope: true + reducer: reducer ) _ = await store.send(Action.a) @@ -95,8 +94,7 @@ internal class TestStoreTests: XCTestCase { state = number return .none } - }), - useNewScope: true + }) ) _ = await store.send(.tap) @@ -133,8 +131,7 @@ internal class TestStoreTests: XCTestCase { let store = TestStore( initialState: State(), - reducer: reducer, - useNewScope: true + reducer: reducer ) _ = await store.send(.increment) { @@ -179,8 +176,7 @@ internal class TestStoreTests: XCTestCase { let store = TestStore( initialState: State(), - reducer: reducer, - useNewScope: true + reducer: reducer ) _ = await store.send(.noop) @@ -251,8 +247,7 @@ internal class TestStoreTests: XCTestCase { count += 1 return .none } - }), - useNewScope: true + }) ) _ = await store.send(.a) { @@ -313,6 +308,7 @@ internal class TestStoreTests: XCTestCase { func testOverrideDependenciesOnTestStore() { struct Counter: ReducerProtocol { @Dependency(\.calendar) var calendar + @Dependency(\.client.fetch) var fetch @Dependency(\.locale) var locale @Dependency(\.timeZone) var timeZone @Dependency(\.urlSession) var urlSession @@ -330,11 +326,13 @@ internal class TestStoreTests: XCTestCase { let store = TestStore( initialState: 0, reducer: Counter() - ) - store.dependencies.calendar = Calendar(identifier: .gregorian) - store.dependencies.locale = Locale(identifier: "en_US") - store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! - store.dependencies.urlSession = URLSession(configuration: .ephemeral) + ) { + $0.calendar = Calendar(identifier: .gregorian) + $0.client.fetch = { 1 } + $0.locale = Locale(identifier: "en_US") + $0.timeZone = TimeZone(secondsFromGMT: 0)! + $0.urlSession = URLSession(configuration: .ephemeral) + } store.send(true) { $0 = 1 } } @@ -386,3 +384,15 @@ internal class TestStoreTests: XCTestCase { } } } + +private struct Client: DependencyKey { + var fetch: () -> Int + static let liveValue = Client(fetch: { 42 }) +} + +extension DependencyValues { + fileprivate var client: Client { + get { self[Client.self] } + set { self[Client.self] = newValue } + } +} diff --git a/development-podspecs/CasePaths.podspec.json b/development-podspecs/CasePaths.podspec.json index 65ea777..fe1e05f 100644 --- a/development-podspecs/CasePaths.podspec.json +++ b/development-podspecs/CasePaths.podspec.json @@ -1,6 +1,6 @@ { "name": "CasePaths", - "version": "0.8.1", + "version": "0.14.0", "authors": "local pod", "homepage": "https://github.com/pointfreeco/swift-case-paths", "summary": "local pod", @@ -13,7 +13,7 @@ }, "source": { "git": "https://github.com/pointfreeco/swift-case-paths", - "tag": "0.8.1" + "tag": "0.14.0" }, "source_files": "Sources/**/*.swift", "swift_version": "5.0"