Skip to content

Commit a4642b3

Browse files
authored
Merge pull request #27 from Ryu0118/feature/test-store
[WIP] Add `TestStore<Reducer>`
2 parents 45b63aa + 4173d31 commit a4642b3

20 files changed

+965
-108
lines changed

Package.resolved

Lines changed: 50 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ let package = Package(
2222
dependencies: [
2323
.package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.0"),
2424
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", exact: "1.0.0"),
25+
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", exact: "1.0.0"),
26+
.package(url: "https://github.com/pointfreeco/swift-dependencies.git", exact: "1.0.0"),
2527
],
2628
targets: [
2729
// Targets are the basic building blocks of a package, defining a module or a test suite.
@@ -31,6 +33,8 @@ let package = Package(
3133
dependencies: [
3234
"SimplexArchitectureMacrosPlugin",
3335
.product(name: "CasePaths", package: "swift-case-paths"),
36+
.product(name: "CustomDump", package: "swift-custom-dump"),
37+
.product(name: "Dependencies", package: "swift-dependencies"),
3438
]
3539
),
3640
.macro(

Sources/SimplexArchitecture/ActionSendable.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,30 @@ public extension ActionSendable {
1414
/// Send an action to the store
1515
@discardableResult
1616
func send(_ action: consuming Reducer.Action) -> SendTask {
17-
if store.container == nil {
17+
threadCheck()
18+
return if store.container == nil {
1819
store.sendAction(action, target: self)
1920
} else {
2021
store.sendIfNeeded(action)
2122
}
2223
}
24+
25+
@inline(__always)
26+
func threadCheck() {
27+
#if DEBUG
28+
guard !Thread.isMainThread else {
29+
return
30+
}
31+
runtimeWarning(
32+
"""
33+
"ActionSendable.send" was called on a non-main thread.
34+
35+
The "Store" class is not thread-safe, and so all interactions with an instance of \
36+
"Store" must be done on the main thread.
37+
"""
38+
)
39+
#endif
40+
}
2341
}
2442

2543
public extension ActionSendable where Reducer.Action: Pullbackable {

Sources/SimplexArchitecture/Effect/CombineAction.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22

3-
public struct CombineAction<Reducer: ReducerProtocol> {
3+
public struct CombineAction<Reducer: ReducerProtocol>: @unchecked Sendable {
4+
@usableFromInline
45
enum ActionKind {
56
case viewAction(
67
action: Reducer.Action
@@ -10,23 +11,32 @@ public struct CombineAction<Reducer: ReducerProtocol> {
1011
)
1112
}
1213

14+
@usableFromInline
1315
let kind: ActionKind
1416

17+
@usableFromInline
1518
init(kind: ActionKind) {
1619
self.kind = kind
1720
}
1821
}
1922

2023
public extension CombineAction {
24+
@inlinable
2125
static func action(
2226
_ action: Reducer.Action
2327
) -> Self {
2428
.init(kind: .viewAction(action: action))
2529
}
2630

31+
@inlinable
2732
static func action(
2833
_ action: Reducer.ReducerAction
2934
) -> Self {
3035
.init(kind: .reducerAction(action: action))
3136
}
3237
}
38+
39+
extension CombineAction: Equatable where CombineAction.ActionKind: Equatable {}
40+
extension CombineAction.ActionKind: Equatable where Reducer.Action: Equatable, Reducer.ReducerAction: Equatable {}
41+
extension CombineAction: Hashable where CombineAction.ActionKind: Hashable {}
42+
extension CombineAction.ActionKind: Hashable where Reducer.Action: Hashable, Reducer.ReducerAction: Hashable {}

Sources/SimplexArchitecture/Effect/EffectTask.swift renamed to Sources/SimplexArchitecture/Effect/SideEffect.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,25 @@ public extension SideEffect {
6262
.init(effectKind: .serialAction(actions))
6363
}
6464

65+
@_disfavoredOverload
6566
@inlinable
6667
static func concurrent(_ actions: Reducer.ReducerAction...) -> Self {
6768
.init(effectKind: .concurrentReducerAction(actions))
6869
}
6970

71+
@_disfavoredOverload
7072
@inlinable
7173
static func serial(_ actions: Reducer.ReducerAction...) -> Self {
7274
.init(effectKind: .serialReducerAction(actions))
7375
}
7476

77+
@_disfavoredOverload
7578
@inlinable
7679
static func concurrent(_ actions: CombineAction<Reducer>...) -> Self {
7780
.init(effectKind: .concurrentCombineAction(actions))
7881
}
7982

83+
@_disfavoredOverload
8084
@inlinable
8185
static func serial(_ actions: CombineAction<Reducer>...) -> Self {
8286
.init(effectKind: .serialCombineAction(actions))
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Foundation
2+
3+
/// ``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.
4+
struct ActionTransition<Reducer: ReducerProtocol> {
5+
/// Represents a state. It includes the target state and the reducer state.
6+
struct State {
7+
let state: Reducer.Target.States?
8+
let reducerState: Reducer.ReducerState?
9+
}
10+
/// The previous state.
11+
let previous: Self.State
12+
/// The next state.
13+
let next: Self.State
14+
/// The associated side effect.
15+
let effect: SideEffect<Reducer>
16+
/// The unique effect context that represents parent effect.
17+
let effectContext: UUID
18+
/// The Action that cause a change of state
19+
let action: CombineAction<Reducer>
20+
21+
/// - Parameters:
22+
/// - previous: The previous state.
23+
/// - next: The next state.
24+
/// - effect: The unique effect context that represents parent effect.
25+
/// - effectContext: The unique effect context that represents parent effect.
26+
/// - action: The action responsible for the transition.
27+
init(
28+
previous: Self.State,
29+
next: Self.State,
30+
effect: SideEffect<Reducer>,
31+
effectContext: UUID,
32+
for action: CombineAction<Reducer>
33+
) {
34+
self.previous = previous
35+
self.next = next
36+
self.effect = effect
37+
self.effectContext = effectContext
38+
self.action = action
39+
}
40+
41+
/// Converts the `ActionTransition` to a `StateContainer` representing the next state.
42+
///
43+
/// - Parameter target: The target reducer.
44+
/// - Returns: A `StateContainer` representing the next state.
45+
func asNextStateContainer(from target: Reducer.Target) -> StateContainer<Reducer.Target> {
46+
asStateContainer(from: target, state: next)
47+
}
48+
49+
/// Converts the `ActionTransition` to a `StateContainer` representing the previous state.
50+
///
51+
/// - Parameter target: The target reducer.
52+
/// - Returns: A `StateContainer` representing the previous state.
53+
func asPreviousStateContainer(from target: Reducer.Target) -> StateContainer<Reducer.Target> {
54+
asStateContainer(from: target, state: previous)
55+
}
56+
57+
private func asStateContainer(
58+
from target: Reducer.Target,
59+
state: Self.State
60+
) -> StateContainer<Reducer.Target> {
61+
.init(
62+
target,
63+
states: state.state,
64+
reducerState: state.reducerState
65+
)
66+
}
67+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Foundation
2+
3+
extension Collection {
4+
subscript(safe index: Index) -> Element? {
5+
indices.contains(index) ? self[index] : nil
6+
}
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
/// Enum to determine parent's Effect
4+
enum EffectContext {
5+
@TaskLocal static var id: UUID?
6+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
3+
extension Task where Failure == any Error {
4+
// Granted when there is no EffectContext in the Task.
5+
@discardableResult
6+
static func withEffectContext(
7+
priority: TaskPriority? = nil,
8+
@_inheritActorContext @_implicitSelfCapture operation: @Sendable @escaping () async throws -> Success
9+
) -> Self {
10+
if let _ = EffectContext.id {
11+
Self(priority: priority, operation: operation)
12+
} else {
13+
Self(priority: priority) {
14+
try await EffectContext.$id.withValue(UUID()) {
15+
try await operation()
16+
}
17+
}
18+
}
19+
}
20+
}
21+
22+
extension Task where Failure == Never {
23+
// Granted when there is no EffectContext in the Task.
24+
@discardableResult
25+
static func withEffectContext(
26+
priority: TaskPriority? = nil,
27+
@_inheritActorContext @_implicitSelfCapture operation: @Sendable @escaping () async -> Success
28+
) -> Self {
29+
if let _ = EffectContext.id {
30+
Self(priority: priority, operation: operation)
31+
} else {
32+
Self(priority: priority) {
33+
await EffectContext.$id.withValue(UUID()) {
34+
await operation()
35+
}
36+
}
37+
}
38+
}
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
import XCTestDynamicOverlay
3+
4+
@propertyWrapper
5+
struct TestOnly<T> {
6+
private var _value: T
7+
8+
var wrappedValue: T {
9+
_read {
10+
if !_XCTIsTesting {
11+
runtimeWarning("\(Self.self) is accessible only during Unit tests")
12+
}
13+
yield _value
14+
}
15+
set {
16+
if _XCTIsTesting {
17+
_value = newValue
18+
}
19+
}
20+
}
21+
22+
init(wrappedValue: T) {
23+
_value = wrappedValue
24+
}
25+
}

0 commit comments

Comments
 (0)