diff --git a/Macros/AsyncStateMacros.swift b/Macros/AsyncStateMacros.swift index c4dccef..d4432cd 100644 --- a/Macros/AsyncStateMacros.swift +++ b/Macros/AsyncStateMacros.swift @@ -12,6 +12,6 @@ import SwiftSyntaxMacros @main struct AsyncStateMacros: CompilerPlugin { var providingMacros: [Macro.Type] = [ - ModeledViewControllerMacro.self, + ModeledViewControllerMacro.self ] } diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index 28fa3e5..23bce49 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -18,51 +18,49 @@ import SwiftSyntaxMacros /// into /// ```swift /// final class SomeViewController: UIViewController { -/// typealias State = VCState /// -/// typealias ViewModel = VCModel +/// typealias State = VCState /// -/// let viewModel: ViewModel +/// typealias ViewModel = VCModel /// -/// private var stateObservingTask: Task? +/// private var stateObservingTask: Task? /// -/// } +/// let viewModel: ViewModel /// -/// extension SomeViewController { /// /// Start an asynchronous Task which receives state changes and renders them /// @MainActor -/// private func startObservingState(renderFirst: Bool) { -/// guard stateObservingTask?.isCancelled != false { -/// // already observing -/// return +/// private func startObservingState(renderImmediately: Bool = false) { +/// guard stateObservingTask?.isCancelled != false else { +/// // already observing +/// return /// } /// -/// let stateStream = viewModel.stateStream.observe() -/// Task { [weak self] in -/// if renderImmediately { -/// await self?.renderCurrentState() -/// } -/// -/// var stateIterator = stateStream.makeAsyncIterator() -/// while let newState = await stateIterator.next() { -/// self?.render(newState) -/// } -/// } -/// } -/// } -/// -/// /// Retrieve the current state from the ViewModel and render -/// public func renderCurrentState() async { -/// let currentState = await viewModel.currentState() -/// await render(currentState) -/// } +/// let stateStream = viewModel.stateStream.observe() +/// stateObservingTask = Task { [weak self] in +/// if renderImmediately { +/// await self?.renderCurrentState() +/// } +/// var stateIterator = stateStream.makeAsyncIterator() +/// while let newState = await stateIterator.next(), let self { +/// self.render(newState) +/// } +/// } +/// } /// -/// /// Renders the current state and observes +/// /// Stop observing state changes /// @MainActor /// private func stopObservingState() { /// stateObservingTask?.cancel() /// stateObservingTask = nil /// } +/// +/// /// Retrieve the current controller state +/// func currentState() async -> State { +/// await viewModel.currentState() +/// } +/// } +/// +/// extension SomeViewController: ModeledViewController { /// } /// ``` public enum ModeledViewControllerMacro { @@ -176,15 +174,14 @@ extension ModeledViewControllerMacro: MemberMacro { return } - if renderImmediately { - renderCurrentState() - } - let stateStream = viewModel.stateStream.observe() stateObservingTask = Task { [weak self] in + if renderImmediately { + await self?.renderCurrentState() + } var stateIterator = stateStream.makeAsyncIterator() - while let newState = await stateIterator.next() { - self?.render(newState) + while let newState = await stateIterator.next(), let self { + self.render(newState) } } } @@ -195,6 +192,11 @@ extension ModeledViewControllerMacro: MemberMacro { stateObservingTask?.cancel() stateObservingTask = nil } + + /// Retrieve the current controller state + func currentState() async -> State { + await viewModel.currentState() + } """ ) diff --git a/Package.swift b/Package.swift index d9539fc..9bc8ea3 100644 --- a/Package.swift +++ b/Package.swift @@ -11,15 +11,15 @@ let package = Package( .iOS(.v13), .tvOS(.v13), .watchOS(.v6), - .macCatalyst(.v13), + .macCatalyst(.v13) ], products: [ - .plugin(name: "AsyncStateTemplateInstaller", targets: ["TemplateInstallerPlugin"]), - .library(name: "AsyncState", targets: ["AsyncState"]), + .plugin(name: "AsyncStateTemplates", targets: ["TemplateInstallerPlugin"]), + .library(name: "AsyncState", targets: ["AsyncState"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2")), + .package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2")) ], targets: [ .macro( @@ -28,13 +28,17 @@ let package = Package( .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") ], path: "Macros" ), .plugin( name: "TemplateInstallerPlugin", - capability: .buildTool(), + capability: .command( + intent: .custom(verb: "async-state-templates", description: "Update XCTemplates for async state"), + permissions: [] + ) + , dependencies: [], path: "Plugins/TemplateInstaller" ), @@ -43,14 +47,14 @@ let package = Package( dependencies: [ "AsyncStateMacros", .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax") ], path: "Sources" ), .testTarget( name: "AsyncStateTests", dependencies: [ - "AsyncState", + "AsyncState" ], path: "Tests/AsyncStateTests" ), @@ -59,9 +63,9 @@ let package = Package( dependencies: [ "AsyncState", "AsyncStateMacros", - .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") ], path: "Tests/AsyncStateMacrosTests" - ), + ) ] ) diff --git a/Plugins/TemplateInstaller/InstallTemplatesPlugin.swift b/Plugins/TemplateInstaller/InstallTemplatesPlugin.swift new file mode 100644 index 0000000..26e2dcd --- /dev/null +++ b/Plugins/TemplateInstaller/InstallTemplatesPlugin.swift @@ -0,0 +1,40 @@ +import Foundation +import PackagePlugin + +/// Executes a command to install the lastest AsyncState templates +/// Requires an xcode restart to take effect +@main public struct InstallTemplatesPlugin: CommandPlugin { + public init() { } + public func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { + let fileManager = FileManager.default + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser + let customTemplateDirectory = homeDirectory.appendingPathComponent("Library/Developer/Xcode/Templates/Async State") + let sourceTemplatesDirectory = context.package.directory.appending("xctemplates") + + do { + if fileManager.fileExists(atPath: customTemplateDirectory.path) { + print("Removing existing templates") + let existingTemplates = try fileManager.contentsOfDirectory(atPath: customTemplateDirectory.path) + for template in existingTemplates { + let templatePath = customTemplateDirectory.appendingPathComponent(template).path + try fileManager.removeItem(atPath: templatePath) + } + } else { + print("Creating directory for custom Xcode templates") + try fileManager.createDirectory(at: customTemplateDirectory, withIntermediateDirectories: true, attributes: nil) + } + + print("Copying latest templates") + let templates = try fileManager.contentsOfDirectory(atPath: sourceTemplatesDirectory.string) + for template in templates { + let sourcePath = sourceTemplatesDirectory.appending(template) + let destinationPath = customTemplateDirectory.appendingPathComponent(template).path + try fileManager.copyItem(atPath: sourcePath.string, toPath: destinationPath) + print("Created: \(destinationPath)") + } + print("done") + } catch { + print("An error occurred: \(error)") + } + } +} diff --git a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift deleted file mode 100644 index 8f9e39f..0000000 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import PackagePlugin - -@main -public struct TemplateInstallerPlugin: BuildToolPlugin { - public func createBuildCommands(context: PluginContext, target _: Target) throws -> [Command] { - let scriptPath = context.package.directory.appending("scripts").appending("install-xctemplates.sh") - - return [ - .prebuildCommand( - displayName: "Update Async State Templates", - executable: scriptPath.string, - arguments: [], - environment: [:] - ), - ] - } -} diff --git a/README.md b/README.md index 6defd12..aa45936 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,10 @@ let observerTask = Task { [weak contactRepository] in ``` ## Templates -Install templates using the install script (likely `{project-path}/.build/checkouts/async-state/xctemplates/install-xctemplates.sh`) - -Run the script and templates will be installed locally. This may require an xcode restart. +Install templates using the `async-state-templates` [CommandPlugin](https://developer.apple.com/documentation/packagedescription/target/plugincapability-swift.enum/command(intent:permissions:)) +```bash +swift package --allow-writing-to-directory ~/Library/Developer/Xcode/Templates async-state-templates +``` Once installed, create a ModeledViewController using the latest template. diff --git a/Sources/Concurrency/AsyncBroadcast.swift b/Sources/Concurrency/AsyncBroadcast.swift index 47165e2..bb0369d 100644 --- a/Sources/Concurrency/AsyncBroadcast.swift +++ b/Sources/Concurrency/AsyncBroadcast.swift @@ -7,15 +7,25 @@ import Foundation -public protocol AsyncBroadcast { +public protocol AsyncBroadcast: Sendable { associatedtype Element: Sendable func observe() -> AsyncStream func observe(final: Element?) -> AsyncStream } +public protocol AsyncDispatcher: AsyncBroadcast { + func send(_ element: Element) +} + +extension AsyncDispatcher { + /// Downcast the dispatcher to hide sendability + /// - Returns: Dispatcher as an erased broadcast + public func erased() -> any AsyncBroadcast { self } +} + /// Broadcast asynchronously to a group of subscribers -public final class OpenAsyncBroadcast: AsyncBroadcast { +public final class OpenAsyncBroadcast: AsyncDispatcher, AsyncBroadcast, @unchecked Sendable { private let lock = NSLock() public private(set) var didFinish: Bool = false @@ -23,12 +33,12 @@ public final class OpenAsyncBroadcast: AsyncBroadcast { public init() {} - public func erased() -> any AsyncBroadcast { - self - } - public func send(_ element: Element) { lock.lock() + guard !didFinish else { + lock.unlock() + return + } let continuations = continuations lock.unlock() diff --git a/Sources/Effects/EffectHandling.swift b/Sources/Effects/EffectHandling.swift index eef1eb4..9299a5d 100644 --- a/Sources/Effects/EffectHandling.swift +++ b/Sources/Effects/EffectHandling.swift @@ -11,48 +11,44 @@ import Foundation public protocol EffectHandling: AnyObject { associatedtype HandledEffect: Effect - /// Handler declaration for a given effect. This is not called from a specific thread and syncronization is an obligation of the implementer + /// Handler declaration for a given effect /// - Parameter effect: ``Effect`` - func handle(_ effect: HandledEffect) + func handle(_ effect: HandledEffect) async /// Handler declaration for a batch of effects received at the same time. /// - Parameter effects: ``Array`` of ``Effect``s which are handled by this object - func handle(all effects: [HandledEffect]) + func handle(all effects: [HandledEffect]) async } public extension EffectHandling { - // Default implementation. Callers may implement this to handle batches of effects together - func handle(all effects: [HandledEffect]) { + // Default implementation. Callers may implement this to handle batches of effects simultaneously + func handle(all effects: [HandledEffect]) async { for effect in effects { - handle(effect) + await handle(effect) } } } -extension EffectHandling where Self: EffectMapping { +public extension EffectHandling where Self: EffectMapping { /// Handle some ``Effect`` if it can be mapped to a ``HandledEffect`` /// If the ``Effect`` is not mapped, it will be ignored. /// - Parameter effect: Any ``Effect`` which may or may not be mapped - func handleIfNeeded(_ effect: SomeEffect) { - assert( - SomeEffect.self != HandledEffect.self, - "\(effect) does not need to be mapped, was this called in error?" - ) - - Task { [weak self] in - guard let mappedEffect = await self?.map(effect) else { return } - - self?.handle(mappedEffect) + func handleIfNeeded(_ effect: any Effect) async { + #if !APPSTORE + if type(of: effect) == HandledEffect.self { + NSLog("Passed effect \(effect) is already \(type(of: HandledEffect.self)) and doesn't need to be mapped. Was this called in error?") } + #endif + guard let mappedEffect = await map(effect) else { return } + + await handle(mappedEffect) } /// Handle a batch of ``Effect``s if they can be mapped. ``Effect``s which are not mapped will be ignored. /// - Parameter effects: ``Array`` of ``Effect``s which may or may not be mapped to a ``HandledEffect`` - func handleIfNeeded(_ effects: [any Effect]) { - Task { [weak self] in - guard let mappedEffects = await self?.map(all: effects) else { return } + func handleIfNeeded(_ effects: [any Effect]) async { + let mappedEffects = await map(all: effects) - self?.handle(all: mappedEffects) - } + await handle(all: mappedEffects) } } diff --git a/Sources/Events/EventReceiving.swift b/Sources/Events/EventReceiving.swift index 7bf67a9..917af02 100644 --- a/Sources/Events/EventReceiving.swift +++ b/Sources/Events/EventReceiving.swift @@ -7,48 +7,57 @@ import Foundation -public protocol EventReceiving: EffectHandling {} +public protocol EventReceiving: EffectHandling { + associatedtype ReceivedEvent: Event + associatedtype State: ObjectState + + var eventReducer: any EventReducer { get } + + func didReceive(_ event: ReceivedEvent) async +} + +public extension EventReceiving where State == Empty { + func didReceive(_ event: ReceivedEvent) async { + var effects = [HandledEffect]() + eventReducer.reduce(event, for: Empty.state, into: &effects) + await handle(all: effects) + } +} + +public extension EventReceiving where Self: Stateful { + func didReceive(_ event: ReceivedEvent) async { + var effects = [HandledEffect]() + let currentState = await currentState() + eventReducer.reduce(event, for: currentState, into: &effects) + await handle(all: effects) + } +} public extension EventReceiving { /// Receive ``Event``s from a streaming object and translate them into ``Effect``s /// - Parameters: /// - eventStreamer: Object streaming ``Event``s /// - effectMapping: ``Effect`` these ``Event``s have - func receiveEvents( - from eventStreamer: some EventStreaming, - effectMapping: @escaping (ReceivedEvent) -> [HandledEffect] - ) { + @discardableResult + func receiveEvents( + from eventStreamer: some EventStreaming, + as eventMapping: @escaping (StreamedEvent) async -> ReceivedEvent? + ) -> Task { Task { [weak self, weak eventStreamer] in guard var iterator = eventStreamer?.eventStream.observe().makeAsyncIterator() else { return } - while let event = await iterator.next(), let self { - let effects = effectMapping(event) - if effects.isEmpty { continue } - handle(all: effects) - } + while let event = await iterator.next(), !Task.isCancelled, let self { + guard + let mappedEvent = await eventMapping(event) + else { continue } + await self.didReceive(mappedEvent) + } } } -} - -public extension EventReceiving where Self: EventStreaming { - func receiveEvents( - from eventStreamer: some EventStreaming, - rebroadcasting eventMapping: @escaping (ReceivedEvent) -> StreamedEvent?, - effectMapping: @escaping (ReceivedEvent) -> [HandledEffect] - ) { - Task { [weak self, weak eventStreamer] in - guard var iterator = eventStreamer?.eventStream.observe().makeAsyncIterator() else { return } - while let event = await iterator.next(), let self { - // - if let rebroadcasted = eventMapping(event) { - // This is a little funky. Should we keep the send function open? - (eventStream as? OpenAsyncBroadcast)?.send(rebroadcasted) - } - let effects = effectMapping(event) - if !effects.isEmpty { - handle(all: effects) - } - } + @discardableResult + func receiveEvents( + from eventStreamer: some EventStreaming + ) -> Task where StreamedEvent == ReceivedEvent { + receiveEvents(from: eventStreamer, as: { $0 }) } - } } diff --git a/Sources/Events/EventReducer.swift b/Sources/Events/EventReducer.swift new file mode 100644 index 0000000..39c0ff8 --- /dev/null +++ b/Sources/Events/EventReducer.swift @@ -0,0 +1,38 @@ +// +// EventReducer.swift +// +// +// Created by Peter Schuette on 9/19/24. +// + +import Foundation + +public protocol EventReducer: Sendable { + associatedtype Incoming: Event + associatedtype State: ObjectState + associatedtype Outgoing: Effect + + func reduce( + event: Incoming, + for state: State, + into effects: inout [Outgoing] + ) +} + +public extension EventReducer { + func reduce( + _ event: any Event, + for state: State, + into effects: inout [Outgoing] + ) { + guard let incoming = event as? Incoming else { return } + + reduce(event: incoming, for: state, into: &effects) + } +} + +public extension EventReducer where State == Empty { + func reduce(_ event: Incoming, into effects: inout [Outgoing]) { + reduce(event: event, for: Empty.state, into: &effects) + } +} diff --git a/Sources/MVVM/ModeledViewController.swift b/Sources/MVVM/ModeledViewController.swift index 3aacf95..521b3d3 100644 --- a/Sources/MVVM/ModeledViewController.swift +++ b/Sources/MVVM/ModeledViewController.swift @@ -18,7 +18,8 @@ import SwiftSyntaxMacros named(viewModel), named(stateObservingTask), named(startObservingState(renderImmediately:)), - named(stopObservingState) + named(stopObservingState), + named(currentState) ) @attached(extension, conformances: ModeledViewController) public macro Modeled(_: State.Type, _: ViewModel.Type) = #externalMacro( @@ -34,7 +35,8 @@ public macro Modeled(_: State.Type, named(viewModel), named(stateObservingTask), named(startObservingState(renderImmediately:)), - named(stopObservingState) + named(stopObservingState), + named(currentState) ) @attached(extension, conformances: ModeledViewController) public macro Modeled(_: State.Type, interface: ViewModel) = #externalMacro( @@ -53,15 +55,6 @@ public macro Modeled(_: State.Type, interface: Vi typealias Items = State.Items } - extension ModeledViewController { - /// Retrieve the current state from the ViewModel and render - @MainActor - public func renderCurrentState() { - let currentState = viewModel.currentState() - render(currentState) - } - } - #else @available(*, deprecated, message: "This is a test-only implementation. It is intended to be used with a UIViewController subclass") public protocol ModeledViewController: AnyObject, StateRendering where ViewModel: ViewModeling, ViewModel.State == State { diff --git a/Sources/State/Empty.swift b/Sources/State/Empty.swift new file mode 100644 index 0000000..38080cf --- /dev/null +++ b/Sources/State/Empty.swift @@ -0,0 +1,14 @@ +// +// Empty.swift +// +// +// Created by Peter Schuette on 9/19/24. +// + +import Foundation + +public struct Empty: ObjectState { + public init() {} + + static let state = Empty() +} diff --git a/Sources/State/StateRendering.swift b/Sources/State/StateRendering.swift index b9dd6f8..8ca2cc0 100644 --- a/Sources/State/StateRendering.swift +++ b/Sources/State/StateRendering.swift @@ -11,3 +11,11 @@ public protocol StateRendering: Stateful { @MainActor func render(_ state: State) } + +extension StateRendering { + /// Pass the current State to ``render(_:)`` + public func renderCurrentState() async { + let state = await currentState() + await render(state) + } +} diff --git a/Sources/State/Stateful.swift b/Sources/State/Stateful.swift index 6de9e75..c853b90 100644 --- a/Sources/State/Stateful.swift +++ b/Sources/State/Stateful.swift @@ -9,4 +9,6 @@ import Foundation public protocol Stateful { associatedtype State: ObjectState + + func currentState() async -> State } diff --git a/Sources/_TemplatesPlugin/TemplatesPlugin.swift b/Sources/_TemplatesPlugin/TemplatesPlugin.swift deleted file mode 100644 index 0959033..0000000 --- a/Sources/_TemplatesPlugin/TemplatesPlugin.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// TemplatesPlugin.swift -// AsyncState -// -// Created by Peter Schuette on 9/18/24. -// - -@freestanding(expression) -macro installAsyncStateTemplates() -> String = #externalMacro(module: "AsyncStateTemplateInstaller", type: "TemplateInstallerPlugin") diff --git a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift index 9c07b66..6534795 100644 --- a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift +++ b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift @@ -4,7 +4,7 @@ // // Created by Peter Schuette on 6/23/24. // - +#if canImport(AsyncStateMacros) import AsyncState import AsyncStateMacros import Foundation @@ -41,15 +41,14 @@ final class ModeledViewControllerMacroTests: XCTestCase { return } - if renderImmediately { - renderCurrentState() - } - let stateStream = viewModel.stateStream.observe() stateObservingTask = Task { [weak self] in + if renderImmediately { + await self?.renderCurrentState() + } var stateIterator = stateStream.makeAsyncIterator() - while let newState = await stateIterator.next() { - await self?.render(newState) + while let newState = await stateIterator.next(), let self { + self.render(newState) } } } @@ -60,6 +59,11 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask?.cancel() stateObservingTask = nil } + + /// Retrieve the current controller state + func currentState() async -> State { + await viewModel.currentState() + } } extension SomeViewController: ModeledViewController { @@ -96,15 +100,14 @@ final class ModeledViewControllerMacroTests: XCTestCase { return } - if renderImmediately { - renderCurrentState() - } - let stateStream = viewModel.stateStream.observe() stateObservingTask = Task { [weak self] in + if renderImmediately { + await self?.renderCurrentState() + } var stateIterator = stateStream.makeAsyncIterator() - while let newState = await stateIterator.next() { - await self?.render(newState) + while let newState = await stateIterator.next(), let self { + self.render(newState) } } } @@ -115,6 +118,11 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask?.cancel() stateObservingTask = nil } + + /// Retrieve the current controller state + func currentState() async -> State { + await viewModel.currentState() + } } extension SomeViewController: ModeledViewController { @@ -144,3 +152,5 @@ private final class SomeViewModel: ViewModelProtocol { state } } + +#endif diff --git a/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift new file mode 100644 index 0000000..fe50c3c --- /dev/null +++ b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift @@ -0,0 +1,79 @@ +// +// EffectHandlingTests.swift +// +// +// Created by Peter Schuette on 9/27/24. +// + +import Foundation +@testable import AsyncState +import XCTest + +final class EffectHandlingTests: XCTestCase { + enum MockEffect: Effect { + case someMappable(Int) + case someUnmappable + } + + private var effectHandler: MockEffectHandler! + + override func setUp() async throws { + effectHandler = await .init() + await setupEffectHandlerMapping() + } + + @MainActor + private func setupEffectHandlerMapping() async { + effectHandler.mapping = { effect in + guard let effect = effect as? MockEffect else { return nil } + + return switch effect { + case let .someMappable(value): .some(value) + case .someUnmappable: nil + } + } + } + + override func tearDown() { + effectHandler = nil + } + + @MainActor + func test_handleIfNeeded_mapsEffect_andHandlesMappedEffect() async { + + await effectHandler.handleIfNeeded(MockEffect.someMappable(123)) + XCTAssert( + effectHandler.mappedEffects.contains(where: { ($0 as? MockEffect) == .someMappable(123)}) + ) + } + + @MainActor + func test_handleIfNeeded_ignoresUnmappedEffects() async { + + await effectHandler.handleIfNeeded(MockEffect.someMappable(123)) + await effectHandler.handleIfNeeded(MockEffect.someUnmappable) + + XCTAssert( + effectHandler.mappedEffects.contains( + where: { ($0 as? MockEffect) == .someUnmappable } + ) + ) + XCTAssert( + effectHandler.mappedEffects.contains( + where: { ($0 as? MockEffect) == .someMappable(123) } + ) + ) + } + + @MainActor + func test_handleAllIfNeeded_mapsEffects_andHandlesMappedEffects() async { + await effectHandler.handleIfNeeded([ + MockEffect.someMappable(123), + MockEffect.someMappable(123), + MockEffect.someMappable(123) + ]) + + XCTAssertEqual(effectHandler.mappedEffects.count, 3) + } + +} diff --git a/Tests/AsyncStateTests/Effects/EffectMappingTests.swift b/Tests/AsyncStateTests/Effects/EffectMappingTests.swift new file mode 100644 index 0000000..fecf0a7 --- /dev/null +++ b/Tests/AsyncStateTests/Effects/EffectMappingTests.swift @@ -0,0 +1,54 @@ +// +// EffectMappingTests.swift +// +// +// Created by Peter Schuette on 9/29/24. +// + +import Foundation +@testable import AsyncState +import XCTest + +final class EffectMappingTests: XCTestCase { + enum MockEffect: Effect { + case someMappable(Int) + case someUnmappable + } + + private var effectHandler: MockEffectHandler! + + override func setUp() async throws { + effectHandler = await .init() + await setupEffectHandlerMapping() + } + + @MainActor + private func setupEffectHandlerMapping() async { + effectHandler.mapping = { effect in + guard let effect = effect as? MockEffect else { return nil } + + return switch effect { + case let .someMappable(value): .some(value) + case .someUnmappable: nil + } + } + } + + override func tearDown() { + effectHandler = nil + } + + @MainActor + func test_mapAllEffects_mapsArrayOfEffects_andHandlesMappableEffects() async { + + let handledEffects = await effectHandler.map(all: [ + MockEffect.someMappable(123), + MockEffect.someUnmappable, + MockEffect.someMappable(456) + ]) + + XCTAssertEqual( + handledEffects, [MockEffectHandler.HandledEffect.some(123), .some(456)] + ) + } +} diff --git a/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift b/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift new file mode 100644 index 0000000..59b1d75 --- /dev/null +++ b/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift @@ -0,0 +1,35 @@ +// +// MockEffectHandler.swift +// +// +// Created by Peter Schuette on 9/29/24. +// + +import Foundation +import AsyncState + +@MainActor +final class MockEffectHandler: EffectHandling, EffectMapping { + var mapping: ((any Effect) -> HandledEffect?)? + + var mappedEffects = [any Effect]() + var handledEffects = [HandledEffect]() + + enum HandledEffect: Effect { + case some(Int) + case someOther(Int) + } + + func handle(_ effect: HandledEffect) async { + self.handledEffects.append(effect) + } + + func map(_ effect: any AsyncState.Effect) async -> HandledEffect? { + mappedEffects.append(effect) + return mapping?(effect) + } + + func setMapping(_ mapping: @escaping ((any Effect) -> HandledEffect?)) { + self.mapping = mapping + } +} diff --git a/Tests/AsyncStateTests/Events/EventReducerTests.swift b/Tests/AsyncStateTests/Events/EventReducerTests.swift new file mode 100644 index 0000000..7efc2d7 --- /dev/null +++ b/Tests/AsyncStateTests/Events/EventReducerTests.swift @@ -0,0 +1,89 @@ +// +// EventReducerTests.swift +// +// +// Created by Peter Schuette on 10/7/24. +// + +import Foundation +@testable import AsyncState +import XCTest + +final class EventReducerTests: XCTestCase { + enum SomeEvent: Event { + case a(Int) + case b(Int) + case c + } + + enum SomeEffect: Effect { + case some(Int) + } + + func test_reduceA_addsSomeEffectToOutgoingEffects() { + let reducer = TestEventReducer() + var effects = [TestEventReducer.Outgoing]() + reducer.reduce(event: .a(123), for: .init(someInt: 0), into: &effects) + XCTAssertEqual(effects, [.some(123)]) + } + + func test_reduceB_addsTwoEffectsToOutgoingEffects() { + let reducer = TestEventReducer() + var effects = [TestEventReducer.Outgoing]() + reducer.reduce(event: .b(123), for: .init(someInt: 0), into: &effects) + XCTAssertEqual(effects, [.some(0), .some(123)]) + } + + func test_reduceC_doesNotAddToOutgoingEffects() { + let reducer = TestEventReducer() + var effects = [TestEventReducer.Outgoing]() + reducer.reduce(event: .c, for: .init(someInt: 0), into: &effects) + XCTAssertEqual(effects, []) + } + + func test_emptyStateReducer_addsEffectToOutgoingEffects() { + let reducer = EmptyStateEventReducer() + var effects = [EmptyStateEventReducer.Outgoing]() + reducer.reduce(.a(123), into: &effects) + XCTAssertEqual(effects, [.some(123)]) + } +} + +extension EventReducerTests { + private struct TestEventReducer: EventReducer { + typealias State = SomeState + typealias Incoming = SomeEvent + typealias Outgoing = SomeEffect + + struct SomeState: ObjectState { + var someInt: Int + } + + func reduce(event: Incoming, for state: State, into effects: inout [Outgoing]) { + switch event { + case let .a(aInt): + effects.append(.some(aInt)) + case let .b(bInt): + effects.append(.some(state.someInt)) + effects.append(.some(bInt)) + case .c: + break + } + } + } + + private struct EmptyStateEventReducer: EventReducer { + typealias State = Empty + typealias Incoming = SomeEvent + typealias Outgoing = SomeEffect + + func reduce(event: Incoming, for state: State, into effects: inout [Outgoing]) { + switch event { + case let .a(aInt): + effects.append(.some(aInt)) + case .b, .c: + break + } + } + } +} diff --git a/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift b/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift index 3353bbd..ce14434 100644 --- a/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift +++ b/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift @@ -30,6 +30,15 @@ @Modeled(SimpleViewState.self, SimpleViewModel.self) final class SimpleViewController: UIViewController { private(set) var didRender = false + init(viewModel: SimpleViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + @MainActor func render(_: SimpleViewState) { didRender = true