diff --git a/Package.resolved b/Package.resolved index 9fc6a39..e567def 100644 --- a/Package.resolved +++ b/Package.resolved @@ -81,6 +81,15 @@ "version" : "509.0.0" } }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation.git", + "state" : { + "revision" : "6eb293c49505d86e9e24232cb6af6be7fff93bd5", + "version" : "1.0.2" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 4ecde0e..9ac7c1d 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,8 @@ let package = Package( .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"), - .package(url: "https://github.com/pointfreeco/swift-macro-testing", exact: "0.1.0"), + .package(url: "https://github.com/pointfreeco/swiftui-navigation.git", exact: "1.0.2"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing.git", exact: "0.1.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -36,6 +37,7 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "SwiftUINavigation", package: "swiftui-navigation"), ] ), .macro( diff --git a/Sources/SimplexArchitecture/ActionSendable.swift b/Sources/SimplexArchitecture/ActionSendable.swift index f5643a4..9e6c8a8 100644 --- a/Sources/SimplexArchitecture/ActionSendable.swift +++ b/Sources/SimplexArchitecture/ActionSendable.swift @@ -22,6 +22,35 @@ public extension ActionSendable { } } + @discardableResult + func send(_ action: consuming Reducer.Action, animation: Animation?) -> SendTask { + threadCheck() + return if store.container == nil { + withAnimation(animation) { + store.sendAction(action, target: self) + } + } else { + withAnimation(animation) { + store.sendIfNeeded(action) + } + } + } + + /// Send an action to the store with transaction + @discardableResult + func send(_ action: consuming Reducer.Action, transaction: Transaction) -> SendTask { + threadCheck() + return if store.container == nil { + withTransaction(transaction) { + store.sendAction(action, target: self) + } + } else { + withTransaction(transaction) { + store.sendIfNeeded(action) + } + } + } + @inline(__always) func threadCheck() { #if DEBUG diff --git a/Sources/SimplexArchitecture/Effect/SideEffect.swift b/Sources/SimplexArchitecture/Effect/SideEffect.swift index d05ede7..2e6a25f 100644 --- a/Sources/SimplexArchitecture/Effect/SideEffect.swift +++ b/Sources/SimplexArchitecture/Effect/SideEffect.swift @@ -37,10 +37,23 @@ public extension SideEffect { @inlinable static func run( priority: TaskPriority? = nil, - _ operation: @Sendable @escaping (_ send: Send) async throws -> Void, + _ operation: @Sendable @escaping (_ send: Send) async throws -> Void, catch: (@Sendable (_ error: any Error, _ send: Send) async -> Void)? = nil ) -> Self { - .init(effectKind: .run(priority: priority, operation: operation, catch: `catch`)) + // If the .dependency modifier is used, the dependency must be conveyed to the escape context. + withEscapedDependencies { continuation in + .init( + effectKind: .run( + priority: priority, + operation: { send in + try await continuation.yield { + try await operation(send) + } + }, + catch: `catch` + ) + ) + } } @inlinable diff --git a/Sources/SimplexArchitecture/Internal/Dependencies+isTesting.swift b/Sources/SimplexArchitecture/Internal/Dependencies+isTesting.swift new file mode 100644 index 0000000..ec92307 --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/Dependencies+isTesting.swift @@ -0,0 +1,13 @@ +import Foundation + +enum IsTestingKey: DependencyKey { + static let liveValue: Bool = _XCTIsTesting + static let testValue: Bool = _XCTIsTesting +} + +extension DependencyValues { + var isTesting: Bool { + get { self[IsTestingKey.self] } + set { self[IsTestingKey.self] = newValue } + } +} diff --git a/Sources/SimplexArchitecture/Internal/Exported.swift b/Sources/SimplexArchitecture/Internal/Exported.swift new file mode 100644 index 0000000..ca6e8f9 --- /dev/null +++ b/Sources/SimplexArchitecture/Internal/Exported.swift @@ -0,0 +1,2 @@ +@_exported import SwiftUINavigation +@_exported import Dependencies diff --git a/Sources/SimplexArchitecture/Internal/TestOnly.swift b/Sources/SimplexArchitecture/Internal/TestOnly.swift index 47b791f..980a5e0 100644 --- a/Sources/SimplexArchitecture/Internal/TestOnly.swift +++ b/Sources/SimplexArchitecture/Internal/TestOnly.swift @@ -5,17 +5,23 @@ import XCTestDynamicOverlay struct TestOnly { private var _value: T + @Dependency(\.isTesting) var isTesting + var wrappedValue: T { _read { - if !_XCTIsTesting { + #if DEBUG + if !isTesting { runtimeWarning("\(Self.self) is accessible only during Unit tests") } + #endif yield _value } set { - if _XCTIsTesting { + #if DEBUG + if isTesting { _value = newValue } + #endif } } diff --git a/Sources/SimplexArchitecture/StateContainer.swift b/Sources/SimplexArchitecture/StateContainer.swift index b6f6999..fbad524 100644 --- a/Sources/SimplexArchitecture/StateContainer.swift +++ b/Sources/SimplexArchitecture/StateContainer.swift @@ -12,6 +12,7 @@ public final class StateContainer { var _reducerState: Target.Reducer.ReducerState? var entity: Target @TestOnly var viewState: Target.ViewState? + @Dependency(\.isTesting) private var isTesting init( _ entity: consuming Target, @@ -25,10 +26,12 @@ public final class StateContainer { public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { - guard !_XCTIsTesting else { + #if DEBUG + guard !isTesting else { yield viewState![keyPath: keyPath] return } + #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield entity[keyPath: viewKeyPath] } else { @@ -36,10 +39,12 @@ public final class StateContainer { } } _modify { - guard !_XCTIsTesting else { + #if DEBUG + guard !isTesting else { yield &viewState![keyPath: keyPath] return } + #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &entity[keyPath: viewKeyPath] } else { diff --git a/Sources/SimplexArchitecture/Store/Store+send.swift b/Sources/SimplexArchitecture/Store/Store+send.swift index 0483de0..b5efd39 100644 --- a/Sources/SimplexArchitecture/Store/Store+send.swift +++ b/Sources/SimplexArchitecture/Store/Store+send.swift @@ -69,9 +69,12 @@ extension Store { } } + let sideEffect: SideEffect // If Unit Testing is in progress and an action is sent from SideEffect - if _XCTIsTesting, let effectContext = EffectContext.id { + #if DEBUG + @Dependency(\.isTesting) var isTesting + if let effectContext = EffectContext.id, isTesting { let before = container.copy() sideEffect = reduce(container, action) sentFromEffectActions.append( @@ -86,6 +89,9 @@ extension Store { } else { sideEffect = reduce(container, action) } + #else + sideEffect = reduce(container, action) + #endif if case .none = sideEffect.kind { return .never diff --git a/Sources/SimplexArchitecture/SwiftUI/Alert.swift b/Sources/SimplexArchitecture/SwiftUI/Alert.swift new file mode 100644 index 0000000..33ed5b1 --- /dev/null +++ b/Sources/SimplexArchitecture/SwiftUI/Alert.swift @@ -0,0 +1,66 @@ +import SwiftUI + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +public extension View { + func alert( + target: Target, + unwrapping value: Binding?> + ) -> some View { + self.alert( + (value.wrappedValue?.title).map(Text.init) ?? Text(""), + isPresented: value.isPresent(), + presenting: value.wrappedValue, + actions: { alertState in + ForEach(alertState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + target.send(action) + } + case let .animatedSend(action, animation): + if let action = action { + target.send(action, animation: animation) + } + } + } label: { + Text(button.label) + } + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + func alert( + target: Target, + unwrapping value: Binding?> + ) -> some View { + self.alert( + (value.wrappedValue?.title).map(Text.init) ?? Text(""), + isPresented: value.isPresent(), + presenting: value.wrappedValue, + actions: { alertState in + ForEach(alertState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + _ = target.store.sendAction(action, target: target) + } + case let .animatedSend(action, animation): + if let action = action { + _ = withAnimation(animation) { + target.store.sendAction(action, target: target) + } + } + } + } label: { + Text(button.label) + } + } + }, + message: { $0.message.map { Text($0) } } + ) + } +} diff --git a/Sources/SimplexArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/SimplexArchitecture/SwiftUI/ConfirmationDialog.swift new file mode 100644 index 0000000..450694e --- /dev/null +++ b/Sources/SimplexArchitecture/SwiftUI/ConfirmationDialog.swift @@ -0,0 +1,68 @@ +import SwiftUI + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +public extension View { + func confirmationDialog( + target: Target, + unwrapping value: Binding?> + ) -> some View { + self.confirmationDialog( + value.wrappedValue.flatMap { Text($0.title) } ?? Text(""), + isPresented: value.isPresent(), + titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: value.wrappedValue, + actions: { confirmationDialogState in + ForEach(confirmationDialogState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + target.send(action) + } + case let .animatedSend(action, animation): + if let action = action { + target.send(action, animation: animation) + } + } + } label: { + Text(button.label) + } + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + func confirmationDialog( + target: Target, + unwrapping value: Binding?> + ) -> some View { + self.confirmationDialog( + value.wrappedValue.flatMap { Text($0.title) } ?? Text(""), + isPresented: value.isPresent(), + titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: value.wrappedValue, + actions: { confirmationDialogState in + ForEach(confirmationDialogState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + _ = target.store.sendAction(action, target: target) + } + case let .animatedSend(action, animation): + if let action = action { + _ = withAnimation(animation) { + target.store.sendAction(action, target: target) + } + } + } + } label: { + Text(button.label) + } + } + }, + message: { $0.message.map { Text($0) } } + ) + } +} diff --git a/Sources/SimplexArchitecture/TaskResult+VoidSuccess.swift b/Sources/SimplexArchitecture/TaskResult+VoidSuccess.swift index 7bf0351..9d29d65 100644 --- a/Sources/SimplexArchitecture/TaskResult+VoidSuccess.swift +++ b/Sources/SimplexArchitecture/TaskResult+VoidSuccess.swift @@ -4,7 +4,7 @@ public struct VoidSuccess: Codable, Sendable, Hashable { } /// An extension on `TaskResult` where the success type is `VoidSuccess`. -extension TaskResult where Success == VoidSuccess { +public extension TaskResult where Success == VoidSuccess { /// Creates a new task result by evaluating an async throwing closure. /// /// This initializer is used to handle asynchronous operations that produce a result of type `Void`. @@ -15,6 +15,7 @@ extension TaskResult where Success == VoidSuccess { /// This initializer is often used within an async effect that's returned from a reducer. /// /// - Parameter body: An async, throwing closure. + @_disfavoredOverload init(catching body: @Sendable () async throws -> Void) async { do { try await body() diff --git a/Sources/SimplexArchitecture/TaskResult.swift b/Sources/SimplexArchitecture/TaskResult.swift index 693bc8b..7bbab61 100644 --- a/Sources/SimplexArchitecture/TaskResult.swift +++ b/Sources/SimplexArchitecture/TaskResult.swift @@ -6,7 +6,7 @@ public enum TaskResult: Sendable { case failure(any Error) @inlinable - public init(catching body: () async throws -> Success) async { + public init(catching body: @Sendable () async throws -> Success) async { do { self = try .success(await body()) } catch { diff --git a/Tests/SimplexArchitectureTests/DependenciesOverrideModifierTests.swift b/Tests/SimplexArchitectureTests/DependenciesOverrideModifierTests.swift index 69f67aa..3535fd7 100644 --- a/Tests/SimplexArchitectureTests/DependenciesOverrideModifierTests.swift +++ b/Tests/SimplexArchitectureTests/DependenciesOverrideModifierTests.swift @@ -1,20 +1,24 @@ @testable import SimplexArchitecture import SwiftUI -import Dependencies import XCTest +@MainActor final class DependenciesOverrideModifierTests: XCTestCase { func testModifier() async { + let isCalled = LockIsolated(false) let base = BaseView( store: Store( reducer: _DependenciesOverrideModifier(base: BaseReducer()) { - $0.test = .init(asyncThrows: {}) + $0.test = .init(asyncThrows: { + isCalled.setValue(true) + }) } ) ) let container = base.store.setContainerIfNeeded(for: base, viewState: .init()) await base.send(.test).wait() XCTAssertEqual(container.count, 1) + XCTAssertTrue(isCalled.value) } } diff --git a/Tests/SimplexArchitectureTests/ReducerTests.swift b/Tests/SimplexArchitectureTests/ReducerTests.swift index 92bbf91..1a07000 100644 --- a/Tests/SimplexArchitectureTests/ReducerTests.swift +++ b/Tests/SimplexArchitectureTests/ReducerTests.swift @@ -1,7 +1,6 @@ @testable import SimplexArchitecture import SwiftUI import XCTest -import Dependencies @MainActor final class ReducerTests: XCTestCase { @@ -137,7 +136,7 @@ struct TestDependency: DependencyKey { } public static let liveValue: TestDependency = .init(asyncThrows: { throw CancellationError() }) - public static let testValue: TestDependency = .init(asyncThrows: {}) + public static let testValue: TestDependency = .init(asyncThrows: unimplemented("testValue is umimplemented")) } extension DependencyValues { diff --git a/Tests/SimplexArchitectureTests/StoreTests.swift b/Tests/SimplexArchitectureTests/StoreTests.swift index ae3e32d..81c473c 100644 --- a/Tests/SimplexArchitectureTests/StoreTests.swift +++ b/Tests/SimplexArchitectureTests/StoreTests.swift @@ -142,6 +142,44 @@ final class StoreTests: XCTestCase { XCTAssertNotNil(store.pullbackReducerAction) XCTAssertEqual(parent.store.container?.id, uuid) } + + func testIsNotUsingViewState() throws { + let container = withDependencies { + $0.isTesting = false + } operation: { + store.setContainerIfNeeded(for: TestView(), viewState: .init()) + } + XCTAssertNil(container.viewState) + } + + func testUsingViewState() throws { + let container = withDependencies { + $0.isTesting = true + } operation: { + store.setContainerIfNeeded(for: TestView(), viewState: .init()) + } + XCTAssertNotNil(container.viewState) + } + + func testSendIsNotTesting() async throws { + let container = store.setContainerIfNeeded(for: TestView()) + await withDependencies { + $0.isTesting = false + } operation: { + await store.sendAction(.action(.c3), container: container).wait() + } + XCTAssertEqual(store.sentFromEffectActions.count, 0) + } + + func testSendIsTesting() async throws { + let container = store.setContainerIfNeeded(for: TestView()) + await withDependencies { + $0.isTesting = true + } operation: { + await store.sendAction(.action(.c3), container: container).wait() + } + XCTAssertEqual(store.sentFromEffectActions.count, 1) + } } @ViewState diff --git a/Tests/SimplexArchitectureTests/TaskResultTests.swift b/Tests/SimplexArchitectureTests/TaskResultTests.swift index a084fa4..615f3b2 100644 --- a/Tests/SimplexArchitectureTests/TaskResultTests.swift +++ b/Tests/SimplexArchitectureTests/TaskResultTests.swift @@ -2,8 +2,8 @@ import XCTest final class TaskResultTests: XCTestCase { - func testCatching() { - let result1 = TaskResult { + func testCatching() async { + let result1 = await TaskResult { throw CancellationError() } switch result1 { @@ -13,7 +13,7 @@ final class TaskResultTests: XCTestCase { XCTAssertTrue(error is CancellationError) } - let result2 = TaskResult {} + let result2 = await TaskResult {} switch result2 { case .success: break diff --git a/Tests/SimplexArchitectureTests/TestOnlyTests.swift b/Tests/SimplexArchitectureTests/TestOnlyTests.swift new file mode 100644 index 0000000..dafa8e8 --- /dev/null +++ b/Tests/SimplexArchitectureTests/TestOnlyTests.swift @@ -0,0 +1,24 @@ +@testable import SimplexArchitecture +import XCTest + +final class TestOnlyTests: XCTestCase { + func testIsTesting() { + var testOnly = withDependencies { + $0.isTesting = true + } operation: { + TestOnly(wrappedValue: true) + } + testOnly.wrappedValue = false + XCTAssertFalse(testOnly.wrappedValue) + } + + func testIsNotTesting() { + var testOnly = withDependencies { + $0.isTesting = false + } operation: { + TestOnly(wrappedValue: true) + } + testOnly.wrappedValue = false + XCTAssertTrue(testOnly.wrappedValue) + } +}