From c7d48610494e8de9d2a9c9d1a69c0fed7f61866e Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 04:05:27 +0900 Subject: [PATCH 1/6] 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 bdb0724..8675bfc 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.0"), .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 From 722d7022ec353657c6480f7bc8d64a4a5f9049ba Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 16:28:05 +0900 Subject: [PATCH 2/6] rename filename EffectTask to SideEffect --- .../Effect/{EffectTask.swift => SideEffect.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/SimplexArchitecture/Effect/{EffectTask.swift => SideEffect.swift} (100%) diff --git a/Sources/SimplexArchitecture/Effect/EffectTask.swift b/Sources/SimplexArchitecture/Effect/SideEffect.swift similarity index 100% rename from Sources/SimplexArchitecture/Effect/EffectTask.swift rename to Sources/SimplexArchitecture/Effect/SideEffect.swift From 9d94c7f68919843c6eeafd8bd57a96f660577ff5 Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 16:29:36 +0900 Subject: [PATCH 3/6] wip --- .../SimplexArchitecture/Effect/SideEffect.swift | 8 -------- .../Internal/ActionTransition.swift | 14 +++++++------- Sources/SimplexArchitecture/Store/Store+send.swift | 2 +- Sources/SimplexArchitecture/TestStore.swift | 4 ++-- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/Sources/SimplexArchitecture/Effect/SideEffect.swift b/Sources/SimplexArchitecture/Effect/SideEffect.swift index 117a8d2..df549ca 100644 --- a/Sources/SimplexArchitecture/Effect/SideEffect.swift +++ b/Sources/SimplexArchitecture/Effect/SideEffect.swift @@ -17,14 +17,6 @@ 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 diff --git a/Sources/SimplexArchitecture/Internal/ActionTransition.swift b/Sources/SimplexArchitecture/Internal/ActionTransition.swift index e4e8789..2d1f0c3 100644 --- a/Sources/SimplexArchitecture/Internal/ActionTransition.swift +++ b/Sources/SimplexArchitecture/Internal/ActionTransition.swift @@ -15,26 +15,26 @@ struct ActionTransition { init( previous: Self.State, next: Self.State, - sideEffect: SideEffect, + effect: SideEffect, effectContext: UUID, for action: CombineAction ) { self.previous = previous self.next = next - effect = sideEffect + self.effect = effect self.effectContext = effectContext self.action = action } - func toNextStateContainer(from target: Reducer.Target) -> StateContainer { - toStateContainer(from: target, state: next) + func asNextStateContainer(from target: Reducer.Target) -> StateContainer { + asStateContainer(from: target, state: next) } - func toPreviousStateContainer(from target: Reducer.Target) -> StateContainer { - toStateContainer(from: target, state: previous) + func asPreviousStateContainer(from target: Reducer.Target) -> StateContainer { + asStateContainer(from: target, state: previous) } - private func toStateContainer(from target: Reducer.Target, state _: Self.State) -> StateContainer { + private func asStateContainer(from target: Reducer.Target, state _: Self.State) -> StateContainer { .init(target, states: next.state, reducerState: next.reducerState) } } diff --git a/Sources/SimplexArchitecture/Store/Store+send.swift b/Sources/SimplexArchitecture/Store/Store+send.swift index 8dd52ce..8e2d05b 100644 --- a/Sources/SimplexArchitecture/Store/Store+send.swift +++ b/Sources/SimplexArchitecture/Store/Store+send.swift @@ -71,7 +71,7 @@ extension Store { ActionTransition( previous: .init(state: before.states, reducerState: before._reducerState), next: .init(state: container.states, reducerState: before._reducerState), - sideEffect: sideEffect, + effect: sideEffect, effectContext: effectContext, for: action ) diff --git a/Sources/SimplexArchitecture/TestStore.swift b/Sources/SimplexArchitecture/TestStore.swift index f303cd7..56c9122 100644 --- a/Sources/SimplexArchitecture/TestStore.swift +++ b/Sources/SimplexArchitecture/TestStore.swift @@ -110,8 +110,8 @@ public final class TestStore where Reducer.Action: Equ 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) + let expectedContainer = stateTransition.asNextStateContainer(from: target) + let actualContainer = stateTransition.asPreviousStateContainer(from: target) expected?(actualContainer) From e8ffda339297ab005dedde124866eaf0d719a0ee Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 16:30:57 +0900 Subject: [PATCH 4/6] wip --- Sources/SimplexArchitecture/ActionSendable.swift | 2 +- Sources/SimplexArchitecture/StatesProtocol.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SimplexArchitecture/ActionSendable.swift b/Sources/SimplexArchitecture/ActionSendable.swift index 11b1b35..e08736a 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 where States.Target == Self + associatedtype States: StatesProtocol /// The store to which actions will be sent. var store: Store { get } diff --git a/Sources/SimplexArchitecture/StatesProtocol.swift b/Sources/SimplexArchitecture/StatesProtocol.swift index 88b360a..8f5080b 100644 --- a/Sources/SimplexArchitecture/StatesProtocol.swift +++ b/Sources/SimplexArchitecture/StatesProtocol.swift @@ -1,6 +1,6 @@ import Foundation public protocol StatesProtocol { - associatedtype Target: ActionSendable where Target.States == Self + associatedtype Target: ActionSendable static var keyPathMap: [PartialKeyPath: PartialKeyPath] { get } } From 13423951d47776f13789000ed34698530e247bbb Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 17:33:20 +0900 Subject: [PATCH 5/6] wip --- Sources/SimplexArchitecture/Internal/ActionTransition.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SimplexArchitecture/Internal/ActionTransition.swift b/Sources/SimplexArchitecture/Internal/ActionTransition.swift index 2d1f0c3..b0061e8 100644 --- a/Sources/SimplexArchitecture/Internal/ActionTransition.swift +++ b/Sources/SimplexArchitecture/Internal/ActionTransition.swift @@ -34,7 +34,7 @@ struct ActionTransition { asStateContainer(from: target, state: previous) } - private func asStateContainer(from target: Reducer.Target, state _: Self.State) -> StateContainer { - .init(target, states: next.state, reducerState: next.reducerState) + private func asStateContainer(from target: Reducer.Target, state: Self.State) -> StateContainer { + .init(target, states: state.state, reducerState: state.reducerState) } } From 4173d31c11b42047fb05e6294f4952cd0f4e6cd7 Mon Sep 17 00:00:00 2001 From: Ryu0118 Date: Tue, 19 Sep 2023 18:34:59 +0900 Subject: [PATCH 6/6] add Document --- .../Internal/ActionTransition.swift | 33 ++++- .../Internal/EffectContext.swift | 1 + .../Internal/Task+withEffectContext.swift | 2 + Sources/SimplexArchitecture/Send.swift | 3 + .../SimplexArchitecture/StateContainer.swift | 4 +- .../Store/Store+pullback.swift | 2 +- Sources/SimplexArchitecture/TestStore.swift | 113 +++++++++++++++--- 7 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Sources/SimplexArchitecture/Internal/ActionTransition.swift b/Sources/SimplexArchitecture/Internal/ActionTransition.swift index b0061e8..f0c4976 100644 --- a/Sources/SimplexArchitecture/Internal/ActionTransition.swift +++ b/Sources/SimplexArchitecture/Internal/ActionTransition.swift @@ -1,17 +1,29 @@ import Foundation +/// ``ActionTransition`` represents a transition between states in a reducer. It captures the previous and next states, the associated side effect,effect context, and the action triggering the transition. struct ActionTransition { + /// Represents a state. It includes the target state and the reducer state. struct State { let state: Reducer.Target.States? let reducerState: Reducer.ReducerState? } - + /// The previous state. let previous: Self.State + /// The next state. let next: Self.State + /// The associated side effect. let effect: SideEffect + /// The unique effect context that represents parent effect. let effectContext: UUID + /// The Action that cause a change of state let action: CombineAction + /// - Parameters: + /// - previous: The previous state. + /// - next: The next state. + /// - effect: The unique effect context that represents parent effect. + /// - effectContext: The unique effect context that represents parent effect. + /// - action: The action responsible for the transition. init( previous: Self.State, next: Self.State, @@ -26,15 +38,30 @@ struct ActionTransition { self.action = action } + /// Converts the `ActionTransition` to a `StateContainer` representing the next state. + /// + /// - Parameter target: The target reducer. + /// - Returns: A `StateContainer` representing the next state. func asNextStateContainer(from target: Reducer.Target) -> StateContainer { asStateContainer(from: target, state: next) } + /// Converts the `ActionTransition` to a `StateContainer` representing the previous state. + /// + /// - Parameter target: The target reducer. + /// - Returns: A `StateContainer` representing the previous state. func asPreviousStateContainer(from target: Reducer.Target) -> StateContainer { asStateContainer(from: target, state: previous) } - private func asStateContainer(from target: Reducer.Target, state: Self.State) -> StateContainer { - .init(target, states: state.state, reducerState: state.reducerState) + private func asStateContainer( + from target: Reducer.Target, + state: Self.State + ) -> StateContainer { + .init( + target, + states: state.state, + reducerState: state.reducerState + ) } } diff --git a/Sources/SimplexArchitecture/Internal/EffectContext.swift b/Sources/SimplexArchitecture/Internal/EffectContext.swift index 1f8a4c7..be708b9 100644 --- a/Sources/SimplexArchitecture/Internal/EffectContext.swift +++ b/Sources/SimplexArchitecture/Internal/EffectContext.swift @@ -1,5 +1,6 @@ import Foundation +/// Enum to determine parent's Effect enum EffectContext { @TaskLocal static var id: UUID? } diff --git a/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift b/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift index f7edab2..d4c5a14 100644 --- a/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift +++ b/Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift @@ -1,6 +1,7 @@ import Foundation extension Task where Failure == any Error { + // Granted when there is no EffectContext in the Task. @discardableResult static func withEffectContext( priority: TaskPriority? = nil, @@ -19,6 +20,7 @@ extension Task where Failure == any Error { } extension Task where Failure == Never { + // Granted when there is no EffectContext in the Task. @discardableResult static func withEffectContext( priority: TaskPriority? = nil, diff --git a/Sources/SimplexArchitecture/Send.swift b/Sources/SimplexArchitecture/Send.swift index 0765778..6fbc8fa 100644 --- a/Sources/SimplexArchitecture/Send.swift +++ b/Sources/SimplexArchitecture/Send.swift @@ -1,3 +1,4 @@ +/// A type that can send actions back into the system when used from run(priority:operation:catch:fileID:line:). public struct Send: Sendable { @usableFromInline let sendAction: @Sendable (Reducer.Action) -> SendTask @@ -25,12 +26,14 @@ public struct Send: Sendable { sendReducerAction(action) } + /// Sends an action back into the system from an effect. @MainActor @inlinable public func callAsFunction(_ action: Reducer.Action) async { await sendAction(action).wait() } + /// Sends an reducer action back into the system from an effect. @_disfavoredOverload @MainActor @inlinable diff --git a/Sources/SimplexArchitecture/StateContainer.swift b/Sources/SimplexArchitecture/StateContainer.swift index 4c363b9..6a71187 100644 --- a/Sources/SimplexArchitecture/StateContainer.swift +++ b/Sources/SimplexArchitecture/StateContainer.swift @@ -1,8 +1,8 @@ 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)`. +/// StateContainer is not thread-safe. Therefore, StateContainer must use NSLock or NSRecursiveLock for exclusions when changing values. +/// In Store, NSRecursiveLock is used for exclusions when executing the `reduce(into:action)`. @dynamicMemberLookup public final class StateContainer { public var reducerState: Target.Reducer.ReducerState { diff --git a/Sources/SimplexArchitecture/Store/Store+pullback.swift b/Sources/SimplexArchitecture/Store/Store+pullback.swift index 61dd86b..8415a5b 100644 --- a/Sources/SimplexArchitecture/Store/Store+pullback.swift +++ b/Sources/SimplexArchitecture/Store/Store+pullback.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Pullback -public extension Store { +extension Store { @inlinable func pullback( to casePath: consuming CasePath, diff --git a/Sources/SimplexArchitecture/TestStore.swift b/Sources/SimplexArchitecture/TestStore.swift index 56c9122..140c5bb 100644 --- a/Sources/SimplexArchitecture/TestStore.swift +++ b/Sources/SimplexArchitecture/TestStore.swift @@ -3,10 +3,19 @@ import CustomDump import Dependencies import Foundation +/// TestStore is a utility class for testing stores that use Reducer protocols. +/// It provides methods for sending actions and verifying state changes. public final class TestStore where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable { + + // MARK: - Properties + + /// The running state container. var runningContainer: StateContainer? + + /// An array of tested actions. var testedActions: [ActionTransition] = [] + /// An array of untested actions. var untestedActions: [ActionTransition] { target.store.sentFromEffectActions.filter { actionTransition in !testedActions.contains { @@ -16,8 +25,15 @@ public final class TestStore where Reducer.Action: Equ } let target: Reducer.Target + + /// The states of the target. let states: Reducer.Target.States + /// Initializes a new test store. + /// + /// - Parameters: + /// - target: The target Reducer. + /// - states: The states of the target Reducer. init( target: Reducer.Target, states: Reducer.Target.States @@ -47,6 +63,12 @@ public final class TestStore where Reducer.Action: Equ } } + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// - Parameters: + /// - action: An action expected from an effect. + /// - timeout: The amount of time to wait for the expected action. + /// - expected: A closure that asserts state changed by sending the action to the store. The mutable state sent to this closure must be modified to match the state of the store after processing the given action. Do not provide a closure if no change is expected. public func receive( _ action: Reducer.ReducerAction, timeout: TimeInterval = 5, @@ -63,6 +85,12 @@ public final class TestStore where Reducer.Action: Equ ) } + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// - Parameters: + /// - action: An action expected from an effect. + /// - timeout: The amount of time to wait for the expected action. + /// - expected: A closure that asserts state changed by sending the action to the store. The mutable state sent to this closure must be modified to match the state of the store after processing the given action. Do not provide a closure if no change is expected. public func receive( _ action: Reducer.Action, timeout: TimeInterval = 5, @@ -107,9 +135,7 @@ public final class TestStore where Reducer.Action: Equ break } - if let firstIndex = untestedActions.firstIndex(where: { $0.action == action }), - let stateTransition = untestedActions[safe: firstIndex] - { + if let stateTransition = untestedActions.first(where: { $0.action == action }) { let expectedContainer = stateTransition.asNextStateContainer(from: target) let actualContainer = stateTransition.asPreviousStateContainer(from: target) @@ -126,7 +152,8 @@ public final class TestStore where Reducer.Action: Equ } } - func receiveWithoutStateCheck( + /// Asserts an action was received from an effect. Does not assert state changes. + public func receiveWithoutStateCheck( _ action: Reducer.Action, timeout: TimeInterval = 5, file: StaticString = #file, @@ -153,9 +180,7 @@ public final class TestStore where Reducer.Action: Equ break } - if let firstIndex = untestedActions.firstIndex(where: { $0.action == .action(action) }), - let stateTransition = untestedActions[safe: firstIndex] - { + if let stateTransition = untestedActions.first(where: { $0.action == .action(action) }) { testedActions.append(stateTransition) break } @@ -164,16 +189,27 @@ public final class TestStore where Reducer.Action: Equ } } + /// Sends an action to the store and asserts when state changes. + /// + /// - Parameters: + /// - action: An action. + /// - assert: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``SendTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult @MainActor public func send( _ action: Reducer.Action, assert expected: ((StateContainer) -> Void)? = nil - ) async { + ) async -> SendTask { let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) runningContainer = expectedContainer let actualContainer = expectedContainer.copy() - target.store.sendIfNeeded(action) + let sendTask = target.store.sendIfNeeded(action) expected?(actualContainer) @@ -181,18 +217,30 @@ public final class TestStore where Reducer.Action: Equ assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) await Task.megaYield() + return sendTask } + /// Sends an action to the store and asserts when state changes. + /// + /// - Parameters: + /// - action: An action. + /// - assert: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``SendTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult @MainActor public func send( _ action: Reducer.Action, assert expected: ((StateContainer) -> Void)? = nil - ) async where Reducer.Target.States: Equatable { + ) async -> SendTask where Reducer.Target.States: Equatable { let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) runningContainer = expectedContainer let actualContainer = expectedContainer.copy() - target.store.sendIfNeeded(action) + let sendTask = target.store.sendIfNeeded(action) expected?(actualContainer) @@ -200,18 +248,31 @@ public final class TestStore where Reducer.Action: Equ assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) await Task.megaYield() + + return sendTask } + /// Sends an action to the store and asserts when state changes. + /// + /// - Parameters: + /// - action: An action. + /// - assert: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``SendTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult @MainActor public func send( _ action: Reducer.Action, assert expected: ((StateContainer) -> Void)? = nil - ) async where Reducer.ReducerState: Equatable { + ) async -> SendTask where Reducer.ReducerState: Equatable { let expectedContainer = target.store.setContainerIfNeeded(for: target, states: states) runningContainer = expectedContainer let actualContainer = expectedContainer.copy() - target.store.sendIfNeeded(action) + let sendTask = target.store.sendIfNeeded(action) expected?(actualContainer) @@ -219,18 +280,30 @@ public final class TestStore where Reducer.Action: Equ assertReducerNoDifference(expected: expectedContainer, actual: actualContainer) await Task.megaYield() + return sendTask } + /// Sends an action to the store and asserts when state changes. + /// + /// - Parameters: + /// - action: An action. + /// - assert: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``SendTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult @MainActor public func send( _ action: Reducer.Action, assert expected: ((StateContainer) -> Void)? = nil - ) async where Reducer.ReducerState: Equatable, Reducer.Target.States: Equatable { + ) async -> SendTask 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) + let sendTask = target.store.sendIfNeeded(action) expected?(actualContainer) @@ -238,12 +311,12 @@ public final class TestStore where Reducer.Action: Equ assertReducerNoDifference(expected: actualContainer, actual: actualContainer) await Task.megaYield() + + return sendTask } } -// MARK: - +Assert - -private extension TestStore { +extension TestStore { private func assertStatesNoDifference( expected expectedContainer: StateContainer, actual actualContainer: StateContainer @@ -294,6 +367,10 @@ private extension TestStore { } public extension ActionSendable where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable { + /// Creates and returns a new test store. + /// + /// - Parameter states: The initial states for testing. + /// - Returns: A new TestStore instance. func testStore(states: States) -> TestStore { TestStore(target: self, states: states) }