Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add TestStore<Reducer> #27

Merged
merged 6 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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"
}
},
{
Expand All @@ -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"
}
}
],
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
20 changes: 19 additions & 1 deletion Sources/SimplexArchitecture/ActionSendable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion Sources/SimplexArchitecture/Effect/CombineAction.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

public struct CombineAction<Reducer: ReducerProtocol> {
public struct CombineAction<Reducer: ReducerProtocol>: @unchecked Sendable {
@usableFromInline
enum ActionKind {
case viewAction(
action: Reducer.Action
Expand All @@ -10,23 +11,32 @@ public struct CombineAction<Reducer: ReducerProtocol> {
)
}

@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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,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<Reducer>...) -> Self {
.init(effectKind: .concurrentCombineAction(actions))
}

@_disfavoredOverload
@inlinable
static func serial(_ actions: CombineAction<Reducer>...) -> Self {
.init(effectKind: .serialCombineAction(actions))
Expand Down
67 changes: 67 additions & 0 deletions Sources/SimplexArchitecture/Internal/ActionTransition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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<Reducer: ReducerProtocol> {
/// 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<Reducer>
/// The unique effect context that represents parent effect.
let effectContext: UUID
/// The Action that cause a change of state
let action: CombineAction<Reducer>

/// - 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,
effect: SideEffect<Reducer>,
effectContext: UUID,
for action: CombineAction<Reducer>
) {
self.previous = previous
self.next = next
self.effect = effect
self.effectContext = effectContext
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<Reducer.Target> {
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<Reducer.Target> {
asStateContainer(from: target, state: previous)
}

private func asStateContainer(
from target: Reducer.Target,
state: Self.State
) -> StateContainer<Reducer.Target> {
.init(
target,
states: state.state,
reducerState: state.reducerState
)
}
}
7 changes: 7 additions & 0 deletions Sources/SimplexArchitecture/Internal/Collection+safe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
6 changes: 6 additions & 0 deletions Sources/SimplexArchitecture/Internal/EffectContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

/// Enum to determine parent's Effect
enum EffectContext {
@TaskLocal static var id: UUID?
}
39 changes: 39 additions & 0 deletions Sources/SimplexArchitecture/Internal/Task+withEffectContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

extension Task where Failure == any Error {
// Granted when there is no EffectContext in the Task.
@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 {
// Granted when there is no EffectContext in the Task.
@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()
}
}
}
}
}
25 changes: 25 additions & 0 deletions Sources/SimplexArchitecture/Internal/TestOnly.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import XCTestDynamicOverlay

@propertyWrapper
struct TestOnly<T> {
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
}
}
16 changes: 16 additions & 0 deletions Sources/SimplexArchitecture/Reducer/ReducerProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,19 @@ public extension ReducerProtocol where ReducerAction == Never {
action _: ReducerAction
) -> SideEffect<Self> {}
}

extension ReducerProtocol {
@inlinable
func reduce(
into state: StateContainer<Target>,
action: CombineAction<Self>
) -> SideEffect<Self> {
switch action.kind {
case .viewAction(let action):
reduce(into: state, action: action)

case .reducerAction(let action):
reduce(into: state, action: action)
}
}
}
5 changes: 5 additions & 0 deletions Sources/SimplexArchitecture/Send.swift
Original file line number Diff line number Diff line change
@@ -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<Reducer: ReducerProtocol>: Sendable {
@usableFromInline
let sendAction: @Sendable (Reducer.Action) -> SendTask
Expand All @@ -18,18 +19,22 @@ public struct Send<Reducer: ReducerProtocol>: Sendable {
sendAction(action)
}

@_disfavoredOverload
@discardableResult
@inlinable
func callAsFunction(_ action: Reducer.ReducerAction) -> SendTask {
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
public func callAsFunction(_ action: Reducer.ReducerAction) async {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SimplexArchitecture/SendTask.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading