diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift index cc9c1402e63a..885727ecac24 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift @@ -63,19 +63,19 @@ let voiceMemoReducer = Reducer memo.mode = .playing(progress: 0) let start = environment.mainQueue.now return .merge( - environment.audioPlayerClient - .play(PlayerId(), memo.url) - .catchToEffect() - .map(VoiceMemoAction.audioPlayerClient) - .cancellable(id: PlayerId()), - Effect.timer(id: TimerId(), every: 0.5, on: environment.mainQueue) .map { .timerUpdated( TimeInterval($0.dispatchTime.uptimeNanoseconds - start.dispatchTime.uptimeNanoseconds) / TimeInterval(NSEC_PER_SEC) ) - } + }, + + environment.audioPlayerClient + .play(PlayerId(), memo.url) + .catchToEffect() + .map(VoiceMemoAction.audioPlayerClient) + .cancellable(id: PlayerId()) ) case .playing: diff --git a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift index 38b9ce04881a..dd82298cfe00 100644 --- a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift +++ b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift @@ -178,10 +178,14 @@ class VoiceMemosTests: XCTestCase { .send(.voiceMemo(index: 0, action: .playButtonTapped)) { $0.voiceMemos[0].mode = VoiceMemo.Mode.playing(progress: 0) }, - .do { self.scheduler.advance(by: 1) }, + .do { self.scheduler.advance(by: 0.5) }, .receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(0.5))) { $0.voiceMemos[0].mode = .playing(progress: 0.5) }, + .do { self.scheduler.advance(by: 0.5) }, + .receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) { + $0.voiceMemos[0].mode = .playing(progress: 1) + }, .receive( .voiceMemo( index: 0, @@ -189,9 +193,6 @@ class VoiceMemosTests: XCTestCase { ) ) { $0.voiceMemos[0].mode = .notPlaying - }, - .receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) { - $0.voiceMemos[0].mode = .notPlaying } ) } diff --git a/Sources/ComposableArchitecture/TestSupport/TestStore.swift b/Sources/ComposableArchitecture/TestSupport/TestStore.swift index e998ec7f1e23..582803ce4d7e 100644 --- a/Sources/ComposableArchitecture/TestSupport/TestStore.swift +++ b/Sources/ComposableArchitecture/TestSupport/TestStore.swift @@ -168,17 +168,17 @@ private let toLocalState: (State) -> LocalState private init( + environment: Environment, + fromLocalAction: @escaping (LocalAction) -> Action, initialState: State, reducer: Reducer, - environment: Environment, - state toLocalState: @escaping (State) -> LocalState, - action fromLocalAction: @escaping (LocalAction) -> Action + toLocalState: @escaping (State) -> LocalState ) { + self.environment = environment + self.fromLocalAction = fromLocalAction self.state = initialState self.reducer = reducer - self.environment = environment self.toLocalState = toLocalState - self.fromLocalAction = fromLocalAction } } @@ -195,11 +195,11 @@ environment: Environment ) { self.init( + environment: environment, + fromLocalAction: { $0 }, initialState: initialState, reducer: reducer, - environment: environment, - state: { $0 }, - action: { $0 } + toLocalState: { $0 } ) } } @@ -220,34 +220,62 @@ file: StaticString = #file, line: UInt = #line ) { - var receivedActions: [Action] = [] - - var cancellables: [String: [AnyCancellable]] = [:] - - func runReducer(action: Action) { - let actionKey = debugCaseOutput(action) - - let effect = self.reducer.run(&self.state, action, self.environment) - var isComplete = false - var cancellable: AnyCancellable? - cancellable = effect.sink( - receiveCompletion: { _ in - isComplete = true - guard let cancellable = cancellable else { return } - cancellables[actionKey]?.removeAll(where: { $0 == cancellable }) - }, - receiveValue: { - receivedActions.append($0) + var receivedActions: [(action: Action, state: State)] = [] + var longLivingEffects: [String: Set] = [:] + var snapshotState = self.state + + let store = Store( + initialState: self.state, + reducer: Reducer { state, action, _ in + let effects: Effect + switch action { + case let .send(localAction): + effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment) + snapshotState = state + + case let .receive(action): + effects = self.reducer.run(&state, action, self.environment) + receivedActions.append((action, state)) } - ) - if !isComplete, let cancellable = cancellable { - cancellables[actionKey] = cancellables[actionKey] ?? [] - cancellables[actionKey]?.append(cancellable) - } - } + + let key = debugCaseOutput(action) + let id = UUID() + return + effects + .handleEvents( + receiveSubscription: { _ in longLivingEffects[key, default: []].insert(id) }, + receiveCompletion: { _ in longLivingEffects[key]?.remove(id) }, + receiveCancel: { longLivingEffects[key]?.remove(id) } + ) + .map(TestAction.receive) + .eraseToEffect() + + }, + environment: () + ) + defer { self.state = store.state.value } + + let viewStore = ViewStore( + store.scope(state: self.toLocalState, action: TestAction.send) + ) for step in steps { - var expectedState = toLocalState(state) + var expectedState = toLocalState(snapshotState) + + func expectedStateShouldMatch(actualState: LocalState) { + if expectedState != actualState { + let diff = + debugDiff(expectedState, actualState) + .map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } + ?? "" + _XCTFail( + """ + State change does not match expectation\(diff) + """, + file: step.file, line: step.line + ) + } + } switch step.type { case let .send(action, update): @@ -262,8 +290,9 @@ file: step.file, line: step.line ) } - runReducer(action: self.fromLocalAction(action)) + viewStore.send(action) update(&expectedState) + expectedStateShouldMatch(actualState: toLocalState(snapshotState)) case let .receive(expectedAction, update): guard !receivedActions.isEmpty else { @@ -271,12 +300,11 @@ """ Expected to receive an action, but received none. """, - file: step.file, - line: step.line + file: step.file, line: step.line ) break } - let receivedAction = receivedActions.removeFirst() + let (receivedAction, state) = receivedActions.removeFirst() if expectedAction != receivedAction { let diff = debugDiff(expectedAction, receivedAction) @@ -286,12 +314,12 @@ """ Received unexpected action\(diff) """, - file: step.file, - line: step.line + file: step.file, line: step.line ) } - runReducer(action: receivedAction) update(&expectedState) + expectedStateShouldMatch(actualState: toLocalState(state)) + snapshotState = state case let .environment(work): if !receivedActions.isEmpty { @@ -305,23 +333,21 @@ file: step.file, line: step.line ) } - work(&self.environment) - } - let actualState = self.toLocalState(self.state) - if expectedState != actualState { - let diff = - debugDiff(expectedState, actualState) - .map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } - ?? "" - _XCTFail( - """ - State change does not match expectation\(diff) - """, - file: step.file, - line: step.line - ) + case let .do(work): + if !receivedActions.isEmpty { + _XCTFail( + """ + Must handle \(receivedActions.count) received \ + action\(receivedActions.count == 1 ? "" : "s") before performing this work: … + + Unhandled actions: \(debugOutput(receivedActions)) + """, + file: step.file, line: step.line + ) + } + work() } } @@ -333,12 +359,11 @@ Unhandled actions: \(debugOutput(receivedActions)) """, - file: file, - line: line + file: file, line: line ) } - let unfinishedActions = cancellables.filter { !$0.value.isEmpty }.map { $0.key } + let unfinishedActions = longLivingEffects.filter { !$0.value.isEmpty }.map { $0.key } if unfinishedActions.count > 0 { let initiatingActions = unfinishedActions.map { "• \($0)" }.joined(separator: "\n") let pluralSuffix = unfinishedActions.count == 1 ? "" : "s" @@ -363,8 +388,7 @@ ensure those effects are completed by returning an `Effect.cancel` effect from a \ particular action in your reducer, and sending that action in the test. """, - file: file, - line: line + file: file, line: line ) } } @@ -385,11 +409,11 @@ action fromLocalAction: @escaping (A) -> LocalAction ) -> TestStore { .init( + environment: self.environment, + fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) }, initialState: self.state, reducer: self.reducer, - environment: self.environment, - state: { toLocalState(self.toLocalState($0)) }, - action: { self.fromLocalAction(fromLocalAction($0)) } + toLocalState: { toLocalState(self.toLocalState($0)) } ) } @@ -476,15 +500,21 @@ line: UInt = #line, _ work: @escaping () -> Void ) -> Step { - self.environment(file: file, line: line) { _ in work() } + Step(.do(work), file: file, line: line) } fileprivate enum StepType { case send(LocalAction, (inout LocalState) -> Void) case receive(Action, (inout LocalState) -> Void) case environment((inout Environment) -> Void) + case `do`(() -> Void) } } + + private enum TestAction { + case send(LocalAction) + case receive(Action) + } } // NB: Dynamically load XCTest to prevent leaking its symbols into our library code. @@ -525,5 +555,4 @@ _XCTest .flatMap { dlsym($0, "_XCTCurrentTestCase") } .map({ unsafeBitCast($0, to: XCTCurrentTestCase.self) }) - #endif diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift new file mode 100644 index 000000000000..b02c919c0391 --- /dev/null +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -0,0 +1,64 @@ +import Combine +import ComposableArchitecture +import XCTest + +class TestStoreTests: XCTestCase { + func testEffectConcatenation() { + struct State: Equatable {} + + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } + + let testScheduler = DispatchQueue.testScheduler + + let reducer = Reducer> { _, action, scheduler in + switch action { + case .a: + return .merge( + Effect.concatenate(.init(value: .b1), .init(value: .c1)) + .delay(for: 1, scheduler: scheduler) + .eraseToEffect(), + Empty(completeImmediately: false) + .eraseToEffect() + .cancellable(id: 1) + ) + case .b1: + return + Effect + .concatenate(.init(value: .b2), .init(value: .b3)) + case .c1: + return + Effect + .concatenate(.init(value: .c2), .init(value: .c3)) + case .b2, .b3, .c2, .c3: + return .none + + case .d: + return .cancel(id: 1) + } + } + + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: testScheduler.eraseToAnyScheduler() + ) + + store.assert( + .send(.a), + + .do { testScheduler.advance(by: 1) }, + + .receive(.b1), + .receive(.b2), + .receive(.b3), + + .receive(.c1), + .receive(.c2), + .receive(.c3), + + .send(.d) + ) + } +}