diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf65cb745..82f3457d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,19 +45,7 @@ jobs: name: SwiftPM Linux runs-on: ubuntu-latest steps: - - name: Install Swift - run: | - eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" - - name: Checkout - uses: actions/checkout@v2 - - name: Pull dependencies - run: | - swift package resolve - name: Swift version run: swift --version - - name: Options - run: swift test --help - - name: List Tests - run: swift test --enable-test-discovery --list-tests - name: Test via SwiftPM run: swift test --enable-test-discovery diff --git a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift index 6af836821..32fdfb76c 100644 --- a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift +++ b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift @@ -2,154 +2,157 @@ import ComposableArchitecture import ReactiveSwift import XCTest -@MainActor -final class ComposableArchitectureTests: XCTestCase { - func testScheduling() async { - enum CounterAction: Equatable { - case incrAndSquareLater - case incrNow - case squareNow - } +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ComposableArchitectureTests: XCTestCase { + func testScheduling() async { + enum CounterAction: Equatable { + case incrAndSquareLater + case incrNow + case squareNow + } - let counterReducer = Reducer { - state, action, scheduler in - switch action { - case .incrAndSquareLater: - return .merge( - Effect(value: .incrNow).deferred(for: 2, scheduler: scheduler), - Effect(value: .squareNow).deferred(for: 1, scheduler: scheduler), - Effect(value: .squareNow).deferred(for: 2, scheduler: scheduler) - ) - case .incrNow: - state += 1 - return .none - case .squareNow: - state *= state - return .none + let counterReducer = Reducer { + state, action, scheduler in + switch action { + case .incrAndSquareLater: + return .merge( + Effect(value: .incrNow).deferred(for: 2, scheduler: scheduler), + Effect(value: .squareNow).deferred(for: 1, scheduler: scheduler), + Effect(value: .squareNow).deferred(for: 2, scheduler: scheduler) + ) + case .incrNow: + state += 1 + return .none + case .squareNow: + state *= state + return .none + } } + + let mainQueue = TestScheduler() + + let store = TestStore( + initialState: 2, + reducer: counterReducer, + environment: mainQueue + ) + + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 1) + await store.receive(.squareNow) { $0 = 4 } + await mainQueue.advance(by: 1) + await store.receive(.incrNow) { $0 = 5 } + await store.receive(.squareNow) { $0 = 25 } + + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 2) + await store.receive(.squareNow) { $0 = 625 } + await store.receive(.incrNow) { $0 = 626 } + await store.receive(.squareNow) { $0 = 391876 } } - let mainQueue = TestScheduler() - - let store = TestStore( - initialState: 2, - reducer: counterReducer, - environment: mainQueue - ) - - await store.send(.incrAndSquareLater) - await mainQueue.advance(by: 1) - await store.receive(.squareNow) { $0 = 4 } - await mainQueue.advance(by: 1) - await store.receive(.incrNow) { $0 = 5 } - await store.receive(.squareNow) { $0 = 25 } - - await store.send(.incrAndSquareLater) - await mainQueue.advance(by: 2) - await store.receive(.squareNow) { $0 = 625 } - await store.receive(.incrNow) { $0 = 626 } - await store.receive(.squareNow) { $0 = 391876 } - } + func testSimultaneousWorkOrdering() { + let testScheduler = TestScheduler() - func testSimultaneousWorkOrdering() { - let testScheduler = TestScheduler() + var values: [Int] = [] + testScheduler.schedule(after: .seconds(0), interval: .seconds(1)) { values.append(1) } + testScheduler.schedule(after: .seconds(0), interval: .seconds(2)) { values.append(42) } - var values: [Int] = [] - testScheduler.schedule(after: .seconds(0), interval: .seconds(1)) { values.append(1) } - testScheduler.schedule(after: .seconds(0), interval: .seconds(2)) { values.append(42) } + XCTAssertNoDifference(values, []) + testScheduler.advance() + XCTAssertNoDifference(values, [1, 42]) + testScheduler.advance(by: 2) + XCTAssertNoDifference(values, [1, 42, 1, 42, 1]) + } - XCTAssertNoDifference(values, []) - testScheduler.advance() - XCTAssertNoDifference(values, [1, 42]) - testScheduler.advance(by: 2) - XCTAssertNoDifference(values, [1, 42, 1, 42, 1]) - } + func testLongLivingEffects() async { + typealias Environment = ( + startEffect: Effect, + stopEffect: Effect + ) - func testLongLivingEffects() async { - typealias Environment = ( - startEffect: Effect, - stopEffect: Effect - ) - - enum Action { case end, incr, start } - - let reducer = Reducer { state, action, environment in - switch action { - case .end: - return environment.stopEffect.fireAndForget() - case .incr: - state += 1 - return .none - case .start: - return environment.startEffect.map { Action.incr } + enum Action { case end, incr, start } + + let reducer = Reducer { state, action, environment in + switch action { + case .end: + return environment.stopEffect.fireAndForget() + case .incr: + state += 1 + return .none + case .start: + return environment.startEffect.map { Action.incr } + } } - } - let subject = Signal.pipe() + let subject = Signal.pipe() - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: ( - startEffect: subject.output.producer.eraseToEffect(), - stopEffect: .fireAndForget { subject.input.sendCompleted() } + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: ( + startEffect: subject.output.producer.eraseToEffect(), + stopEffect: .fireAndForget { subject.input.sendCompleted() } + ) ) - ) - await store.send(.start) - await store.send(.incr) { $0 = 1 } - subject.input.send(value: ()) - await store.receive(.incr) { $0 = 2 } - await store.send(.end) - } + await store.send(.start) + await store.send(.incr) { $0 = 1 } + subject.input.send(value: ()) + await store.receive(.incr) { $0 = 2 } + await store.send(.end) + } - func testCancellation() async { - let mainQueue = TestScheduler() + func testCancellation() async { + let mainQueue = TestScheduler() - enum Action: Equatable { - case cancel - case incr - case response(Int) - } + enum Action: Equatable { + case cancel + case incr + case response(Int) + } - struct Environment { - let fetch: (Int) async -> Int - } + struct Environment { + let fetch: (Int) async -> Int + } - let reducer = Reducer { state, action, environment in - enum CancelID {} + let reducer = Reducer { state, action, environment in + enum CancelID {} - switch action { - case .cancel: - return .cancel(id: CancelID.self) + switch action { + case .cancel: + return .cancel(id: CancelID.self) - case .incr: - state += 1 - return .task { [state] in - try await mainQueue.sleep(for: .seconds(1)) - return .response(await environment.fetch(state)) - } - .cancellable(id: CancelID.self) + case .incr: + state += 1 + return .task { [state] in + try await mainQueue.sleep(for: .seconds(1)) + return .response(await environment.fetch(state)) + } + .cancellable(id: CancelID.self) - case let .response(value): - state = value - return .none + case let .response(value): + state = value + return .none + } } - } - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: Environment( - fetch: { value in value * value } + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: Environment( + fetch: { value in value * value } + ) ) - ) - await store.send(.incr) { $0 = 1 } - await mainQueue.advance(by: .seconds(1)) - await store.receive(.response(1)) + await store.send(.incr) { $0 = 1 } + await mainQueue.advance(by: .seconds(1)) + await store.receive(.response(1)) - await store.send(.incr) { $0 = 2 } - await store.send(.cancel) + await store.send(.incr) { $0 = 2 } + await store.send(.cancel) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift index e9f63ddbc..2ae2c45f2 100644 --- a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift +++ b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift @@ -3,86 +3,89 @@ import XCTest @testable import ComposableArchitecture -@MainActor -final class EffectDebounceTests: XCTestCase { - func testDebounce() async { - let mainQueue = TestScheduler() - var values: [Int] = [] - - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runDebouncedEffect(value: Int) { - struct CancelToken: Hashable {} - - Effect(value: value) - .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) - .producer - .startWithValues { values.append($0) } - } +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectDebounceTests: XCTestCase { + func testDebounce() async { + let mainQueue = TestScheduler() + var values: [Int] = [] + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { + struct CancelToken: Hashable {} + + Effect(value: value) + .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) + .producer + .startWithValues { values.append($0) } + } - runDebouncedEffect(value: 1) + runDebouncedEffect(value: 1) - // Nothing emits right away. - XCTAssertNoDifference(values, []) + // Nothing emits right away. + XCTAssertNoDifference(values, []) - // Waiting half the time also emits nothing - await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Waiting half the time also emits nothing + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Run another debounced effect. - runDebouncedEffect(value: 2) + // Run another debounced effect. + runDebouncedEffect(value: 2) - // Waiting half the time emits nothing because the first debounced effect has been canceled. - await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Waiting half the time emits nothing because the first debounced effect has been canceled. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Run another debounced effect. - runDebouncedEffect(value: 3) + // Run another debounced effect. + runDebouncedEffect(value: 3) - // Waiting half the time emits nothing because the second debounced effect has been canceled. - await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Waiting half the time emits nothing because the second debounced effect has been canceled. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Waiting the rest of the time emits the final effect value. - await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, [3]) + // Waiting the rest of the time emits the final effect value. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, [3]) - // Running out the scheduler - await mainQueue.run() - XCTAssertNoDifference(values, [3]) - } + // Running out the scheduler + await mainQueue.run() + XCTAssertNoDifference(values, [3]) + } - func testDebounceIsLazy() async { - let mainQueue = TestScheduler() - var values: [Int] = [] - var effectRuns = 0 + func testDebounceIsLazy() async { + let mainQueue = TestScheduler() + var values: [Int] = [] + var effectRuns = 0 - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runDebouncedEffect(value: Int) { - struct CancelToken: Hashable {} + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { + struct CancelToken: Hashable {} - SignalProducer.deferred { () -> SignalProducer in - effectRuns += 1 - return .init(value: value) + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) + .producer + .startWithValues { values.append($0) } } - .eraseToEffect() - .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) - .producer - .startWithValues { values.append($0) } - } - runDebouncedEffect(value: 1) + runDebouncedEffect(value: 1) - XCTAssertNoDifference(values, []) - XCTAssertNoDifference(effectRuns, 0) + XCTAssertNoDifference(values, []) + XCTAssertNoDifference(effectRuns, 0) - await mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) - XCTAssertNoDifference(effectRuns, 0) + XCTAssertNoDifference(values, []) + XCTAssertNoDifference(effectRuns, 0) - await mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(effectRuns, 1) + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(effectRuns, 1) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectRunTests.swift b/Tests/ComposableArchitectureTests/EffectRunTests.swift index 184dba1e9..0024754f0 100644 --- a/Tests/ComposableArchitectureTests/EffectRunTests.swift +++ b/Tests/ComposableArchitectureTests/EffectRunTests.swift @@ -6,119 +6,122 @@ import XCTest import CDispatch #endif -@MainActor -final class EffectRunTests: XCTestCase { - func testRun() async { - struct State: Equatable {} - enum Action: Equatable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .run { send in await send(.response) } - case .response: - return .none +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectRunTests: XCTestCase { + func testRun() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in await send(.response) } + case .response: + return .none + } } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped) - await store.receive(.response) - } - func testRunCatch() async { - struct State: Equatable {} - enum Action: Equatable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .run { _ in - struct Failure: Error {} - throw Failure() - } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 - await send(.response) + func testRunCatch() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { _ in + struct Failure: Error {} + throw Failure() + } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 + await send(.response) + } + case .response: + return .none } - case .response: - return .none } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped) - await store.receive(.response) - } - // `XCTExpectFailure` is not supported on Linux - #if !os(Linux) - func testRunUnhandledFailure() async { - XCTExpectFailure(nil, enabled: nil, strict: nil) { - $0.compactDescription == """ - An 'Effect.run' returned from "ComposableArchitectureTests/EffectRunTests.swift:67" threw \ - an unhandled error. … + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testRunUnhandledFailure() async { + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An 'Effect.run' returned from "ComposableArchitectureTests/EffectRunTests.swift:69" threw \ + an unhandled error. … - EffectRunTests.Failure() + EffectRunTests.Failure() - All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ - 'Effect.run', or via a 'do' block. - """ + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.run', or via a 'do' block. + """ + } + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + // NB: We wait a long time here because XCTest failures take a long time to generate + await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) } + #endif + + func testRunCancellation() async { + enum CancelID {} struct State: Equatable {} enum Action: Equatable { case tapped, response } let reducer = Reducer { state, action, _ in switch action { case .tapped: return .run { send in - struct Failure: Error {} - throw Failure() + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + await send(.response) } + .cancellable(id: CancelID.self) case .response: return .none } } let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - // NB: We wait a long time here because XCTest failures take a long time to generate - await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) + await store.send(.tapped).finish() } - #endif - func testRunCancellation() async { - enum CancelID {} - struct State: Equatable {} - enum Action: Equatable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .run { send in - await Task.cancel(id: CancelID.self) - try Task.checkCancellation() - await send(.response) - } - .cancellable(id: CancelID.self) - case .response: - return .none - } - } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped).finish() - } - - func testRunCancellationCatch() async { - enum CancelID {} - struct State: Equatable {} - enum Action: Equatable { case tapped, responseA, responseB } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .run { send in - await Task.cancel(id: CancelID.self) - try Task.checkCancellation() - await send(.responseA) - } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 - await send(.responseB) + func testRunCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + await send(.responseA) + } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 + await send(.responseB) + } + .cancellable(id: CancelID.self) + case .responseA, .responseB: + return .none } - .cancellable(id: CancelID.self) - case .responseA, .responseB: - return .none } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped).finish() } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectTaskTests.swift b/Tests/ComposableArchitectureTests/EffectTaskTests.swift index f16fe0aa9..a91ba9084 100644 --- a/Tests/ComposableArchitectureTests/EffectTaskTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTaskTests.swift @@ -2,123 +2,122 @@ import XCTest @testable import ComposableArchitecture -#if os(Linux) - import CDispatch -#endif - -@MainActor -final class EffectTaskTests: XCTestCase { - func testTask() async { - struct State: Equatable {} - enum Action: Equatable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .task { .response } - case .response: - return .none +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectTaskTests: XCTestCase { + func testTask() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { .response } + case .response: + return .none + } } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped) - await store.receive(.response) - } - func testTaskCatch() async { - struct State: Equatable {} - enum Action: Equatable, Sendable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .task { - struct Failure: Error {} - throw Failure() - } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 - .response + func testTaskCatch() async { + struct State: Equatable {} + enum Action: Equatable, Sendable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + struct Failure: Error {} + throw Failure() + } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 + .response + } + case .response: + return .none } - case .response: - return .none } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped) - await store.receive(.response) - } - // `XCTExpectFailure` is not supported on Linux - #if !os(Linux) - func testTaskUnhandledFailure() async { - XCTExpectFailure(nil, enabled: nil, strict: nil) { - $0.compactDescription == """ - An 'Effect.task' returned from "ComposableArchitectureTests/EffectTaskTests.swift:67" \ - threw an unhandled error. … + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testTaskUnhandledFailure() async { + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An 'Effect.task' returned from "ComposableArchitectureTests/EffectTaskTests.swift:65" \ + threw an unhandled error. … - EffectTaskTests.Failure() + EffectTaskTests.Failure() - All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ - 'Effect.task', or via a 'do' block. - """ + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.task', or via a 'do' block. + """ + } + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + // NB: We wait a long time here because XCTest failures take a long time to generate + await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) } + #endif + + func testTaskCancellation() async { + enum CancelID {} struct State: Equatable {} enum Action: Equatable { case tapped, response } let reducer = Reducer { state, action, _ in switch action { case .tapped: return .task { - struct Failure: Error {} - throw Failure() + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + return .response } + .cancellable(id: CancelID.self) case .response: return .none } } let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - // NB: We wait a long time here because XCTest failures take a long time to generate - await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) - } - #endif - - func testTaskCancellation() async { - enum CancelID {} - struct State: Equatable {} - enum Action: Equatable { case tapped, response } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .task { - await Task.cancel(id: CancelID.self) - try Task.checkCancellation() - return .response - } - .cancellable(id: CancelID.self) - case .response: - return .none - } + await store.send(.tapped).finish() } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped).finish() - } - func testTaskCancellationCatch() async { - enum CancelID {} - struct State: Equatable {} - enum Action: Equatable { case tapped, responseA, responseB } - let reducer = Reducer { state, action, _ in - switch action { - case .tapped: - return .task { - await Task.cancel(id: CancelID.self) - try Task.checkCancellation() - return .responseA - } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 - .responseB + func testTaskCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + return .responseA + } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 + .responseB + } + .cancellable(id: CancelID.self) + case .responseA, .responseB: + return .none } - .cancellable(id: CancelID.self) - case .responseA, .responseB: - return .none } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() } - let store = TestStore(initialState: State(), reducer: reducer, environment: ()) - await store.send(.tapped).finish() } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index cf3c76d2c..7ce1772b2 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -3,246 +3,245 @@ import XCTest @testable import ComposableArchitecture -#if os(Linux) - import CDispatch -#endif - -@MainActor -final class EffectTests: XCTestCase { - let mainQueue = TestScheduler() - - func testEraseToEffectWithError() { - struct Error: Swift.Error, Equatable {} - - SignalProducer(result: .success(42)) - .startWithResult { XCTAssertNoDifference($0, .success(42)) } - - SignalProducer(result: .failure(Error())) - .startWithResult { XCTAssertNoDifference($0, .failure(Error())) } - - SignalProducer(result: .success(42)) - .startWithResult { XCTAssertNoDifference($0, .success(42)) } - - SignalProducer(result: .success(42)) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectTests: XCTestCase { + let mainQueue = TestScheduler() + + func testEraseToEffectWithError() { + struct Error: Swift.Error, Equatable {} + + SignalProducer(result: .success(42)) + .startWithResult { XCTAssertNoDifference($0, .success(42)) } + + SignalProducer(result: .failure(Error())) + .startWithResult { XCTAssertNoDifference($0, .failure(Error())) } + + SignalProducer(result: .success(42)) + .startWithResult { XCTAssertNoDifference($0, .success(42)) } + + SignalProducer(result: .success(42)) + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } - .producer - .startWithValues { XCTAssertNoDifference($0, 42) } - - SignalProducer(result: .failure(Error())) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 + .producer + .startWithValues { XCTAssertNoDifference($0, 42) } + + SignalProducer(result: .failure(Error())) + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } - .producer - .startWithValues { XCTAssertNoDifference($0, -1) } - } - - func testConcatenate() { - var values: [Int] = [] + .producer + .startWithValues { XCTAssertNoDifference($0, -1) } + } - let effect = Effect.concatenate( - Effect(value: 1).deferred(for: 1, scheduler: mainQueue), - Effect(value: 2).deferred(for: 2, scheduler: mainQueue), - Effect(value: 3).deferred(for: 3, scheduler: mainQueue) - ) + func testConcatenate() { + var values: [Int] = [] - effect.producer.startWithValues { values.append($0) } + let effect = Effect.concatenate( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue), + Effect(value: 2).deferred(for: 2, scheduler: mainQueue), + Effect(value: 3).deferred(for: 3, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.advance(by: 2) - XCTAssertNoDifference(values, [1, 2]) + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - self.mainQueue.advance(by: 3) - XCTAssertNoDifference(values, [1, 2, 3]) + self.mainQueue.advance(by: 2) + XCTAssertNoDifference(values, [1, 2]) - self.mainQueue.run() - XCTAssertNoDifference(values, [1, 2, 3]) - } + self.mainQueue.advance(by: 3) + XCTAssertNoDifference(values, [1, 2, 3]) - func testConcatenateOneEffect() { - var values: [Int] = [] + self.mainQueue.run() + XCTAssertNoDifference(values, [1, 2, 3]) + } - let effect = Effect.concatenate( - Effect(value: 1).deferred(for: 1, scheduler: mainQueue) - ) + func testConcatenateOneEffect() { + var values: [Int] = [] - effect.producer.startWithValues { values.append($0) } + let effect = Effect.concatenate( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.run() - XCTAssertNoDifference(values, [1]) - } + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - func testMerge() { - let effect = Effect.merge( - Effect(value: 1).deferred(for: 1, scheduler: mainQueue), - Effect(value: 2).deferred(for: 2, scheduler: mainQueue), - Effect(value: 3).deferred(for: 3, scheduler: mainQueue) - ) + self.mainQueue.run() + XCTAssertNoDifference(values, [1]) + } - var values: [Int] = [] - effect.producer.startWithValues { values.append($0) } + func testMerge() { + let effect = Effect.merge( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue), + Effect(value: 2).deferred(for: 2, scheduler: mainQueue), + Effect(value: 3).deferred(for: 3, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + var values: [Int] = [] + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2]) + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2, 3]) - } - - func testEffectRunInitializer() { - let effect = Effect.run { observer in - observer.send(value: 1) - observer.send(value: 2) - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { - observer.send(value: 3) - } - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(2)) { - observer.send(value: 4) - observer.sendCompleted() - } + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1, 2]) - return AnyDisposable() + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1, 2, 3]) } - var values: [Int] = [] - var isComplete = false - effect - .producer - .on(completed: { isComplete = true }, value: { values.append($0) }) - .start() + func testEffectRunInitializer() { + let effect = Effect.run { observer in + observer.send(value: 1) + observer.send(value: 2) + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { + observer.send(value: 3) + } + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(2)) { + observer.send(value: 4) + observer.sendCompleted() + } - XCTAssertNoDifference(values, [1, 2]) - XCTAssertNoDifference(isComplete, false) + return AnyDisposable() + } - self.mainQueue.advance(by: 1) + var values: [Int] = [] + var isComplete = false + effect + .producer + .on(completed: { isComplete = true }, value: { values.append($0) }) + .start() - XCTAssertNoDifference(values, [1, 2, 3]) - XCTAssertNoDifference(isComplete, false) + XCTAssertNoDifference(values, [1, 2]) + XCTAssertNoDifference(isComplete, false) - self.mainQueue.advance(by: 1) + self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2, 3, 4]) - XCTAssertNoDifference(isComplete, true) - } + XCTAssertNoDifference(values, [1, 2, 3]) + XCTAssertNoDifference(isComplete, false) - func testEffectRunInitializer_WithCancellation() { - enum CancelID {} + self.mainQueue.advance(by: 1) - let effect = Effect.run { subscriber in - subscriber.send(value: 1) - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { - subscriber.send(value: 2) - } - return AnyDisposable() + XCTAssertNoDifference(values, [1, 2, 3, 4]) + XCTAssertNoDifference(isComplete, true) } - .cancellable(id: CancelID.self) - var values: [Int] = [] - var isComplete = false - effect - .producer - .on(completed: { isComplete = true }) - .startWithValues { values.append($0) } + func testEffectRunInitializer_WithCancellation() { + enum CancelID {} - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(isComplete, false) - - Effect.cancel(id: CancelID.self) - .producer - .startWithValues { _ in } - - self.mainQueue.advance(by: 1) + let effect = Effect.run { subscriber in + subscriber.send(value: 1) + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { + subscriber.send(value: 2) + } + return AnyDisposable() + } + .cancellable(id: CancelID.self) - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(isComplete, true) - } + var values: [Int] = [] + var isComplete = false + effect + .producer + .on(completed: { isComplete = true }) + .startWithValues { values.append($0) } - func testDoubleCancelInFlight() { - var result: Int? + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(isComplete, false) - _ = Effect(value: 42) - .cancellable(id: "id", cancelInFlight: true) - .cancellable(id: "id", cancelInFlight: true) - .producer - .startWithValues { result = $0 } + Effect.cancel(id: CancelID.self) + .producer + .startWithValues { _ in } - XCTAssertEqual(result, 42) - } + self.mainQueue.advance(by: 1) - #if !os(Linux) - func testUnimplemented() { - let effect = Effect.failing("unimplemented") - _ = XCTExpectFailure { - effect - .producer - .start() - } issueMatcher: { issue in - issue.compactDescription == "unimplemented - An unimplemented effect ran." - } + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(isComplete, true) } - #endif - #if canImport(_Concurrency) && compiler(>=5.5.2) - func testTask() { - let expectation = self.expectation(description: "Complete") + func testDoubleCancelInFlight() { var result: Int? - Effect.task { @MainActor in - expectation.fulfill() - return 42 - } - .producer - .startWithValues { result = $0 } - self.wait(for: [expectation], timeout: 1) - XCTAssertNoDifference(result, 42) + + _ = Effect(value: 42) + .cancellable(id: "id", cancelInFlight: true) + .cancellable(id: "id", cancelInFlight: true) + .producer + .startWithValues { result = $0 } + + XCTAssertEqual(result, 42) } - func testCancellingTask_Infallible() { - @Sendable func work() async -> Int { - do { - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTFail() - } catch { + #if !os(Linux) + func testUnimplemented() { + let effect = Effect.failing("unimplemented") + _ = XCTExpectFailure { + effect + .producer + .start() + } issueMatcher: { issue in + issue.compactDescription == "unimplemented - An unimplemented effect ran." } - return 42 } - - let disposable = Effect.task { await work() } + #endif + + #if canImport(_Concurrency) && compiler(>=5.5.2) + func testTask() { + let expectation = self.expectation(description: "Complete") + var result: Int? + Effect.task { @MainActor in + expectation.fulfill() + return 42 + } .producer - .on( - completed: { XCTFail() }, - value: { _ in XCTFail() } - ) - .start(on: QueueScheduler.main) - .start() + .startWithValues { result = $0 } + self.wait(for: [expectation], timeout: 1) + XCTAssertNoDifference(result, 42) + } - disposable.dispose() + func testCancellingTask_Infallible() { + @Sendable func work() async -> Int { + do { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTFail() + } catch { + } + return 42 + } - _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) - } - #endif -} + let disposable = Effect.task { await work() } + .producer + .on( + completed: { XCTFail() }, + value: { _ in XCTFail() } + ) + .start(on: QueueScheduler.main) + .start() + + disposable.dispose() + + _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) + } + #endif + } +#endif diff --git a/Tests/ComposableArchitectureTests/EffectThrottleTests.swift b/Tests/ComposableArchitectureTests/EffectThrottleTests.swift index 9b4644a7c..61c814ece 100644 --- a/Tests/ComposableArchitectureTests/EffectThrottleTests.swift +++ b/Tests/ComposableArchitectureTests/EffectThrottleTests.swift @@ -3,209 +3,212 @@ import XCTest @testable import ComposableArchitecture -@MainActor -final class EffectThrottleTests: XCTestCase { - let mainQueue = TestScheduler() - - func testThrottleLatest() async { - var values: [Int] = [] - var effectRuns = 0 - - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runThrottledEffect(value: Int) { - enum CancelToken {} - - SignalProducer.deferred { () -> SignalProducer in - effectRuns += 1 - return .init(value: value) +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectThrottleTests: XCTestCase { + let mainQueue = TestScheduler() + + func testThrottleLatest() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) + .producer + .startWithValues { values.append($0) } } - .eraseToEffect() - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) - .producer - .startWithValues { values.append($0) } - } - - runThrottledEffect(value: 1) - await mainQueue.advance() + runThrottledEffect(value: 1) - // A value emits right away. - XCTAssertNoDifference(values, [1]) + await mainQueue.advance() - runThrottledEffect(value: 2) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - await mainQueue.advance() + runThrottledEffect(value: 2) - // A second value is throttled. - XCTAssertNoDifference(values, [1]) + await mainQueue.advance() - await mainQueue.advance(by: 0.25) + // A second value is throttled. + XCTAssertNoDifference(values, [1]) - runThrottledEffect(value: 3) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 3) - runThrottledEffect(value: 4) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 4) - runThrottledEffect(value: 5) + await mainQueue.advance(by: 0.25) - // A third value is throttled. - XCTAssertNoDifference(values, [1]) + runThrottledEffect(value: 5) - await mainQueue.advance(by: 0.25) + // A third value is throttled. + XCTAssertNoDifference(values, [1]) - // The latest value emits. - XCTAssertNoDifference(values, [1, 5]) - } - - func testThrottleFirst() async { - var values: [Int] = [] - var effectRuns = 0 - - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runThrottledEffect(value: Int) { - enum CancelToken {} + await mainQueue.advance(by: 0.25) - SignalProducer.deferred { () -> SignalProducer in - effectRuns += 1 - return .init(value: value) - } - .eraseToEffect() - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false) - .producer - .startWithValues { values.append($0) } + // The latest value emits. + XCTAssertNoDifference(values, [1, 5]) } - runThrottledEffect(value: 1) - - await mainQueue.advance() - - // A value emits right away. - XCTAssertNoDifference(values, [1]) + func testThrottleFirst() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false) + .producer + .startWithValues { values.append($0) } + } - runThrottledEffect(value: 2) + runThrottledEffect(value: 1) - await mainQueue.advance() + await mainQueue.advance() - // A second value is throttled. - XCTAssertNoDifference(values, [1]) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 2) - runThrottledEffect(value: 3) + await mainQueue.advance() - await mainQueue.advance(by: 0.25) + // A second value is throttled. + XCTAssertNoDifference(values, [1]) - runThrottledEffect(value: 4) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 3) - runThrottledEffect(value: 5) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 4) - // The second (throttled) value emits. - XCTAssertNoDifference(values, [1, 2]) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.25) + runThrottledEffect(value: 5) - runThrottledEffect(value: 6) + await mainQueue.advance(by: 0.25) - await mainQueue.advance(by: 0.50) + // The second (throttled) value emits. + XCTAssertNoDifference(values, [1, 2]) - // A third value is throttled. - XCTAssertNoDifference(values, [1, 2]) + await mainQueue.advance(by: 0.25) - runThrottledEffect(value: 7) + runThrottledEffect(value: 6) - await mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.50) - // The third (throttled) value emits. - XCTAssertNoDifference(values, [1, 2, 6]) - } + // A third value is throttled. + XCTAssertNoDifference(values, [1, 2]) - func testThrottleAfterInterval() async { - var values: [Int] = [] - var effectRuns = 0 + runThrottledEffect(value: 7) - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runThrottledEffect(value: Int) { - enum CancelToken {} + await mainQueue.advance(by: 0.25) - SignalProducer.deferred { () -> SignalProducer in - effectRuns += 1 - return .init(value: value) - } - .eraseToEffect() - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) - .producer - .startWithValues { values.append($0) } + // The third (throttled) value emits. + XCTAssertNoDifference(values, [1, 2, 6]) } - runThrottledEffect(value: 1) - - await mainQueue.advance() + func testThrottleAfterInterval() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) + .producer + .startWithValues { values.append($0) } + } - // A value emits right away. - XCTAssertNoDifference(values, [1]) + runThrottledEffect(value: 1) - await mainQueue.advance(by: 2) + await mainQueue.advance() - runThrottledEffect(value: 2) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - await mainQueue.advance() + await mainQueue.advance(by: 2) - // A second value is emitted right away. - XCTAssertNoDifference(values, [1, 2]) + runThrottledEffect(value: 2) - await mainQueue.advance(by: 2) + await mainQueue.advance() - runThrottledEffect(value: 3) + // A second value is emitted right away. + XCTAssertNoDifference(values, [1, 2]) - await mainQueue.advance() + await mainQueue.advance(by: 2) - // A third value is emitted right away. - XCTAssertNoDifference(values, [1, 2, 3]) - } + runThrottledEffect(value: 3) - func testThrottleEmitsFirstValueOnce() async { - var values: [Int] = [] - var effectRuns = 0 + await mainQueue.advance() - // NB: Explicit @MainActor is needed for Swift 5.5.2 - @MainActor func runThrottledEffect(value: Int) { - enum CancelToken {} + // A third value is emitted right away. + XCTAssertNoDifference(values, [1, 2, 3]) + } - SignalProducer.deferred { () -> SignalProducer in - effectRuns += 1 - return .init(value: value) + func testThrottleEmitsFirstValueOnce() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle( + id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false + ) + .producer + .startWithValues { values.append($0) } } - .eraseToEffect() - .throttle( - id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false - ) - .producer - .startWithValues { values.append($0) } - } - runThrottledEffect(value: 1) + runThrottledEffect(value: 1) - await mainQueue.advance() + await mainQueue.advance() - // A value emits right away. - XCTAssertNoDifference(values, [1]) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - await mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - runThrottledEffect(value: 2) + runThrottledEffect(value: 2) - await mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - runThrottledEffect(value: 3) + runThrottledEffect(value: 3) - // A second value is emitted right away. - XCTAssertNoDifference(values, [1, 2]) + // A second value is emitted right away. + XCTAssertNoDifference(values, [1, 2]) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index 6c1599854..05355c224 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -4,226 +4,225 @@ import XCTest @testable import ComposableArchitecture -#if canImport(os) - import os.signpost -#endif - -@MainActor -final class ReducerTests: XCTestCase { - func testCallableAsFunction() { - let reducer = Reducer { state, _, _ in - state += 1 - return .none - } - - var state = 0 - _ = reducer.run(&state, (), ()) - XCTAssertNoDifference(state, 1) - } - - func testCombine_EffectsAreMerged() async { - typealias Scheduler = DateScheduler - enum Action: Equatable { - case increment - } - - var fastValue: Int? - let fastReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { fastValue = 42 } - .deferred(for: 1, scheduler: scheduler) - } - - var slowValue: Int? - let slowReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { slowValue = 1729 } - .deferred(for: 2, scheduler: scheduler) - } - - let mainQueue = TestScheduler() - let store = TestStore( - initialState: 0, - reducer: .combine(fastReducer, slowReducer), - environment: mainQueue - ) +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ReducerTests: XCTestCase { + func testCallableAsFunction() { + let reducer = Reducer { state, _, _ in + state += 1 + return .none + } - await store.send(.increment) { - $0 = 2 + var state = 0 + _ = reducer.run(&state, (), ()) + XCTAssertNoDifference(state, 1) } - // Waiting a second causes the fast effect to fire. - await mainQueue.advance(by: 1) - XCTAssertNoDifference(fastValue, 42) - // Waiting one more second causes the slow effect to fire. This proves that the effects - // are merged together, as opposed to concatenated. - await mainQueue.advance(by: 1) - XCTAssertNoDifference(slowValue, 1729) - } - func testCombine() async { - enum Action: Equatable { - case increment - } + func testCombine_EffectsAreMerged() async { + typealias Scheduler = DateScheduler + enum Action: Equatable { + case increment + } - var childEffectExecuted = false - let childReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { childEffectExecuted = true } - } + var fastValue: Int? + let fastReducer = Reducer { state, _, scheduler in + state += 1 + return Effect.fireAndForget { fastValue = 42 } + .deferred(for: 1, scheduler: scheduler) + } - var mainEffectExecuted = false - let mainReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { mainEffectExecuted = true } - } - .combined(with: childReducer) + var slowValue: Int? + let slowReducer = Reducer { state, _, scheduler in + state += 1 + return Effect.fireAndForget { slowValue = 1729 } + .deferred(for: 2, scheduler: scheduler) + } - let store = TestStore( - initialState: 0, - reducer: mainReducer, - environment: () - ) + let mainQueue = TestScheduler() + let store = TestStore( + initialState: 0, + reducer: .combine(fastReducer, slowReducer), + environment: mainQueue + ) - await store.send(.increment) { - $0 = 2 + await store.send(.increment) { + $0 = 2 + } + // Waiting a second causes the fast effect to fire. + await mainQueue.advance(by: 1) + XCTAssertNoDifference(fastValue, 42) + // Waiting one more second causes the slow effect to fire. This proves that the effects + // are merged together, as opposed to concatenated. + await mainQueue.advance(by: 1) + XCTAssertNoDifference(slowValue, 1729) } - XCTAssertTrue(childEffectExecuted) - XCTAssertTrue(mainEffectExecuted) - } - - func testDebug() async { - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") - logsExpectation.expectedFulfillmentCount = 2 + func testCombine() async { + enum Action: Equatable { + case increment + } - let reducer = Reducer { state, action, _ in - switch action { - case .incrWithBool: - return .none - case .incr: - state.count += 1 - return .none - case .noop: - return .none + var childEffectExecuted = false + let childReducer = Reducer { state, _, _ in + state += 1 + return Effect.fireAndForget { childEffectExecuted = true } } - } - .debug("[prefix]") { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() - } - ) - } - let store = TestStore( - initialState: .init(), - reducer: reducer, - environment: () - ) - await store.send(.incr) { $0.count = 1 } - await store.send(.noop) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertNoDifference( - logs, - [ - #""" - [prefix]: received action: - DebugAction.incr - - DebugState(count: 0) - + DebugState(count: 1) - - """#, - #""" - [prefix]: received action: - DebugAction.noop - (No state changes) - - """#, - ] - ) - } + var mainEffectExecuted = false + let mainReducer = Reducer { state, _, _ in + state += 1 + return Effect.fireAndForget { mainEffectExecuted = true } + } + .combined(with: childReducer) - func testDebug_ActionFormat_OnlyLabels() { - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") + let store = TestStore( + initialState: 0, + reducer: mainReducer, + environment: () + ) - let reducer = Reducer { state, action, _ in - switch action { - case let .incrWithBool(bool): - state.count += bool ? 1 : 0 - return .none - default: - return .none + await store.send(.increment) { + $0 = 2 } + + XCTAssertTrue(childEffectExecuted) + XCTAssertTrue(mainEffectExecuted) } - .debug("[prefix]", actionFormat: .labelsOnly) { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() + + func testDebug() async { + var logs: [String] = [] + let logsExpectation = self.expectation(description: "logs") + logsExpectation.expectedFulfillmentCount = 2 + + let reducer = Reducer { state, action, _ in + switch action { + case .incrWithBool: + return .none + case .incr: + state.count += 1 + return .none + case .noop: + return .none } - ) - } + } + .debug("[prefix]") { _ in + DebugEnvironment( + printer: { + logs.append($0) + logsExpectation.fulfill() + } + ) + } - let viewStore = ViewStore( - Store( + let store = TestStore( initialState: .init(), reducer: reducer, environment: () ) - ) - viewStore.send(.incrWithBool(true)) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertNoDifference( - logs, - [ - #""" - [prefix]: received action: - DebugAction.incrWithBool - - DebugState(count: 0) - + DebugState(count: 1) - - """# - ] - ) - } - - #if canImport(os) - @available(iOS 12.0, *) - func testDefaultSignpost() { - let reducer = Reducer.empty.signpost(log: .default) - var n = 0 - let effect = reducer.run(&n, (), ()) - let expectation = self.expectation(description: "effect") - effect - .producer - .startWithCompleted { - expectation.fulfill() - } - self.wait(for: [expectation], timeout: 0.1) + await store.send(.incr) { $0.count = 1 } + await store.send(.noop) + + self.wait(for: [logsExpectation], timeout: 5) + + XCTAssertNoDifference( + logs, + [ + #""" + [prefix]: received action: + DebugAction.incr + - DebugState(count: 0) + + DebugState(count: 1) + + """#, + #""" + [prefix]: received action: + DebugAction.noop + (No state changes) + + """#, + ] + ) } - @available(iOS 12.0, *) - func testDisabledSignpost() { - let reducer = Reducer.empty.signpost(log: .disabled) - var n = 0 - let effect = reducer.run(&n, (), ()) - let expectation = self.expectation(description: "effect") - effect - .producer - .startWithCompleted { - expectation.fulfill() + func testDebug_ActionFormat_OnlyLabels() { + var logs: [String] = [] + let logsExpectation = self.expectation(description: "logs") + + let reducer = Reducer { state, action, _ in + switch action { + case let .incrWithBool(bool): + state.count += bool ? 1 : 0 + return .none + default: + return .none } - self.wait(for: [expectation], timeout: 0.1) + } + .debug("[prefix]", actionFormat: .labelsOnly) { _ in + DebugEnvironment( + printer: { + logs.append($0) + logsExpectation.fulfill() + } + ) + } + + let viewStore = ViewStore( + Store( + initialState: .init(), + reducer: reducer, + environment: () + ) + ) + viewStore.send(.incrWithBool(true)) + + self.wait(for: [logsExpectation], timeout: 5) + + XCTAssertNoDifference( + logs, + [ + #""" + [prefix]: received action: + DebugAction.incrWithBool + - DebugState(count: 0) + + DebugState(count: 1) + + """# + ] + ) } - #endif -} + + #if canImport(os) + @available(iOS 12.0, *) + func testDefaultSignpost() { + let reducer = Reducer.empty.signpost(log: .default) + var n = 0 + let effect = reducer.run(&n, (), ()) + let expectation = self.expectation(description: "effect") + effect + .producer + .startWithCompleted { + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 0.1) + } + + @available(iOS 12.0, *) + func testDisabledSignpost() { + let reducer = Reducer.empty.signpost(log: .disabled) + var n = 0 + let effect = reducer.run(&n, (), ()) + let expectation = self.expectation(description: "effect") + effect + .producer + .startWithCompleted { + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 0.1) + } + #endif + } +#endif enum DebugAction: Equatable { case incrWithBool(Bool) diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 69e9531d2..dc6bb020b 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import ComposableArchitecture -// `XCTExpectFailure` is not supported on Linux +// `XCTExpectFailure` is not supported on Linux / `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) final class RuntimeWarningTests: XCTestCase { func testStoreCreationMainThread() { diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 12d98002d..bb543c386 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -3,549 +3,552 @@ import XCTest @testable import ComposableArchitecture -@MainActor -final class StoreTests: XCTestCase { - - func testProducedMapping() { - struct ChildState: Equatable { - var value: Int = 0 - } - struct ParentState: Equatable { - var child: ChildState = .init() - } +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class StoreTests: XCTestCase { + + func testProducedMapping() { + struct ChildState: Equatable { + var value: Int = 0 + } + struct ParentState: Equatable { + var child: ChildState = .init() + } - let store = Store( - initialState: ParentState(), - reducer: Reducer { state, _, _ in - state.child.value += 1 - return .none - }, - environment: () - ) + let store = Store( + initialState: ParentState(), + reducer: Reducer { state, _, _ in + state.child.value += 1 + return .none + }, + environment: () + ) - let viewStore = ViewStore(store) - var values: [Int] = [] + let viewStore = ViewStore(store) + var values: [Int] = [] - viewStore.produced.child.value.startWithValues { value in - values.append(value) - } + viewStore.produced.child.value.startWithValues { value in + values.append(value) + } - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) - XCTAssertNoDifference(values, [0, 1, 2, 3]) - } + XCTAssertNoDifference(values, [0, 1, 2, 3]) + } - func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let reducer = Reducer { _, _, _ in .none } - let store = Store(initialState: (), reducer: reducer, environment: ()) + func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let reducer = Reducer { _, _, _ in .none } + let store = Store(initialState: (), reducer: reducer, environment: ()) - XCTAssertNoDifference(store.effectDisposables.count, 0) + XCTAssertNoDifference(store.effectDisposables.count, 0) - _ = store.send(()) + _ = store.send(()) - XCTAssertNoDifference(store.effectDisposables.count, 0) - } + XCTAssertNoDifference(store.effectDisposables.count, 0) + } - func testCancellableIsRemovedWhenEffectCompletes() async { - let mainQueue = TestScheduler() - let effect = SignalProducer(value: ()) - .delay(1, on: mainQueue) - .eraseToEffect() + func testCancellableIsRemovedWhenEffectCompletes() async { + let mainQueue = TestScheduler() + let effect = SignalProducer(value: ()) + .delay(1, on: mainQueue) + .eraseToEffect() - enum Action { case start, end } + enum Action { case start, end } - let reducer = Reducer { _, action, _ in - switch action { - case .start: - return effect.map { .end } - case .end: - return .none + let reducer = Reducer { _, action, _ in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } } - } - let store = Store(initialState: (), reducer: reducer, environment: ()) - - XCTAssertNoDifference(store.effectDisposables.count, 0) + let store = Store(initialState: (), reducer: reducer, environment: ()) - _ = store.send(.start) + XCTAssertNoDifference(store.effectDisposables.count, 0) - XCTAssertNoDifference(store.effectDisposables.count, 1) + _ = store.send(.start) - await mainQueue.advance(by: 2) + XCTAssertNoDifference(store.effectDisposables.count, 1) - XCTAssertNoDifference(store.effectDisposables.count, 0) - } + await mainQueue.advance(by: 2) - func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none + XCTAssertNoDifference(store.effectDisposables.count, 0) } - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) - let parentViewStore = ViewStore(parentStore) - let childStore = parentStore.scope(state: String.init) + func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reducer { state, _, _ in + state += 1 + return .none + } - var values: [String] = [] - childStore.producer - .startWithValues { values.append($0) } + let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + let parentViewStore = ViewStore(parentStore) + let childStore = parentStore.scope(state: String.init) - XCTAssertNoDifference(values, ["0"]) + var values: [String] = [] + childStore.producer + .startWithValues { values.append($0) } - parentViewStore.send(()) + XCTAssertNoDifference(values, ["0"]) - XCTAssertNoDifference(values, ["0", "1"]) - } + parentViewStore.send(()) - func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none + XCTAssertNoDifference(values, ["0", "1"]) } - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) - let childStore = parentStore.scope(state: String.init) - let childViewStore = ViewStore(childStore) - - var values: [Int] = [] - parentStore.producer - .startWithValues { values.append($0) } - - XCTAssertNoDifference(values, [0]) - - childViewStore.send(()) + func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reducer { state, _, _ in + state += 1 + return .none + } - XCTAssertNoDifference(values, [0, 1]) - } + let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + let childStore = parentStore.scope(state: String.init) + let childViewStore = ViewStore(childStore) - func testScopeCallCount() { - let counterReducer = Reducer { state, _, _ in state += 1 - return .none - } + var values: [Int] = [] + parentStore.producer + .startWithValues { values.append($0) } - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer, environment: ()) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) + XCTAssertNoDifference(values, [0]) - XCTAssertNoDifference(numCalls1, 1) - } + childViewStore.send(()) - func testScopeCallCount2() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none + XCTAssertNoDifference(values, [0, 1]) } - var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store1 = Store(initialState: 0, reducer: counterReducer, environment: ()) - let store2 = - store1 - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - let store3 = - store2 - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - let store4 = - store3 - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) - - _ = ViewStore(store1) - _ = ViewStore(store2) - _ = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - XCTAssertNoDifference(numCalls1, 1) - XCTAssertNoDifference(numCalls2, 1) - XCTAssertNoDifference(numCalls3, 1) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 2) - XCTAssertNoDifference(numCalls2, 2) - XCTAssertNoDifference(numCalls3, 2) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 3) - XCTAssertNoDifference(numCalls2, 3) - XCTAssertNoDifference(numCalls3, 3) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 4) - XCTAssertNoDifference(numCalls2, 4) - XCTAssertNoDifference(numCalls3, 4) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 5) - XCTAssertNoDifference(numCalls2, 5) - XCTAssertNoDifference(numCalls3, 5) - } - - func testSynchronousEffectsSentAfterSinking() { - enum Action { - case tap - case next1 - case next2 - case end - } - var values: [Int] = [] - let counterReducer = Reducer { state, action, _ in - switch action { - case .tap: - return .merge( - Effect(value: .next1), - Effect(value: .next2), - Effect.fireAndForget { values.append(1) } - ) - case .next1: - return .merge( - Effect(value: .end), - Effect.fireAndForget { values.append(2) } - ) - case .next2: - return .fireAndForget { values.append(3) } - case .end: - return .fireAndForget { values.append(4) } + func testScopeCallCount() { + let counterReducer = Reducer { state, _, _ in state += 1 + return .none } - } - - let store = Store(initialState: (), reducer: counterReducer, environment: ()) - _ = store.send(.tap) + var numCalls1 = 0 + _ = Store(initialState: 0, reducer: counterReducer, environment: ()) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) - XCTAssertNoDifference(values, [1, 2, 3, 4]) - } + XCTAssertNoDifference(numCalls1, 1) + } - func testLotsOfSynchronousActions() { - enum Action { case incr, noop } - let reducer = Reducer { state, action, _ in - switch action { - case .incr: + func testScopeCallCount2() { + let counterReducer = Reducer { state, _, _ in state += 1 - return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) - case .noop: return .none } - } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - _ = store.send(.incr) - XCTAssertNoDifference(ViewStore(store).state, 100_000) - } + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 - func testIfLetAfterScope() { - struct AppState { - var count: Int? - } - - let appReducer = Reducer { state, action, _ in - state.count = action - return .none - } + let store1 = Store(initialState: 0, reducer: counterReducer, environment: ()) + let store2 = + store1 + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + let store3 = + store2 + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + let store4 = + store3 + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) - let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) + _ = ViewStore(store1) + _ = ViewStore(store2) + _ = ViewStore(store3) + let viewStore4 = ViewStore(store4) - // NB: This test needs to hold a strong reference to the emitted stores - var outputs: [Int?] = [] - var stores: [Any] = [] + XCTAssertNoDifference(numCalls1, 1) + XCTAssertNoDifference(numCalls2, 1) + XCTAssertNoDifference(numCalls3, 1) - parentStore - .scope(state: \.count) - .ifLet( - then: { store in - stores.append(store) - outputs.append(store.state) - }, - else: { - outputs.append(nil) - }) + viewStore4.send(()) - XCTAssertNoDifference(outputs, [nil]) + XCTAssertNoDifference(numCalls1, 2) + XCTAssertNoDifference(numCalls2, 2) + XCTAssertNoDifference(numCalls3, 2) - _ = parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1]) + viewStore4.send(()) - _ = parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil]) + XCTAssertNoDifference(numCalls1, 3) + XCTAssertNoDifference(numCalls2, 3) + XCTAssertNoDifference(numCalls3, 3) - _ = parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1]) + viewStore4.send(()) - _ = parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil]) + XCTAssertNoDifference(numCalls1, 4) + XCTAssertNoDifference(numCalls2, 4) + XCTAssertNoDifference(numCalls3, 4) - _ = parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1]) + viewStore4.send(()) - _ = parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1, nil]) - } + XCTAssertNoDifference(numCalls1, 5) + XCTAssertNoDifference(numCalls2, 5) + XCTAssertNoDifference(numCalls3, 5) + } - func testIfLetTwo() { - let parentStore = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - if action { - state? += 1 - return .none - } else { - return SignalProducer(value: true) - .observe(on: QueueScheduler.main) - .eraseToEffect() + func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reducer { state, action, _ in + switch action { + case .tap: + return .merge( + Effect(value: .next1), + Effect(value: .next2), + Effect.fireAndForget { values.append(1) } + ) + case .next1: + return .merge( + Effect(value: .end), + Effect.fireAndForget { values.append(2) } + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } } - }, - environment: () - ) - - parentStore - .ifLet(then: { childStore in - let vs = ViewStore(childStore) - - vs - .produced.producer - .startWithValues { _ in } - - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - XCTAssertNoDifference(vs.state, 3) - }) - } + } + + let store = Store(initialState: (), reducer: counterReducer, environment: ()) - func testActionQueuing() async { - let subject = Signal.pipe() + _ = store.send(.tap) - enum Action: Equatable { - case incrementTapped - case `init` - case doIncrement + XCTAssertNoDifference(values, [1, 2, 3, 4]) } - let store = TestStore( - initialState: 0, - reducer: Reducer { state, action, _ in + func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reducer { state, action, _ in switch action { - case .incrementTapped: - subject.input.send(value: ()) - return .none - - case .`init`: - return subject.output.producer - .map { .doIncrement } - .eraseToEffect() - - case .doIncrement: + case .incr: state += 1 + return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) + case .noop: return .none } - }, - environment: () - ) - - await store.send(.`init`) - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 1 - } - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 2 + } + + let store = Store(initialState: 0, reducer: reducer, environment: ()) + _ = store.send(.incr) + XCTAssertNoDifference(ViewStore(store).state, 100_000) } - subject.input.sendCompleted() - } - func testCoalesceSynchronousActions() { - let store = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case 0: - return .merge( - Effect(value: 1), - Effect(value: 2), - Effect(value: 3) - ) - default: - state = action - return .none - } - }, - environment: () - ) + func testIfLetAfterScope() { + struct AppState { + var count: Int? + } - var emissions: [Int] = [] - let viewStore = ViewStore(store) - viewStore.produced.producer - .startWithValues { emissions.append($0) } + let appReducer = Reducer { state, action, _ in + state.count = action + return .none + } - XCTAssertNoDifference(emissions, [0]) + let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) - viewStore.send(0) + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] - XCTAssertNoDifference(emissions, [0, 3]) - } + parentStore + .scope(state: \.count) + .ifLet( + then: { store in + stores.append(store) + outputs.append(store.state) + }, + else: { + outputs.append(nil) + }) + + XCTAssertNoDifference(outputs, [nil]) - func testBufferedActionProcessing() { - struct ChildState: Equatable { - var count: Int? + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1]) + + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1, nil]) } - let childReducer = Reducer { state, action, _ in - state.count = action - return .none + func testIfLetTwo() { + let parentStore = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + if action { + state? += 1 + return .none + } else { + return SignalProducer(value: true) + .observe(on: QueueScheduler.main) + .eraseToEffect() + } + }, + environment: () + ) + + parentStore + .ifLet(then: { childStore in + let vs = ViewStore(childStore) + + vs + .produced.producer + .startWithValues { _ in } + + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertNoDifference(vs.state, 3) + }) } - struct ParentState: Equatable { - var count: Int? - var child: ChildState? + func testActionQueuing() async { + let subject = Signal.pipe() + + enum Action: Equatable { + case incrementTapped + case `init` + case doIncrement + } + + let store = TestStore( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .incrementTapped: + subject.input.send(value: ()) + return .none + + case .`init`: + return subject.output.producer + .map { .doIncrement } + .eraseToEffect() + + case .doIncrement: + state += 1 + return .none + } + }, + environment: () + ) + + await store.send(.`init`) + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 1 + } + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 2 + } + subject.input.sendCompleted() } - enum ParentAction: Equatable { - case button - case child(Int?) + func testCoalesceSynchronousActions() { + let store = Store( + initialState: 0, + reducer: Reducer { 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] = [] + let viewStore = ViewStore(store) + viewStore.produced.producer + .startWithValues { emissions.append($0) } + + XCTAssertNoDifference(emissions, [0]) + + viewStore.send(0) + + XCTAssertNoDifference(emissions, [0, 3]) } - var handledActions: [ParentAction] = [] - let parentReducer = Reducer.combine([ - childReducer - .optional() - .pullback( - state: \.child, - action: /ParentAction.child, - environment: {} - ), - Reducer { state, action, _ in - handledActions.append(action) + func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? + } - switch action { - case .button: - state.child = .init(count: nil) - return .none + let childReducer = Reducer { state, action, _ in + state.count = action + return .none + } - case .child(let childCount): - state.count = childCount - return .none - } - }, - ]) - - let parentStore = Store( - initialState: .init(), - reducer: parentReducer, - environment: () - ) - - parentStore - .scope( - state: \.child, - action: ParentAction.child - ) - .ifLet { childStore in - ViewStore(childStore).send(2) + struct ParentState: Equatable { + var count: Int? + var child: ChildState? } - XCTAssertNoDifference(handledActions, []) + enum ParentAction: Equatable { + case button + case child(Int?) + } - _ = parentStore.send(.button) - XCTAssertNoDifference( - handledActions, - [ - .button, - .child(2), + var handledActions: [ParentAction] = [] + let parentReducer = Reducer.combine([ + childReducer + .optional() + .pullback( + state: \.child, + action: /ParentAction.child, + environment: {} + ), + Reducer { 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 + } + }, ]) - } - func testCascadingTaskCancellation() async { - enum Action { case task, response, response1, response2 } - let reducer = Reducer { state, action, _ in - switch action { - case .task: - return .task { .response } - case .response: - return .merge( - SignalProducer { _, _ in }.eraseToEffect(), - .task { .response1 } - ) - case .response1: - return .merge( - SignalProducer { _, _ in }.eraseToEffect(), - .task { .response2 } + let parentStore = Store( + initialState: .init(), + reducer: parentReducer, + environment: () + ) + + parentStore + .scope( + state: \.child, + action: ParentAction.child ) - case .response2: - return SignalProducer { _, _ in }.eraseToEffect() - } - } + .ifLet { childStore in + ViewStore(childStore).send(2) + } - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: () - ) - - let task = await store.send(.task) - await store.receive(.response) - await store.receive(.response1) - await store.receive(.response2) - await task.cancel() - } + XCTAssertNoDifference(handledActions, []) - func testTaskCancellationEmpty() async { - enum Action { case task } - let reducer = Reducer { state, action, _ in - switch action { - case .task: - return .fireAndForget { try await Task.never() } - } + _ = parentStore.send(.button) + XCTAssertNoDifference( + handledActions, + [ + .button, + .child(2), + ]) } - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: () - ) + func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + let reducer = Reducer { state, action, _ in + switch action { + case .task: + return .task { .response } + case .response: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response1 } + ) + case .response1: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response2 } + ) + case .response2: + return SignalProducer { _, _ in }.eraseToEffect() + } + } - await store.send(.task).cancel() - } + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: () + ) - func testScopeCancellation() async throws { - let neverEndingTask = Task { try await Task.never() } + let task = await store.send(.task) + await store.receive(.response) + await store.receive(.response1) + await store.receive(.response2) + await task.cancel() + } - let store = Store( - initialState: (), - reducer: Reducer { _, _, _ in - .fireAndForget { - try await neverEndingTask.value + func testTaskCancellationEmpty() async { + enum Action { case task } + let reducer = Reducer { state, action, _ in + switch action { + case .task: + return .fireAndForget { try await Task.never() } } - }, - environment: () - ) - let scopedStore = store.scope(state: { $0 }) - - let sendTask = scopedStore.send(()) - await Task.yield() - neverEndingTask.cancel() - try await XCTUnwrap(sendTask).value - XCTAssertEqual(store.effectDisposables.count, 0) - XCTAssertEqual(scopedStore.effectDisposables.count, 0) + } + + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: () + ) + + await store.send(.task).cancel() + } + + func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } + + let store = Store( + initialState: (), + reducer: Reducer { _, _, _ in + .fireAndForget { + try await neverEndingTask.value + } + }, + environment: () + ) + let scopedStore = store.scope(state: { $0 }) + + let sendTask = scopedStore.send(()) + await Task.yield() + neverEndingTask.cancel() + try await XCTUnwrap(sendTask).value + XCTAssertEqual(store.effectDisposables.count, 0) + XCTAssertEqual(scopedStore.effectDisposables.count, 0) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift index 4eaa0f522..5ec3d5265 100644 --- a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift @@ -1,7 +1,8 @@ import ComposableArchitecture import XCTest -#if !os(Linux) // XCTExpectFailure is not supported on Linux +// `XCTExpectFailure` is not supported on Linux / `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) @MainActor class TestStoreFailureTests: XCTestCase { func testNoStateChangeFailure() { diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 0b21983a9..67750cb6f 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -2,306 +2,309 @@ import ComposableArchitecture import ReactiveSwift import XCTest -@MainActor -class TestStoreTests: XCTestCase { - func testEffectConcatenation() async { - struct State: Equatable {} +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + class TestStoreTests: XCTestCase { + func testEffectConcatenation() async { + struct State: Equatable {} - enum Action: Equatable { - case a, b1, b2, b3, c1, c2, c3, d - } - - let reducer = Reducer { _, action, scheduler in - switch action { - case .a: - return .merge( - SignalProducer.concatenate(.init(value: .b1), .init(value: .c1)) - .delay(1, on: scheduler) - .eraseToEffect(), - Effect.none - .cancellable(id: 1) - ) - case .b1: - return - SignalProducer - .concatenate(.init(value: .b2), .init(value: .b3)) - .eraseToEffect() - case .c1: - return - SignalProducer - .concatenate(.init(value: .c2), .init(value: .c3)) - .eraseToEffect() - case .b2, .b3, .c2, .c3: - return .none - - case .d: - return .cancel(id: 1) + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d } - } - let mainQueue = TestScheduler() + let reducer = Reducer { _, action, scheduler in + switch action { + case .a: + return .merge( + SignalProducer.concatenate(.init(value: .b1), .init(value: .c1)) + .delay(1, on: scheduler) + .eraseToEffect(), + Effect.none + .cancellable(id: 1) + ) + case .b1: + return + SignalProducer + .concatenate(.init(value: .b2), .init(value: .b3)) + .eraseToEffect() + case .c1: + return + SignalProducer + .concatenate(.init(value: .c2), .init(value: .c3)) + .eraseToEffect() + case .b2, .b3, .c2, .c3: + return .none - let store = TestStore( - initialState: State(), - reducer: reducer, - environment: mainQueue - ) + case .d: + return .cancel(id: 1) + } + } - await store.send(.a) + let mainQueue = TestScheduler() - await mainQueue.advance(by: 1) + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: mainQueue + ) - await store.receive(.b1) - await store.receive(.b2) - await store.receive(.b3) + await store.send(.a) - await store.receive(.c1) - await store.receive(.c2) - await store.receive(.c3) + await mainQueue.advance(by: 1) - await store.send(.d) - } + await store.receive(.b1) + await store.receive(.b2) + await store.receive(.b3) - func testAsync() async { - enum Action: Equatable { - case tap - case response(Int) - } - let store = TestStore( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case .tap: - return .task { .response(42) } - case let .response(number): - state = number - return .none - } - }, - environment: () - ) + await store.receive(.c1) + await store.receive(.c2) + await store.receive(.c3) - await store.send(.tap) - await store.receive(.response(42)) { - $0 = 42 + await store.send(.d) } - } - - // `XCTExpectFailure` is not supported on Linux - #if !os(Linux) - func testExpectedStateEquality() async { - struct State: Equatable { - var count: Int = 0 - var isChanging: Bool = false - } + func testAsync() async { enum Action: Equatable { - case increment - case changed(from: Int, to: Int) + case tap + case response(Int) } - - let reducer = Reducer { state, action, scheduler in - switch action { - case .increment: - state.isChanging = true - return Effect(value: .changed(from: state.count, to: state.count + 1)) - case .changed(let from, let to): - state.isChanging = false - if state.count == from { - state.count = to - } - return .none - } - } - let store = TestStore( - initialState: State(), - reducer: reducer, + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { .response(42) } + case let .response(number): + state = number + return .none + } + }, environment: () ) - await store.send(.increment) { - $0.isChanging = true - } - await store.receive(.changed(from: 0, to: 1)) { - $0.isChanging = false - $0.count = 1 + await store.send(.tap) + await store.receive(.response(42)) { + $0 = 42 } + } - XCTExpectFailure { - _ = store.send(.increment) { - $0.isChanging = false + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testExpectedStateEquality() async { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false } - } - XCTExpectFailure { - store.receive(.changed(from: 1, to: 2)) { + + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } + + let reducer = Reducer { state, action, scheduler in + switch action { + case .increment: + state.isChanging = true + return Effect(value: .changed(from: state.count, to: state.count + 1)) + case .changed(let from, let to): + state.isChanging = false + if state.count == from { + state.count = to + } + return .none + } + } + + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: () + ) + + await store.send(.increment) { $0.isChanging = true - $0.count = 1100 } - } - } + await store.receive(.changed(from: 0, to: 1)) { + $0.isChanging = false + $0.count = 1 + } - func testExpectedStateEqualityMustModify() async { - struct State: Equatable { - var count: Int = 0 + XCTExpectFailure { + _ = store.send(.increment) { + $0.isChanging = false + } + } + XCTExpectFailure { + store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } + } } - enum Action: Equatable { - case noop, finished - } + func testExpectedStateEqualityMustModify() async { + struct State: Equatable { + var count: Int = 0 + } - let reducer = Reducer { state, action, scheduler in - switch action { - case .noop: - return Effect(value: .finished) - case .finished: - return .none + enum Action: Equatable { + case noop, finished } - } - let store = TestStore( - initialState: State(), - reducer: reducer, - environment: () - ) + let reducer = Reducer { state, action, scheduler in + switch action { + case .noop: + return Effect(value: .finished) + case .finished: + return .none + } + } + + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: () + ) - await store.send(.noop) - await store.receive(.finished) + await store.send(.noop) + await store.receive(.finished) - XCTExpectFailure { - _ = store.send(.noop) { - $0.count = 0 + XCTExpectFailure { + _ = store.send(.noop) { + $0.count = 0 + } } - } - XCTExpectFailure { - store.receive(.finished) { - $0.count = 0 + XCTExpectFailure { + store.receive(.finished) { + $0.count = 0 + } } } - } - #endif + #endif - func testStateAccess() async { - enum Action { case a, b, c, d } - let store = TestStore( - initialState: 0, - reducer: Reducer { count, action, _ in - switch action { - case .a: - count += 1 - return .merge(Effect(value: .b), Effect(value: .c), Effect(value: .d)) - case .b, .c, .d: - count += 1 - return .none - } - }, - environment: () - ) + func testStateAccess() async { + enum Action { case a, b, c, d } + let store = TestStore( + initialState: 0, + reducer: Reducer { count, action, _ in + switch action { + case .a: + count += 1 + return .merge(Effect(value: .b), Effect(value: .c), Effect(value: .d)) + case .b, .c, .d: + count += 1 + return .none + } + }, + environment: () + ) - await store.send(.a) { - $0 = 1 - XCTAssertEqual(store.state, 0) - } - XCTAssertEqual(store.state, 1) - await store.receive(.b) { - $0 = 2 + await store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) + } XCTAssertEqual(store.state, 1) - } - XCTAssertEqual(store.state, 2) - await store.receive(.c) { - $0 = 3 + await store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } XCTAssertEqual(store.state, 2) - } - XCTAssertEqual(store.state, 3) - await store.receive(.d) { - $0 = 4 + await store.receive(.c) { + $0 = 3 + XCTAssertEqual(store.state, 2) + } XCTAssertEqual(store.state, 3) + await store.receive(.d) { + $0 = 4 + XCTAssertEqual(store.state, 3) + } + XCTAssertEqual(store.state, 4) } - XCTAssertEqual(store.state, 4) - } - // @MainActor - // func testNonDeterministicActions() async { - // struct State: Equatable { - // var count1 = 0 - // var count2 = 0 - // } - // enum Action { case tap, response1, response2 } - // let store = TestStore( - // initialState: State(), - // reducer: Reducer { state, action, _ in - // switch action { - // case .tap: - // return .merge( - // .task { .response1 }, - // .task { .response2 } - // ) - // case .response1: - // state.count1 = 1 - // return .none - // case .response2: - // state.count2 = 2 - // return .none - // } - // }, - // environment: () - // ) - // - // store.send(.tap) - // await store.receive(.response1) { - // $0.count1 = 1 - // } - // await store.receive(.response2) { - // $0.count2 = 2 - // } - // } + // @MainActor + // func testNonDeterministicActions() async { + // struct State: Equatable { + // var count1 = 0 + // var count2 = 0 + // } + // enum Action { case tap, response1, response2 } + // let store = TestStore( + // initialState: State(), + // reducer: Reducer { state, action, _ in + // switch action { + // case .tap: + // return .merge( + // .task { .response1 }, + // .task { .response2 } + // ) + // case .response1: + // state.count1 = 1 + // return .none + // case .response2: + // state.count2 = 2 + // return .none + // } + // }, + // environment: () + // ) + // + // store.send(.tap) + // await store.receive(.response1) { + // $0.count1 = 1 + // } + // await store.receive(.response2) { + // $0.count2 = 2 + // } + // } - // @MainActor - // func testSerialExecutor() async { - // struct State: Equatable { - // var count = 0 - // } - // enum Action: Equatable { - // case tap - // case response(Int) - // } - // let store = TestStore( - // initialState: State(), - // reducer: Reducer { state, action, _ in - // switch action { - // case .tap: - // return .run { send in - // await withTaskGroup(of: Void.self) { group in - // for index in 1...5 { - // group.addTask { - // await send(.response(index)) - // } - // } - // } - // } - // case let .response(value): - // state.count += value - // return .none - // } - // }, - // environment: () - // ) - // - // store.send(.tap) - // await store.receive(.response(1)) { - // $0.count = 1 - // } - // await store.receive(.response(2)) { - // $0.count = 3 - // } - // await store.receive(.response(3)) { - // $0.count = 6 - // } - // await store.receive(.response(4)) { - // $0.count = 10 - // } - // await store.receive(.response(5)) { - // $0.count = 15 - // } - // } -} + // @MainActor + // func testSerialExecutor() async { + // struct State: Equatable { + // var count = 0 + // } + // enum Action: Equatable { + // case tap + // case response(Int) + // } + // let store = TestStore( + // initialState: State(), + // reducer: Reducer { state, action, _ in + // switch action { + // case .tap: + // return .run { send in + // await withTaskGroup(of: Void.self) { group in + // for index in 1...5 { + // group.addTask { + // await send(.response(index)) + // } + // } + // } + // } + // case let .response(value): + // state.count += value + // return .none + // } + // }, + // environment: () + // ) + // + // store.send(.tap) + // await store.receive(.response(1)) { + // $0.count = 1 + // } + // await store.receive(.response(2)) { + // $0.count = 3 + // } + // await store.receive(.response(3)) { + // $0.count = 6 + // } + // await store.receive(.response(4)) { + // $0.count = 10 + // } + // await store.receive(.response(5)) { + // $0.count = 15 + // } + // } + } +#endif diff --git a/Tests/ComposableArchitectureTests/TimerTests.swift b/Tests/ComposableArchitectureTests/TimerTests.swift index 79ed8f5f8..502560791 100644 --- a/Tests/ComposableArchitectureTests/TimerTests.swift +++ b/Tests/ComposableArchitectureTests/TimerTests.swift @@ -3,120 +3,123 @@ import XCTest @testable import ComposableArchitecture -@MainActor -final class TimerTests: XCTestCase { - func testTimer() async { - let mainQueue = TestScheduler() +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class TimerTests: XCTestCase { + func testTimer() async { + let mainQueue = TestScheduler() - var count = 0 + var count = 0 - Effect.timer(id: 1, every: .seconds(1), on: mainQueue) - .producer - .startWithValues { _ in count += 1 } - - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 1) - - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 2) + Effect.timer(id: 1, every: .seconds(1), on: mainQueue) + .producer + .startWithValues { _ in count += 1 } - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 3) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 1) - await mainQueue.advance(by: 3) - XCTAssertNoDifference(count, 6) - } + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 2) - func testInterleavingTimer() async { - let mainQueue = TestScheduler() + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 3) - var count2 = 0 - var count3 = 0 - - Effect.merge( - Effect.timer(id: 1, every: .seconds(2), on: mainQueue) - .producer - .on(value: { _ in count2 += 1 }) - .eraseToEffect(), - Effect.timer(id: 2, every: .seconds(3), on: mainQueue) - .producer - .on(value: { _ in count3 += 1 }) - .eraseToEffect() - ) - .producer - .start() - - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 0) - XCTAssertNoDifference(count3, 0) - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 1) - XCTAssertNoDifference(count3, 0) - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 1) - XCTAssertNoDifference(count3, 1) - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 2) - XCTAssertNoDifference(count3, 1) - } + await mainQueue.advance(by: 3) + XCTAssertNoDifference(count, 6) + } - func testTimerCancellation() async { - let mainQueue = TestScheduler() + func testInterleavingTimer() async { + let mainQueue = TestScheduler() - var firstCount = 0 - var secondCount = 0 + var count2 = 0 + var count3 = 0 - struct CancelToken: Hashable {} - - Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) + Effect.merge( + Effect.timer(id: 1, every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in count2 += 1 }) + .eraseToEffect(), + Effect.timer(id: 2, every: .seconds(3), on: mainQueue) + .producer + .on(value: { _ in count3 += 1 }) + .eraseToEffect() + ) .producer - .on(value: { _ in firstCount += 1 }) .start() - await mainQueue.advance(by: 2) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 0) + XCTAssertNoDifference(count3, 0) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 1) + XCTAssertNoDifference(count3, 0) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 1) + XCTAssertNoDifference(count3, 1) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 2) + XCTAssertNoDifference(count3, 1) + } + + func testTimerCancellation() async { + let mainQueue = TestScheduler() + + var firstCount = 0 + var secondCount = 0 + + struct CancelToken: Hashable {} + + Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in firstCount += 1 }) + .start() + + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 1) + XCTAssertNoDifference(firstCount, 1) - await mainQueue.advance(by: 2) + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 2) + XCTAssertNoDifference(firstCount, 2) - Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) - .producer - .on(value: { _ in secondCount += 1 }) - .startWithValues { _ in } + Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in secondCount += 1 }) + .startWithValues { _ in } - await mainQueue.advance(by: 2) + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 2) - XCTAssertNoDifference(secondCount, 1) + XCTAssertNoDifference(firstCount, 2) + XCTAssertNoDifference(secondCount, 1) - await mainQueue.advance(by: 2) + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 2) - XCTAssertNoDifference(secondCount, 2) - } + XCTAssertNoDifference(firstCount, 2) + XCTAssertNoDifference(secondCount, 2) + } - func testTimerCompletion() async { - let mainQueue = TestScheduler() + func testTimerCompletion() async { + let mainQueue = TestScheduler() - var count = 0 + var count = 0 - Effect.timer(id: 1, every: .seconds(1), on: mainQueue) - .producer - .take(first: 3) - .startWithValues { _ in count += 1 } + Effect.timer(id: 1, every: .seconds(1), on: mainQueue) + .producer + .take(first: 3) + .startWithValues { _ in count += 1 } - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 1) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 1) - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 2) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 2) - await mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 3) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 3) - await mainQueue.run() - XCTAssertNoDifference(count, 3) + await mainQueue.run() + XCTAssertNoDifference(count, 3) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index f1b7d9db1..a3e9e982f 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -6,107 +6,81 @@ import XCTest import Combine #endif -#if os(Linux) - import CDispatch -#endif - -@MainActor -final class ViewStoreTests: XCTestCase { - override func setUp() { - super.setUp() - equalityChecks = 0 - subEqualityChecks = 0 - } - - func testPublisherFirehose() { - let store = Store( - initialState: 0, - reducer: Reducer.empty, - environment: () - ) - - let viewStore = ViewStore(store) - - var emissionCount = 0 - viewStore.produced.producer - .startWithValues { _ in emissionCount += 1 } - - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - } - - func testEqualityChecks() { - let store = Store( - initialState: State(), - reducer: Reducer.empty, - environment: () - ) - - let store1 = store.scope(state: { $0 }) - let store2 = store1.scope(state: { $0 }) - let store3 = store2.scope(state: { $0 }) - let store4 = store3.scope(state: { $0 }) - - let viewStore1 = ViewStore(store1) - let viewStore2 = ViewStore(store2) - let viewStore3 = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - viewStore1.produced.producer.startWithValues { _ in } - viewStore2.produced.producer.startWithValues { _ in } - viewStore3.produced.producer.startWithValues { _ in } - viewStore4.produced.producer.startWithValues { _ in } - viewStore1.produced.substate.startWithValues { _ in } - viewStore2.produced.substate.startWithValues { _ in } - viewStore3.produced.substate.startWithValues { _ in } - viewStore4.produced.substate.startWithValues { _ in } - - XCTAssertNoDifference(0, equalityChecks) - XCTAssertNoDifference(0, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(4, equalityChecks) - XCTAssertNoDifference(4, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(8, equalityChecks) - XCTAssertNoDifference(8, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(12, equalityChecks) - XCTAssertNoDifference(12, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(16, equalityChecks) - XCTAssertNoDifference(16, subEqualityChecks) - } - - func testAccessViewStoreStateInPublisherSink() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ViewStoreTests: XCTestCase { + override func setUp() { + super.setUp() + equalityChecks = 0 + subEqualityChecks = 0 } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) + func testPublisherFirehose() { + let store = Store( + initialState: 0, + reducer: Reducer.empty, + environment: () + ) - var results: [Int] = [] + let viewStore = ViewStore(store) - viewStore.produced.producer - .startWithValues { _ in results.append(viewStore.state) } + var emissionCount = 0 + viewStore.produced.producer + .startWithValues { _ in emissionCount += 1 } - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + } - XCTAssertNoDifference([0, 1, 2, 3], results) - } + func testEqualityChecks() { + let store = Store( + initialState: State(), + reducer: Reducer.empty, + environment: () + ) - #if canImport(Combine) - func testWillSet() { - var cancellables: Set = [] + let store1 = store.scope(state: { $0 }) + let store2 = store1.scope(state: { $0 }) + let store3 = store2.scope(state: { $0 }) + let store4 = store3.scope(state: { $0 }) + + let viewStore1 = ViewStore(store1) + let viewStore2 = ViewStore(store2) + let viewStore3 = ViewStore(store3) + let viewStore4 = ViewStore(store4) + + viewStore1.produced.producer.startWithValues { _ in } + viewStore2.produced.producer.startWithValues { _ in } + viewStore3.produced.producer.startWithValues { _ in } + viewStore4.produced.producer.startWithValues { _ in } + viewStore1.produced.substate.startWithValues { _ in } + viewStore2.produced.substate.startWithValues { _ in } + viewStore3.produced.substate.startWithValues { _ in } + viewStore4.produced.substate.startWithValues { _ in } + + XCTAssertNoDifference(0, equalityChecks) + XCTAssertNoDifference(0, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(4, equalityChecks) + XCTAssertNoDifference(4, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(8, equalityChecks) + XCTAssertNoDifference(8, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(12, equalityChecks) + XCTAssertNoDifference(12, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(16, equalityChecks) + XCTAssertNoDifference(16, subEqualityChecks) + } + func testAccessViewStoreStateInPublisherSink() { let reducer = Reducer { count, _, _ in count += 1 return .none @@ -117,204 +91,229 @@ final class ViewStoreTests: XCTestCase { var results: [Int] = [] - viewStore.objectWillChange - .sink { _ in results.append(viewStore.state) } - .store(in: &cancellables) + viewStore.produced.producer + .startWithValues { _ in results.append(viewStore.state) } viewStore.send(()) viewStore.send(()) viewStore.send(()) - XCTAssertNoDifference([0, 1, 2], results) - } - #endif - - // disabled as the fix for this would be onerous with - // ReactiveSwift, forcing explicit disposable of any use of - // `ViewStore.produced.producer` - func disabled_testPublisherOwnsViewStore() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none + XCTAssertNoDifference([0, 1, 2, 3], results) } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - var results: [Int] = [] - ViewStore(store) - .produced.producer - .startWithValues { results.append($0) } + #if canImport(Combine) + func testWillSet() { + var cancellables: Set = [] - ViewStore(store).send(()) - XCTAssertNoDifference(results, [0, 1]) - } + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } - func testStorePublisherSubscriptionOrder() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none - } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) + let store = Store(initialState: 0, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) - var results: [Int] = [] + var results: [Int] = [] - viewStore.produced.producer - .startWithValues { _ in results.append(0) } + viewStore.objectWillChange + .sink { _ in results.append(viewStore.state) } + .store(in: &cancellables) - viewStore.produced.producer - .startWithValues { _ in results.append(1) } + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) - viewStore.produced.producer - .startWithValues { _ in results.append(2) } + XCTAssertNoDifference([0, 1, 2], results) + } + #endif + + // disabled as the fix for this would be onerous with + // ReactiveSwift, forcing explicit disposable of any use of + // `ViewStore.produced.producer` + func disabled_testPublisherOwnsViewStore() { + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer, environment: ()) - XCTAssertNoDifference(results, [0, 1, 2]) + var results: [Int] = [] + ViewStore(store) + .produced.producer + .startWithValues { results.append($0) } - for _ in 0..<9 { - viewStore.send(()) + ViewStore(store).send(()) + XCTAssertNoDifference(results, [0, 1]) } - XCTAssertNoDifference(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) - } + func testStorePublisherSubscriptionOrder() { + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) - #if canImport(_Concurrency) && compiler(>=5.5.2) - func testSendWhile() async { - Task { - enum Action { - case response - case tapped - } - let reducer = Reducer { state, action, environment in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return SignalProducer(value: .response) - .observe(on: QueueScheduler.main) - .eraseToEffect() - } - } + var results: [Int] = [] - let store = Store(initialState: false, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) + viewStore.produced.producer + .startWithValues { _ in results.append(0) } + + viewStore.produced.producer + .startWithValues { _ in results.append(1) } - XCTAssertNoDifference(viewStore.state, false) - await viewStore.send(.tapped, while: { $0 }) - XCTAssertNoDifference(viewStore.state, false) + viewStore.produced.producer + .startWithValues { _ in results.append(2) } + + XCTAssertNoDifference(results, [0, 1, 2]) + + for _ in 0..<9 { + viewStore.send(()) } + + XCTAssertNoDifference(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) } - func testSuspend() async { - let expectation = self.expectation(description: "await") - Task { - enum Action { - case response - case tapped - } - let reducer = Reducer { state, action, environment in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return SignalProducer(value: .response) - .observe(on: QueueScheduler.main) - .eraseToEffect() + #if canImport(_Concurrency) && compiler(>=5.5.2) + func testSendWhile() async { + Task { + enum Action { + case response + case tapped + } + let reducer = Reducer { state, action, environment in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return SignalProducer(value: .response) + .observe(on: QueueScheduler.main) + .eraseToEffect() + } } - } - let store = Store(initialState: false, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) + let store = Store(initialState: false, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) - XCTAssertNoDifference(viewStore.state, false) - _ = { viewStore.send(.tapped) }() - XCTAssertNoDifference(viewStore.state, true) - await viewStore.yield(while: { $0 }) - XCTAssertNoDifference(viewStore.state, false) + XCTAssertNoDifference(viewStore.state, false) + await viewStore.send(.tapped, while: { $0 }) + XCTAssertNoDifference(viewStore.state, false) + } } - _ = XCTWaiter.wait(for: [expectation], timeout: 1) - } - func testAsyncSend() async throws { - enum Action { - case tap - case response(Int) - } - let store = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case .tap: - return .task { - return .response(42) + func testSuspend() async { + let expectation = self.expectation(description: "await") + Task { + enum Action { + case response + case tapped + } + let reducer = Reducer { state, action, environment in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return SignalProducer(value: .response) + .observe(on: QueueScheduler.main) + .eraseToEffect() } - case let .response(value): - state = value - return .none } - }, - environment: () - ) - let viewStore = ViewStore(store) + let store = Store(initialState: false, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) - XCTAssertEqual(viewStore.state, 0) - await viewStore.send(.tap).finish() - XCTAssertEqual(viewStore.state, 42) - } + XCTAssertNoDifference(viewStore.state, false) + _ = { viewStore.send(.tapped) }() + XCTAssertNoDifference(viewStore.state, true) + await viewStore.yield(while: { $0 }) + XCTAssertNoDifference(viewStore.state, false) + } + _ = XCTWaiter.wait(for: [expectation], timeout: 1) + } + + func testAsyncSend() async throws { + enum Action { + case tap + case response(Int) + } + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { + return .response(42) + } + case let .response(value): + state = value + return .none + } + }, + environment: () + ) + + let viewStore = ViewStore(store) - func testAsyncSendCancellation() async throws { - enum Action { - case tap - case response(Int) + XCTAssertEqual(viewStore.state, 0) + await viewStore.send(.tap).finish() + XCTAssertEqual(viewStore.state, 42) } - let store = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case .tap: - return .task { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - return .response(42) + + func testAsyncSendCancellation() async throws { + enum Action { + case tap + case response(Int) + } + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .response(42) + } + case let .response(value): + state = value + return .none } - case let .response(value): - state = value - return .none - } - }, - environment: () - ) + }, + environment: () + ) - let viewStore = ViewStore(store) + let viewStore = ViewStore(store) - XCTAssertEqual(viewStore.state, 0) - let task = viewStore.send(.tap) - await task.cancel() - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTAssertEqual(viewStore.state, 0) - } - #endif -} + XCTAssertEqual(viewStore.state, 0) + let task = viewStore.send(.tap) + await task.cancel() + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTAssertEqual(viewStore.state, 0) + } + #endif + } -private struct State: Equatable { - var substate = Substate() + private struct State: Equatable { + var substate = Substate() - static func == (lhs: Self, rhs: Self) -> Bool { - equalityChecks += 1 - return lhs.substate == rhs.substate + static func == (lhs: Self, rhs: Self) -> Bool { + equalityChecks += 1 + return lhs.substate == rhs.substate + } } -} -private struct Substate: Equatable { - var name = "Blob" + private struct Substate: Equatable { + var name = "Blob" - static func == (lhs: Self, rhs: Self) -> Bool { - subEqualityChecks += 1 - return lhs.name == rhs.name + static func == (lhs: Self, rhs: Self) -> Bool { + subEqualityChecks += 1 + return lhs.name == rhs.name + } } -} +#endif private var equalityChecks = 0 private var subEqualityChecks = 0