From 2503c1ef3c76ace87e1c21d72f57fc5b3def0062 Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 04:05:27 +0900 Subject: [PATCH] Add TestStore --- Package.resolved | 55 +++- Package.swift | 4 + .../SimplexArchitecture/ActionSendable.swift | 22 +- .../Effect/CombineAction.swift | 12 +- .../Effect/EffectTask.swift | 12 + .../Internal/ActionTransition.swift | 40 +++ .../Internal/Collection+safe.swift | 7 + .../Internal/EffectContext.swift | 5 + .../Internal/Task+withEffectContext.swift | 37 +++ .../Internal/TestOnly.swift | 25 ++ .../Reducer/ReducerProtocol.swift | 16 + Sources/SimplexArchitecture/Send.swift | 2 + Sources/SimplexArchitecture/SendTask.swift | 2 +- .../SimplexArchitecture/StateContainer.swift | 32 +- .../SimplexArchitecture/StatesProtocol.swift | 2 +- .../Store/Store+send.swift | 111 +++---- Sources/SimplexArchitecture/Store/Store.swift | 94 ++++-- Sources/SimplexArchitecture/TestStore.swift | 300 ++++++++++++++++++ .../ReducerTests.swift | 190 +++++++++++ .../SimplexArchitectureTests.swift | 1 - 20 files changed, 862 insertions(+), 107 deletions(-) create mode 100644 Sources/SimplexArchitecture/Internal/ActionTransition.swift create mode 100644 Sources/SimplexArchitecture/Internal/Collection+safe.swift create mode 100644 Sources/SimplexArchitecture/Internal/EffectContext.swift create mode 100644 Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift create mode 100644 Sources/SimplexArchitecture/Internal/TestOnly.swift create mode 100644 Sources/SimplexArchitecture/TestStore.swift create mode 100644 Tests/SimplexArchitectureTests/ReducerTests.swift delete mode 100644 Tests/SimplexArchitectureTests/SimplexArchitectureTests.swift diff --git a/Package.resolved b/Package.resolved index c5720b8..127ad2e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,57 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", - "location" : "https://github.com/Ryu0118/swift-case-paths.git", + "location" : "https://github.com/pointfreeco/swift-case-paths.git", + "state" : { + "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "ea631ce892687f5432a833312292b80db238186a", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump.git", + "state" : { + "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies.git", "state" : { - "branch" : "fix-macro", - "revision" : "49c6dc7c8e9cb56288ee4dd0b14024a896e49b1e" + "revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5", + "version" : "1.0.0" } }, { @@ -23,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", - "version" : "0.9.0" + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Package.swift b/Package.swift index f1fe611..7306886 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), .package(url: "https://github.com/pointfreeco/swift-case-paths.git", exact: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", exact: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies.git", exact: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -31,6 +33,8 @@ let package = Package( dependencies: [ "SimplexArchitectureMacrosPlugin", .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "Dependencies", package: "swift-dependencies"), ] ), .macro( diff --git a/Sources/SimplexArchitecture/ActionSendable.swift b/Sources/SimplexArchitecture/ActionSendable.swift index daf9e6d..11b1b35 100644 --- a/Sources/SimplexArchitecture/ActionSendable.swift +++ b/Sources/SimplexArchitecture/ActionSendable.swift @@ -4,7 +4,7 @@ import SwiftUI /// A protocol for send actions to a store. public protocol ActionSendable { associatedtype Reducer: ReducerProtocol - associatedtype States: StatesProtocol + associatedtype States: StatesProtocol where States.Target == Self /// The store to which actions will be sent. var store: Store { get } @@ -14,12 +14,30 @@ public extension ActionSendable { /// Send an action to the store @discardableResult func send(_ action: consuming Reducer.Action) -> SendTask { - if store.container == nil { + threadCheck() + return if store.container == nil { store.sendAction(action, target: self) } else { store.sendIfNeeded(action) } } + + @inline(__always) + func threadCheck() { + #if DEBUG + guard !Thread.isMainThread else { + return + } + runtimeWarning( + """ + "ActionSendable.send" was called on a non-main thread. + + The "Store" class is not thread-safe, and so all interactions with an instance of \ + "Store" must be done on the main thread. + """ + ) + #endif + } } public extension ActionSendable where Reducer.Action: Pullbackable { diff --git a/Sources/SimplexArchitecture/Effect/CombineAction.swift b/Sources/SimplexArchitecture/Effect/CombineAction.swift index 92d3a09..332a958 100644 --- a/Sources/SimplexArchitecture/Effect/CombineAction.swift +++ b/Sources/SimplexArchitecture/Effect/CombineAction.swift @@ -1,6 +1,7 @@ import Foundation -public struct CombineAction { +public struct CombineAction: @unchecked Sendable { + @usableFromInline enum ActionKind { case viewAction( action: Reducer.Action @@ -10,23 +11,32 @@ public struct CombineAction { ) } + @usableFromInline let kind: ActionKind + @usableFromInline init(kind: ActionKind) { self.kind = kind } } public extension CombineAction { + @inlinable static func action( _ action: Reducer.Action ) -> Self { .init(kind: .viewAction(action: action)) } + @inlinable static func action( _ action: Reducer.ReducerAction ) -> Self { .init(kind: .reducerAction(action: action)) } } + +extension CombineAction: Equatable where CombineAction.ActionKind: Equatable {} +extension CombineAction.ActionKind: Equatable where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable {} +extension CombineAction: Hashable where CombineAction.ActionKind: Hashable {} +extension CombineAction.ActionKind: Hashable where Reducer.Action: Hashable, Reducer.ReducerAction: Hashable {} diff --git a/Sources/SimplexArchitecture/Effect/EffectTask.swift b/Sources/SimplexArchitecture/Effect/EffectTask.swift index 689c759..117a8d2 100644 --- a/Sources/SimplexArchitecture/Effect/EffectTask.swift +++ b/Sources/SimplexArchitecture/Effect/EffectTask.swift @@ -17,6 +17,14 @@ public struct SideEffect: Sendable { case concurrentReducerAction([Reducer.ReducerAction]) case serialCombineAction([CombineAction]) case concurrentCombineAction([CombineAction]) + + @inlinable + var isNone: Bool { + if case .none = self { + return true + } + return false + } } let kind: EffectKind @@ -62,21 +70,25 @@ public extension SideEffect { .init(effectKind: .serialAction(actions)) } + @_disfavoredOverload @inlinable static func concurrent(_ actions: Reducer.ReducerAction...) -> Self { .init(effectKind: .concurrentReducerAction(actions)) } + @_disfavoredOverload @inlinable static func serial(_ actions: Reducer.ReducerAction...) -> Self { .init(effectKind: .serialReducerAction(actions)) } + @_disfavoredOverload @inlinable static func concurrent(_ actions: CombineAction...) -> Self { .init(effectKind: .concurrentCombineAction(actions)) } + @_disfavoredOverload @inlinable static func serial(_ actions: CombineAction...) -> Self { .init(effectKind: .serialCombineAction(actions)) diff --git a/Sources/SimplexArchitecture/Internal/ActionTransition.swift b/Sources/SimplexArchitecture/Internal/ActionTransition.swift new file mode 100644 index 0000000..e4e8789 --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/ActionTransition.swift @@ -0,0 +1,40 @@ +import Foundation + +struct ActionTransition { + struct State { + let state: Reducer.Target.States? + let reducerState: Reducer.ReducerState? + } + + let previous: Self.State + let next: Self.State + let effect: SideEffect + let effectContext: UUID + let action: CombineAction + + init( + previous: Self.State, + next: Self.State, + sideEffect: SideEffect, + effectContext: UUID, + for action: CombineAction + ) { + self.previous = previous + self.next = next + effect = sideEffect + self.effectContext = effectContext + self.action = action + } + + func toNextStateContainer(from target: Reducer.Target) -> StateContainer { + toStateContainer(from: target, state: next) + } + + func toPreviousStateContainer(from target: Reducer.Target) -> StateContainer { + toStateContainer(from: target, state: previous) + } + + private func toStateContainer(from target: Reducer.Target, state _: Self.State) -> StateContainer { + .init(target, states: next.state, reducerState: next.reducerState) + } +} diff --git a/Sources/SimplexArchitecture/Internal/Collection+safe.swift b/Sources/SimplexArchitecture/Internal/Collection+safe.swift new file mode 100644 index 0000000..e8e5ead --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/Collection+safe.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/SimplexArchitecture/Internal/EffectContext.swift b/Sources/SimplexArchitecture/Internal/EffectContext.swift new file mode 100644 index 0000000..1f8a4c7 --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/EffectContext.swift @@ -0,0 +1,5 @@ +import Foundation + +enum EffectContext { + @TaskLocal static var id: UUID? +} diff --git a/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift b/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift new file mode 100644 index 0000000..f7edab2 --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift @@ -0,0 +1,37 @@ +import Foundation + +extension Task where Failure == any Error { + @discardableResult + static func withEffectContext( + priority: TaskPriority? = nil, + @_inheritActorContext @_implicitSelfCapture operation: @Sendable @escaping () async throws -> Success + ) -> Self { + if let _ = EffectContext.id { + Self(priority: priority, operation: operation) + } else { + Self(priority: priority) { + try await EffectContext.$id.withValue(UUID()) { + try await operation() + } + } + } + } +} + +extension Task where Failure == Never { + @discardableResult + static func withEffectContext( + priority: TaskPriority? = nil, + @_inheritActorContext @_implicitSelfCapture operation: @Sendable @escaping () async -> Success + ) -> Self { + if let _ = EffectContext.id { + Self(priority: priority, operation: operation) + } else { + Self(priority: priority) { + await EffectContext.$id.withValue(UUID()) { + await operation() + } + } + } + } +} diff --git a/Sources/SimplexArchitecture/Internal/TestOnly.swift b/Sources/SimplexArchitecture/Internal/TestOnly.swift new file mode 100644 index 0000000..47b791f --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/TestOnly.swift @@ -0,0 +1,25 @@ +import Foundation +import XCTestDynamicOverlay + +@propertyWrapper +struct TestOnly { + private var _value: T + + var wrappedValue: T { + _read { + if !_XCTIsTesting { + runtimeWarning("\(Self.self) is accessible only during Unit tests") + } + yield _value + } + set { + if _XCTIsTesting { + _value = newValue + } + } + } + + init(wrappedValue: T) { + _value = wrappedValue + } +} diff --git a/Sources/SimplexArchitecture/Reducer/ReducerProtocol.swift b/Sources/SimplexArchitecture/Reducer/ReducerProtocol.swift index 300cbd1..331548f 100644 --- a/Sources/SimplexArchitecture/Reducer/ReducerProtocol.swift +++ b/Sources/SimplexArchitecture/Reducer/ReducerProtocol.swift @@ -122,3 +122,19 @@ public extension ReducerProtocol where ReducerAction == Never { action _: ReducerAction ) -> SideEffect {} } + +extension ReducerProtocol { + @inlinable + func reduce( + into state: StateContainer, + action: CombineAction + ) -> SideEffect { + switch action.kind { + case .viewAction(let action): + reduce(into: state, action: action) + + case .reducerAction(let action): + reduce(into: state, action: action) + } + } +} diff --git a/Sources/SimplexArchitecture/Send.swift b/Sources/SimplexArchitecture/Send.swift index bcedde9..0765778 100644 --- a/Sources/SimplexArchitecture/Send.swift +++ b/Sources/SimplexArchitecture/Send.swift @@ -18,6 +18,7 @@ public struct Send: Sendable { sendAction(action) } + @_disfavoredOverload @discardableResult @inlinable func callAsFunction(_ action: Reducer.ReducerAction) -> SendTask { @@ -30,6 +31,7 @@ public struct Send: Sendable { await sendAction(action).wait() } + @_disfavoredOverload @MainActor @inlinable public func callAsFunction(_ action: Reducer.ReducerAction) async { diff --git a/Sources/SimplexArchitecture/SendTask.swift b/Sources/SimplexArchitecture/SendTask.swift index 2c1c1e8..3902336 100644 --- a/Sources/SimplexArchitecture/SendTask.swift +++ b/Sources/SimplexArchitecture/SendTask.swift @@ -1,7 +1,7 @@ import Foundation /// The type returned from send(_:) that represents the lifecycle of the effect started from sending an action. -public struct SendTask: Sendable { +public struct SendTask: Sendable, Hashable { static let never = SendTask(task: nil) @usableFromInline diff --git a/Sources/SimplexArchitecture/StateContainer.swift b/Sources/SimplexArchitecture/StateContainer.swift index a5fe549..4c363b9 100644 --- a/Sources/SimplexArchitecture/StateContainer.swift +++ b/Sources/SimplexArchitecture/StateContainer.swift @@ -1,4 +1,5 @@ import Foundation +import XCTestDynamicOverlay // StateContainer is not thread-safe. Therefore, StateContainer must use NSLock or NSRecursiveLock for exclusions when changing values. // In Send.swift, NSRecursiveLock is used for exclusions when executing the `reduce(into:action)`. @@ -9,35 +10,46 @@ public final class StateContainer { _modify { yield &_reducerState! } } - private var _reducerState: Target.Reducer.ReducerState? - private var _entity: Target - - init(_ entity: consuming Target) { - self._entity = entity - } + var _reducerState: Target.Reducer.ReducerState? + var entity: Target + @TestOnly var states: Target.States? init( _ entity: consuming Target, - reducerState: consuming Target.Reducer.ReducerState? + states: Target.States? = nil, + reducerState: consuming Target.Reducer.ReducerState? = nil ) { - self._entity = entity + self.entity = entity + self.states = states self._reducerState = reducerState } public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { + guard !_XCTIsTesting else { + yield states![keyPath: keyPath] + return + } if let viewKeyPath = Target.States.keyPathMap[keyPath] as? WritableKeyPath { - yield _entity[keyPath: viewKeyPath] + yield entity[keyPath: viewKeyPath] } else { fatalError() } } _modify { + guard !_XCTIsTesting else { + yield &states![keyPath: keyPath] + return + } if let viewKeyPath = Target.States.keyPathMap[keyPath] as? WritableKeyPath { - yield &_entity[keyPath: viewKeyPath] + yield &entity[keyPath: viewKeyPath] } else { fatalError() } } } + + func copy() -> Self { + Self(entity, states: states, reducerState: _reducerState) + } } diff --git a/Sources/SimplexArchitecture/StatesProtocol.swift b/Sources/SimplexArchitecture/StatesProtocol.swift index 8f5080b..88b360a 100644 --- a/Sources/SimplexArchitecture/StatesProtocol.swift +++ b/Sources/SimplexArchitecture/StatesProtocol.swift @@ -1,6 +1,6 @@ import Foundation public protocol StatesProtocol { - associatedtype Target: ActionSendable + associatedtype Target: ActionSendable where Target.States == Self static var keyPathMap: [PartialKeyPath: PartialKeyPath] { get } } diff --git a/Sources/SimplexArchitecture/Store/Store+send.swift b/Sources/SimplexArchitecture/Store/Store+send.swift index 4a88db4..8dd52ce 100644 --- a/Sources/SimplexArchitecture/Store/Store+send.swift +++ b/Sources/SimplexArchitecture/Store/Store+send.swift @@ -1,4 +1,5 @@ import Foundation +import XCTestDynamicOverlay extension Store { @usableFromInline @@ -7,15 +8,10 @@ extension Store { _ action: consuming Reducer.Action, target: consuming Reducer.Target ) -> SendTask { - let container = if let container { - container - } else { - StateContainer(target, reducerState: initialReducerState?()) - } - - defer { self.container = container } - - return sendAction(action, container: container) + sendAction( + action, + container: setContainerIfNeeded(for: target) + ) } @usableFromInline @@ -23,15 +19,10 @@ extension Store { _ action: consuming Reducer.ReducerAction, target: consuming Reducer.Target ) -> SendTask { - let container = if let container { - container - } else { - StateContainer(target, reducerState: initialReducerState?()) - } - - defer { self.container = container } - - return sendAction(action, container: container) + sendAction( + action, + container: setContainerIfNeeded(for: target) + ) } @usableFromInline @@ -45,29 +36,7 @@ extension Store { } } - threadCheck() - - let sideEffect = withLock { - reducer.reduce(into: container, action: action) - } - if case .none = sideEffect.kind { - return .never - } else { - let send = - self.send - ?? Send( - sendAction: { [weak self] action in - self?.sendAction(action, container: container) ?? .never - }, - sendReducerAction: { [weak self] reducerAction in - self?.sendAction(reducerAction, container: container) ?? .never - } - ) - - return executeTasks( - runEffect(sideEffect, send: send) - ) - } + return sendAction(.action(action), container: container) } @usableFromInline @@ -83,22 +52,38 @@ extension Store { } } - threadCheck() + return sendAction(.action(action), container: container) + } - let sideEffect = withLock { - reducer.reduce(into: container, action: action) + @inline(__always) + func sendAction( + _ action: CombineAction, + container: StateContainer + ) -> SendTask { + let sideEffect: SideEffect + // If Unit Testing is in progress and an action is sent from SideEffect + if _XCTIsTesting, let effectContext = EffectContext.id { + let before = container.copy() + sideEffect = withLock { + reducer.reduce(into: container, action: action) + } + sentFromEffectActions.append( + ActionTransition( + previous: .init(state: before.states, reducerState: before._reducerState), + next: .init(state: container.states, reducerState: before._reducerState), + sideEffect: sideEffect, + effectContext: effectContext, + for: action + ) + ) + } else { + sideEffect = withLock { reducer.reduce(into: container, action: action) } } + if case .none = sideEffect.kind { return .never } else { - let send = self.send ?? Send( - sendAction: { [weak self] action in - self?.sendAction(action, container: container) ?? .never - }, - sendReducerAction: { [weak self] reducerAction in - self?.sendAction(reducerAction, container: container) ?? .never - } - ) + let send = self.send ?? makeSend(for: container) return executeTasks( runEffect(sideEffect, send: send) @@ -133,20 +118,14 @@ extension Store { return try operation() } - @inline(__always) - func threadCheck() { - #if DEBUG - guard !Thread.isMainThread else { - return + func makeSend(for container: StateContainer) -> Send { + Send( + sendAction: { [weak self] action in + self?.sendAction(action, container: container) ?? .never + }, + sendReducerAction: { [weak self] reducerAction in + self?.sendAction(reducerAction, container: container) ?? .never } - runtimeWarning( - """ - "ActionSendable.send" was called on a non-main thread. - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" must be done on the main thread. - """ - ) - #endif + ) } } diff --git a/Sources/SimplexArchitecture/Store/Store.swift b/Sources/SimplexArchitecture/Store/Store.swift index a6e3d5a..ee88f63 100644 --- a/Sources/SimplexArchitecture/Store/Store.swift +++ b/Sources/SimplexArchitecture/Store/Store.swift @@ -1,23 +1,20 @@ import Foundation +import XCTestDynamicOverlay /// `Store` is responsible for managing state and handling actions. public final class Store { // The container that holds the ViewState and ReducerState + @usableFromInline var container: StateContainer? { didSet { guard let container else { return } - send = Send( - sendAction: { [weak self] action in - self?.sendAction(action, container: container) ?? .never - }, - sendReducerAction: { [weak self] reducerAction in - self?.sendAction(reducerAction, container: container) ?? .never - } - ) + send = makeSend(for: container) } } var send: Send? + // Buffer to store Actions recurrently invoked through SideEffect in a single Action sent from View + @TestOnly var sentFromEffectActions: [ActionTransition] = [] @usableFromInline let lock = NSRecursiveLock() @usableFromInline var pullbackAction: ((Reducer.Action) -> Void)? @@ -39,6 +36,32 @@ public final class Store { self.reducer = reducer self.initialReducerState = initialReducerState } + + public func getContainer( + for target: Reducer.Target, + states: Reducer.Target.States? = nil + ) -> StateContainer { + if let container { + container + } else { + StateContainer(target, states: states, reducerState: initialReducerState?()) + } + } + + @inlinable + @discardableResult + public func setContainerIfNeeded( + for target: Reducer.Target, + states: Reducer.Target.States? = nil + ) -> StateContainer { + if let container { + return container + } else { + let container = getContainer(for: target, states: states) + self.container = container + return container + } + } } extension Store { @@ -53,7 +76,7 @@ extension Store { return task } - let task = Task.detached { + let task = Task.withEffectContext { await withTaskGroup(of: Void.self) { group in for task in tasks { group.addTask { @@ -71,10 +94,13 @@ extension Store { return SendTask(task: task) } - func runEffect(_ sideEffect: borrowing SideEffect, send: Send) -> [SendTask] { + func runEffect( + _ sideEffect: borrowing SideEffect, + send: Send + ) -> [SendTask] { switch sideEffect.kind { case let .run(priority, operation, `catch`): - let task = Task.detached(priority: priority ?? .medium) { + let task = Task.withEffectContext(priority: priority ?? .medium) { do { try await operation(send) } catch is CancellationError { @@ -90,18 +116,36 @@ extension Store { return [SendTask(task: task)] case let .sendAction(action): - return [send(action)] + return [ + SendTask( + task: Task.withEffectContext { + send(action) + } + ), + ] case let .sendReducerAction(action): - return [send(action)] + return [ + SendTask( + task: Task.withEffectContext { + send(action) + } + ), + ] case let .concurrentAction(actions): return actions.reduce(into: [SendTask]()) { partialResult, action in - partialResult.append(send(action)) + partialResult.append( + SendTask( + task: Task.withEffectContext { @MainActor in + send(action) + } + ) + ) } case let .serialAction(actions): - let task = Task.detached { + let task = Task.withEffectContext { for action in actions { await send(action) } @@ -110,11 +154,17 @@ extension Store { case let .concurrentReducerAction(actions): return actions.reduce(into: [SendTask]()) { tasks, action in - tasks.append(send(action)) + tasks.append( + SendTask( + task: Task.withEffectContext { @MainActor in + send(action) + } + ) + ) } case let .serialReducerAction(actions): - let task = Task.detached { + let task = Task.withEffectContext { for action in actions { await send(action) } @@ -125,14 +175,18 @@ extension Store { return combineActions.compactMap { combineAction in switch combineAction.kind { case let .reducerAction(action): - send(action) + SendTask( + task: Task.withEffectContext { @MainActor in send(action) } + ) case let .viewAction(action): - send(action) + SendTask( + task: Task.withEffectContext { @MainActor in send(action) } + ) } } case let .serialCombineAction(combineActions): - let task = Task.detached { + let task = Task.withEffectContext { for combineAction in combineActions { switch combineAction.kind { case let .reducerAction(action): diff --git a/Sources/SimplexArchitecture/TestStore.swift b/Sources/SimplexArchitecture/TestStore.swift new file mode 100644 index 0000000..f303cd7 --- /dev/null +++ b/Sources/SimplexArchitecture/TestStore.swift @@ -0,0 +1,300 @@ +import CasePaths +import CustomDump +import Dependencies +import Foundation + +public final class TestStore where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable { + var runningContainer: StateContainer? + var testedActions: [ActionTransition] = [] + + var untestedActions: [ActionTransition] { + target.store.sentFromEffectActions.filter { actionTransition in + !testedActions.contains { + String(customDumping: $0) == String(customDumping: actionTransition) + } + } + } + + let target: Reducer.Target + let states: Reducer.Target.States + + init( + target: Reducer.Target, + states: Reducer.Target.States + ) { + self.target = target + self.states = states + } + + deinit { + if untestedActions.count > 0 { + let unhandledActionStrings = untestedActions + .map { + switch $0.action.kind { + case let .viewAction(action): + String(customDumping: action) + case let .reducerAction(action): + String(customDumping: action) + } + } + .joined(separator: ", ") + + XCTFail( + """ + Unhandled Action remains. Use TestStore.receive to implement tests of \(unhandledActionStrings) + """ + ) + } + } + + public func receive( + _ action: Reducer.ReducerAction, + timeout: TimeInterval = 5, + expected: ((StateContainer) -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await receive( + .action(action), + timeout: timeout, + expected: expected, + file: file, + line: line + ) + } + + public func receive( + _ action: Reducer.Action, + timeout: TimeInterval = 5, + expected: ((StateContainer) -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await receive( + .action(action), + timeout: timeout, + expected: expected, + file: file, + line: line + ) + } + + private func receive( + _ action: CombineAction, + timeout: TimeInterval = 5, + expected: ((StateContainer) -> Void)? = nil, + file: StaticString, + line: UInt + ) async { + guard let _ = runningContainer else { + XCTFail( + """ + Action has not been sent. Please invoke TestStore.send(_:) + """ + ) + return + } + + let start = Date() + + while !Task.isCancelled { + if Date().timeIntervalSince(start) > timeout { + XCTFail( + "Store.receive has timed out.", + file: file, + line: line + ) + break + } + + if let firstIndex = untestedActions.firstIndex(where: { $0.action == action }), + let stateTransition = untestedActions[safe: firstIndex] + { + let expectedContainer = stateTransition.toNextStateContainer(from: target) + let actualContainer = stateTransition.toPreviousStateContainer(from: target) + + expected?(actualContainer) + + assertStatesNoDifference(expected: expectedContainer, actual: actualContainer) + assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) + + testedActions.append(stateTransition) + break + } + + await Task.yield() + } + } + + func receiveWithoutStateCheck( + _ action: Reducer.Action, + timeout: TimeInterval = 5, + file: StaticString = #file, + line: UInt = #line + ) async { + guard let _ = runningContainer else { + XCTFail( + """ + Action has not been sent. Please invoke TestStore.send(_:) + """ + ) + return + } + + let start = Date() + + while !Task.isCancelled { + if Date().timeIntervalSince(start) > timeout { + XCTFail( + "Store.receive has timed out.", + file: file, + line: line + ) + break + } + + if let firstIndex = untestedActions.firstIndex(where: { $0.action == .action(action) }), + let stateTransition = untestedActions[safe: firstIndex] + { + testedActions.append(stateTransition) + break + } + + await Task.yield() + } + } + + @MainActor + public func send( + _ action: Reducer.Action, + assert expected: ((StateContainer) -> Void)? = nil + ) async { + let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) + runningContainer = expectedContainer + let actualContainer = expectedContainer.copy() + + target.store.sendIfNeeded(action) + + expected?(actualContainer) + + assertStatesNoDifference(expected: expectedContainer, actual: actualContainer) + assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) + + await Task.megaYield() + } + + @MainActor + public func send( + _ action: Reducer.Action, + assert expected: ((StateContainer) -> Void)? = nil + ) async where Reducer.Target.States: Equatable { + let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) + runningContainer = expectedContainer + let actualContainer = expectedContainer.copy() + + target.store.sendIfNeeded(action) + + expected?(actualContainer) + + assertStatesNoDifference(expected: expectedContainer, actual: actualContainer) + assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) + + await Task.megaYield() + } + + @MainActor + public func send( + _ action: Reducer.Action, + assert expected: ((StateContainer) -> Void)? = nil + ) async where Reducer.ReducerState: Equatable { + let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) + runningContainer = expectedContainer + let actualContainer = expectedContainer.copy() + + target.store.sendIfNeeded(action) + + expected?(actualContainer) + + assertStatesNoDifference(expected: expectedContainer, actual: actualContainer) + assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) + + await Task.megaYield() + } + + @MainActor + public func send( + _ action: Reducer.Action, + assert expected: ((StateContainer) -> Void)? = nil + ) async where Reducer.ReducerState: Equatable, Reducer.Target.States: Equatable { + let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) + runningContainer = expectedContainer + let actualContainer = expectedContainer.copy() + + target.store.sendIfNeeded(action) + + expected?(actualContainer) + + assertStatesNoDifference(expected: actualContainer, actual: actualContainer) + assertReducerNoDifference(expected: actualContainer, actual: actualContainer) + + await Task.megaYield() + } +} + +// MARK: - +Assert + +private extension TestStore { + private func assertStatesNoDifference( + expected expectedContainer: StateContainer, + actual actualContainer: StateContainer + ) where Reducer.Target.States: Equatable { + if let expectedStates = expectedContainer.states, + let actualStates = actualContainer.states + { + XCTAssertNoDifference(expectedStates, actualStates) + } + } + + private func assertStatesNoDifference( + expected expectedContainer: StateContainer, + actual actualContainer: StateContainer + ) { + if let expectedStates = expectedContainer.states, + let actualStates = actualContainer.states + { + let expectedDump = String(customDumping: expectedStates) + let actualDump = String(customDumping: actualStates) + XCTAssertNoDifference(expectedDump, actualDump) + } + } + + private func assertReducerStateNoDifference( + expected expectedContainer: StateContainer, + actual actualContainer: StateContainer + ) where Reducer.ReducerState: Equatable { + if let expected = expectedContainer._reducerState, + let actual = actualContainer._reducerState + { + XCTAssertNoDifference(expected, actual) + } + } + + private func assertReducerNoDifference( + expected expectedContainer: StateContainer, + actual actualContainer: StateContainer + ) { + if let expected = expectedContainer._reducerState, + let actual = actualContainer._reducerState + { + let expectedDump = String(customDumping: expected) + let actualDump = String(customDumping: actual) + XCTAssertNoDifference(expectedDump, actualDump) + } + } +} + +public extension ActionSendable where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable { + func testStore(states: States) -> TestStore { + TestStore(target: self, states: states) + } +} diff --git a/Tests/SimplexArchitectureTests/ReducerTests.swift b/Tests/SimplexArchitectureTests/ReducerTests.swift new file mode 100644 index 0000000..1b9e70b --- /dev/null +++ b/Tests/SimplexArchitectureTests/ReducerTests.swift @@ -0,0 +1,190 @@ +@testable import SimplexArchitecture +import SwiftUI +import XCTest + +@MainActor +final class ReducerTests: XCTestCase { + func testReducer1() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.increment) { + $0.count = 1 + } + await testStore.send(.decrement) { + $0.count = 0 + } + } + + func testReducer2() async { + let testStore = TestView().testStore(states: .init(count: 2)) + await testStore.send(.increment) { + $0.count = 3 + } + await testStore.send(.decrement) { + $0.count = 2 + } + } + + func testRun() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.run) + await testStore.receive(.increment) { + $0.count = 1 + } + await testStore.receive(.decrement) { + $0.count = 0 + } + } + + func testSend() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.send) + await testStore.receive(.increment) { + $0.count = 1 + } + } + + func testSerialAction() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.serial) + await testStore.receive(.increment) { + $0.count = 1 + } + await testStore.receive(.decrement) { + $0.count = 0 + } + } + + func testConcurrentAction() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.concurrent) + await testStore.receiveWithoutStateCheck(.increment, timeout: 0.5) + await testStore.receiveWithoutStateCheck(.decrement, timeout: 0.5) + } + + func testReducerState1() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.modifyReducerState1) { + $0.reducerState.count = 1 + $0.reducerState.string = "hoge" + } + } + + func testReducerState2() async { + let testStore = TestView( + store: .init( + reducer: TestReducer(), + initialReducerState: .init(count: 2, string: "hoge") + ) + ).testStore(states: .init()) + + await testStore.send(.modifyReducerState2) { + $0.reducerState.count = 3 + $0.reducerState.string = "hogehoge" + } + } + + func testReducerAction() async { + let testStore = TestView().testStore(states: .init()) + await testStore.send(.invokeIncrement) + await testStore.receive(.incrementFromReducerAction) { + $0.count = 1 + } + await testStore.send(.invokeDecrement) + await testStore.receive(.decrementFromReducerAction) { + $0.count = 0 + } + } +} + +struct TestReducer: ReducerProtocol { + struct ReducerState: Equatable { + var count = 0 + var string = "string" + } + + enum ReducerAction { + case incrementFromReducerAction + case decrementFromReducerAction + } + + enum Action: Equatable { + case increment + case decrement + case serial + case concurrent + case run + case modifyReducerState1 + case modifyReducerState2 + case invokeIncrement + case invokeDecrement + case send + } + + func reduce(into state: StateContainer, action: ReducerAction) -> SideEffect { + switch action { + case .incrementFromReducerAction: + state.count += 1 + return .none + case .decrementFromReducerAction: + state.count -= 1 + return .none + } + } + + func reduce(into state: StateContainer, action: Action) -> SideEffect { + switch action { + case .increment: + state.count += 1 + return .none + + case .decrement: + state.count -= 1 + return .none + + case .run: + return .run { send in + await send(.increment) + await send(.decrement) + } + + case .serial: + return .serial(.increment, .decrement) + + case .concurrent: + return .concurrent(.increment, .decrement) + + case .modifyReducerState1: + state.reducerState.count = 1 + state.reducerState.string = "hoge" + return .none + + case .modifyReducerState2: + state.reducerState.count += 1 + state.reducerState.string += "hoge" + return .none + + case .invokeIncrement: + return .send(.incrementFromReducerAction) + + case .invokeDecrement: + return .send(.decrementFromReducerAction) + + case .send: + return .send(.increment) + } + } +} + +@ScopeState +struct TestView: View { + @State var count = 0 + let store: Store + + init(store: Store = Store(reducer: TestReducer(), initialReducerState: .init())) { + self.store = store + } + + var body: some View { + EmptyView() + } +} diff --git a/Tests/SimplexArchitectureTests/SimplexArchitectureTests.swift b/Tests/SimplexArchitectureTests/SimplexArchitectureTests.swift deleted file mode 100644 index fecc4ab..0000000 --- a/Tests/SimplexArchitectureTests/SimplexArchitectureTests.swift +++ /dev/null @@ -1 +0,0 @@ -import Foundation