From 972b00be6758a79471d4d7000cf01fbdccd52b92 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 20 Sep 2024 13:22:32 -0400 Subject: [PATCH 01/13] initial reducer commit --- .../TemplateInstallerPlugin.swift | 7 +++-- Sources/Events/EventReceiving.swift | 22 ++++++++++------ .../Reducers/AggregateEventReducer.swift | 26 +++++++++++++++++++ Sources/Events/Reducers/EventReducer.swift | 22 ++++++++++++++++ Sources/State/Empty.swift | 14 ++++++++++ .../ModeledViewControllerMacroTests.swift | 4 +-- 6 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 Sources/Events/Reducers/AggregateEventReducer.swift create mode 100644 Sources/Events/Reducers/EventReducer.swift create mode 100644 Sources/State/Empty.swift diff --git a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift index 8f9e39f..2423182 100644 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift @@ -3,15 +3,18 @@ import PackagePlugin @main public struct TemplateInstallerPlugin: BuildToolPlugin { + public init() { } + 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, + executable: scriptPath, arguments: [], - environment: [:] + environment: [:], + outputFilesDirectory: context.package.directory ), ] } diff --git a/Sources/Events/EventReceiving.swift b/Sources/Events/EventReceiving.swift index 7bf67a9..d97c79c 100644 --- a/Sources/Events/EventReceiving.swift +++ b/Sources/Events/EventReceiving.swift @@ -7,23 +7,29 @@ import Foundation -public protocol EventReceiving: EffectHandling {} +public protocol EventReceiving: EffectHandling { + associatedtype ReceivedEvent: Event + + func effects(of event: ReceivedEvent) -> [HandledEffect] +} 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] + func receiveEvents( + from eventStreamer: some EventStreaming, + as eventMapping: @escaping (StreamedEvent) async -> ReceivedEvent? ) { 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() { + guard + let mappedEvent = await eventMapping(event), + let effects = self?.effects(of: mappedEvent) + else { continue } + self?.handle(all: effects) } } } diff --git a/Sources/Events/Reducers/AggregateEventReducer.swift b/Sources/Events/Reducers/AggregateEventReducer.swift new file mode 100644 index 0000000..04d28b2 --- /dev/null +++ b/Sources/Events/Reducers/AggregateEventReducer.swift @@ -0,0 +1,26 @@ +// +// AggregateEventReducer.swift +// +// +// Created by Peter Schuette on 9/19/24. +// + +import Foundation + +public class AggregateEventReducer { + public private(set) var reducers = [any EventReducer]() + + public init(reducers: [any EventReducer] = [any EventReducer]()) { + self.reducers = reducers + } + + public func reduce( + _ event: any Event, + for state: some ObjectState, + into effects: inout [Outgoing] + ) { + for reducer in reducers { + reducer.reduce(event, for: state, into: &effects) + } + } +} diff --git a/Sources/Events/Reducers/EventReducer.swift b/Sources/Events/Reducers/EventReducer.swift new file mode 100644 index 0000000..298c759 --- /dev/null +++ b/Sources/Events/Reducers/EventReducer.swift @@ -0,0 +1,22 @@ +// +// EventReducer.swift +// +// +// Created by Peter Schuette on 9/19/24. +// + +import Foundation + +public protocol EventReducer { + func reduce( + _ event: Incoming, + for state: State, + into effects: inout [Outgoing] + ) +} + +public extension EventReducer { + func reduce(_ event: Incoming, into effects: inout [Outgoing]) { + reduce(event, for: Empty.state, into: &effects) + } +} diff --git a/Sources/State/Empty.swift b/Sources/State/Empty.swift new file mode 100644 index 0000000..24c7d98 --- /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 var state = Empty() +} diff --git a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift index 9c07b66..da51189 100644 --- a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift +++ b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift @@ -49,7 +49,7 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask = Task { [weak self] in var stateIterator = stateStream.makeAsyncIterator() while let newState = await stateIterator.next() { - await self?.render(newState) + self?.render(newState) } } } @@ -104,7 +104,7 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask = Task { [weak self] in var stateIterator = stateStream.makeAsyncIterator() while let newState = await stateIterator.next() { - await self?.render(newState) + self?.render(newState) } } } From d147071bbdcc450097b7d1d3fbdfe61e8921bc11 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 23 Sep 2024 15:51:34 -0500 Subject: [PATCH 02/13] initial working event reducer commit --- Macros/ModeledViewControllerMacro.swift | 4 ++ .../TemplateInstallerPlugin.swift | 6 +-- Sources/Events/EventReceiving.swift | 53 +++++++++---------- .../EventReducing/AggregateEventReducer.swift | 32 +++++++++++ .../Events/EventReducing/EventReducer.swift | 38 +++++++++++++ .../Reducers/AggregateEventReducer.swift | 26 --------- Sources/Events/Reducers/EventReducer.swift | 22 -------- Sources/MVVM/ModeledViewController.swift | 6 ++- Sources/State/Empty.swift | 6 +-- Sources/State/Stateful.swift | 2 + 10 files changed, 110 insertions(+), 85 deletions(-) create mode 100644 Sources/Events/EventReducing/AggregateEventReducer.swift create mode 100644 Sources/Events/EventReducing/EventReducer.swift delete mode 100644 Sources/Events/Reducers/AggregateEventReducer.swift delete mode 100644 Sources/Events/Reducers/EventReducer.swift diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index 28fa3e5..d99f716 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -195,6 +195,10 @@ extension ModeledViewControllerMacro: MemberMacro { stateObservingTask?.cancel() stateObservingTask = nil } + + func currentState() async -> State { + return await viewModel.currentState() + } """ ) diff --git a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift index 2423182..d8afd4e 100644 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift @@ -3,8 +3,8 @@ import PackagePlugin @main public struct TemplateInstallerPlugin: BuildToolPlugin { - public init() { } - + public init() {} + public func createBuildCommands(context: PluginContext, target _: Target) throws -> [Command] { let scriptPath = context.package.directory.appending("scripts").appending("install-xctemplates.sh") @@ -13,7 +13,7 @@ public struct TemplateInstallerPlugin: BuildToolPlugin { displayName: "Update Async State Templates", executable: scriptPath, arguments: [], - environment: [:], + environment: [:], outputFilesDirectory: context.package.directory ), ] diff --git a/Sources/Events/EventReceiving.swift b/Sources/Events/EventReceiving.swift index d97c79c..d5f9eb6 100644 --- a/Sources/Events/EventReceiving.swift +++ b/Sources/Events/EventReceiving.swift @@ -9,8 +9,28 @@ import Foundation public protocol EventReceiving: EffectHandling { associatedtype ReceivedEvent: Event + associatedtype State: ObjectState - func effects(of event: ReceivedEvent) -> [HandledEffect] + 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) + 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) + handle(all: effects) + } } public extension EventReceiving { @@ -25,35 +45,10 @@ public extension EventReceiving { Task { [weak self, weak eventStreamer] in guard var iterator = eventStreamer?.eventStream.observe().makeAsyncIterator() else { return } while let event = await iterator.next() { - guard - let mappedEvent = await eventMapping(event), - let effects = self?.effects(of: mappedEvent) + guard + let mappedEvent = await eventMapping(event) else { continue } - self?.handle(all: effects) - } - } - } -} - -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) - } + await self?.didReceive(mappedEvent) } } } diff --git a/Sources/Events/EventReducing/AggregateEventReducer.swift b/Sources/Events/EventReducing/AggregateEventReducer.swift new file mode 100644 index 0000000..2cf7586 --- /dev/null +++ b/Sources/Events/EventReducing/AggregateEventReducer.swift @@ -0,0 +1,32 @@ +// +// AggregateEventReducer.swift +// AsyncState +// +// Created by Peter Schuette on 9/23/24. +// + +import Foundation + +@available(iOS 16.0.0, *) +public struct AggregateEventReducer: EventReducer { + private var components = [any EventReducer]() + + public init(_ components: (any EventReducer)...) { + self.components = components + } + + public mutating + func add(subreducer: some EventReducer) { + components.append(subreducer) + } + + public func reduce( + event: Incoming, + for state: State, + into effects: inout [Outgoing] + ) { + for component in components { + component.reduce(event, for: state, into: &effects) + } + } +} diff --git a/Sources/Events/EventReducing/EventReducer.swift b/Sources/Events/EventReducing/EventReducer.swift new file mode 100644 index 0000000..39c0ff8 --- /dev/null +++ b/Sources/Events/EventReducing/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/Events/Reducers/AggregateEventReducer.swift b/Sources/Events/Reducers/AggregateEventReducer.swift deleted file mode 100644 index 04d28b2..0000000 --- a/Sources/Events/Reducers/AggregateEventReducer.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// AggregateEventReducer.swift -// -// -// Created by Peter Schuette on 9/19/24. -// - -import Foundation - -public class AggregateEventReducer { - public private(set) var reducers = [any EventReducer]() - - public init(reducers: [any EventReducer] = [any EventReducer]()) { - self.reducers = reducers - } - - public func reduce( - _ event: any Event, - for state: some ObjectState, - into effects: inout [Outgoing] - ) { - for reducer in reducers { - reducer.reduce(event, for: state, into: &effects) - } - } -} diff --git a/Sources/Events/Reducers/EventReducer.swift b/Sources/Events/Reducers/EventReducer.swift deleted file mode 100644 index 298c759..0000000 --- a/Sources/Events/Reducers/EventReducer.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// EventReducer.swift -// -// -// Created by Peter Schuette on 9/19/24. -// - -import Foundation - -public protocol EventReducer { - func reduce( - _ event: Incoming, - for state: State, - into effects: inout [Outgoing] - ) -} - -public extension EventReducer { - func reduce(_ event: Incoming, into effects: inout [Outgoing]) { - reduce(event, for: Empty.state, into: &effects) - } -} diff --git a/Sources/MVVM/ModeledViewController.swift b/Sources/MVVM/ModeledViewController.swift index 3aacf95..025ce0a 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( diff --git a/Sources/State/Empty.swift b/Sources/State/Empty.swift index 24c7d98..38080cf 100644 --- a/Sources/State/Empty.swift +++ b/Sources/State/Empty.swift @@ -8,7 +8,7 @@ import Foundation public struct Empty: ObjectState { - public init() {} - - static var state = Empty() + public init() {} + + static let state = Empty() } 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 } From 95be6ce3afa97a97a916a775bac7aff463cd1dd1 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 23 Sep 2024 16:53:01 -0500 Subject: [PATCH 03/13] docc to macro --- Macros/ModeledViewControllerMacro.swift | 3 +- .../{EventReducing => }/EventReducer.swift | 0 .../EventReducing/AggregateEventReducer.swift | 32 ------------------- .../ModeledViewControllerMacroTests.swift | 10 ++++++ 4 files changed, 12 insertions(+), 33 deletions(-) rename Sources/Events/{EventReducing => }/EventReducer.swift (100%) delete mode 100644 Sources/Events/EventReducing/AggregateEventReducer.swift diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index d99f716..3c2df36 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -196,8 +196,9 @@ extension ModeledViewControllerMacro: MemberMacro { stateObservingTask = nil } + /// Retrieve the current controller state func currentState() async -> State { - return await viewModel.currentState() + await viewModel.currentState() } """ ) diff --git a/Sources/Events/EventReducing/EventReducer.swift b/Sources/Events/EventReducer.swift similarity index 100% rename from Sources/Events/EventReducing/EventReducer.swift rename to Sources/Events/EventReducer.swift diff --git a/Sources/Events/EventReducing/AggregateEventReducer.swift b/Sources/Events/EventReducing/AggregateEventReducer.swift deleted file mode 100644 index 2cf7586..0000000 --- a/Sources/Events/EventReducing/AggregateEventReducer.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// AggregateEventReducer.swift -// AsyncState -// -// Created by Peter Schuette on 9/23/24. -// - -import Foundation - -@available(iOS 16.0.0, *) -public struct AggregateEventReducer: EventReducer { - private var components = [any EventReducer]() - - public init(_ components: (any EventReducer)...) { - self.components = components - } - - public mutating - func add(subreducer: some EventReducer) { - components.append(subreducer) - } - - public func reduce( - event: Incoming, - for state: State, - into effects: inout [Outgoing] - ) { - for component in components { - component.reduce(event, for: state, into: &effects) - } - } -} diff --git a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift index da51189..598646b 100644 --- a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift +++ b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift @@ -60,6 +60,11 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask?.cancel() stateObservingTask = nil } + + /// Retrieve the current controller state + func currentState() async -> State { + await viewModel.currentState() + } } extension SomeViewController: ModeledViewController { @@ -115,6 +120,11 @@ final class ModeledViewControllerMacroTests: XCTestCase { stateObservingTask?.cancel() stateObservingTask = nil } + + /// Retrieve the current controller state + func currentState() async -> State { + await viewModel.currentState() + } } extension SomeViewController: ModeledViewController { From 02da0d9b17ec962740d4bccbb28e0038f0f9c21c Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 10:46:01 -0500 Subject: [PATCH 04/13] reducer architecture commit (working in recipes) --- Macros/ModeledViewControllerMacro.swift | 11 ++- Package.swift | 5 +- .../TemplateInstallerPlugin.swift | 25 ++---- Sources/Concurrency/AsyncBroadcast.swift | 19 +++-- Sources/Effects/EffectHandling.swift | 40 +++++----- Sources/Events/EventReceiving.swift | 26 +++--- Sources/MVVM/ModeledViewController.swift | 9 --- Sources/State/StateRendering.swift | 8 ++ .../_TemplatesPlugin/TemplatesPlugin.swift | 9 --- .../ModeledViewControllerMacroTests.swift | 22 +++-- .../Effects/EffectHandlingTests.swift | 80 +++++++++++++++++++ .../Effects/EffectMappingTests.swift | 55 +++++++++++++ .../Effects/_MockEffectHandler.swift | 36 +++++++++ 13 files changed, 254 insertions(+), 91 deletions(-) delete mode 100644 Sources/_TemplatesPlugin/TemplatesPlugin.swift create mode 100644 Tests/AsyncStateTests/Effects/EffectHandlingTests.swift create mode 100644 Tests/AsyncStateTests/Effects/EffectMappingTests.swift create mode 100644 Tests/AsyncStateTests/Effects/_MockEffectHandler.swift diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index 3c2df36..5b93b7e 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -176,15 +176,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) } } } diff --git a/Package.swift b/Package.swift index d9539fc..d548042 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,9 @@ let package = Package( .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-docc-plugin", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2")), +// .package(url: "https://github.com/apple/swift-async-algorithms", .upToNextMajor(from: "1.0.0")), ], targets: [ .macro( @@ -44,6 +45,7 @@ let package = Package( "AsyncStateMacros", .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), +// .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Sources" ), @@ -51,6 +53,7 @@ let package = Package( name: "AsyncStateTests", dependencies: [ "AsyncState", +// .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Tests/AsyncStateTests" ), diff --git a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift index d8afd4e..e0722c2 100644 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift @@ -1,21 +1,12 @@ import Foundation import PackagePlugin -@main -public struct TemplateInstallerPlugin: BuildToolPlugin { - public init() {} - - 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, - arguments: [], - environment: [:], - outputFilesDirectory: context.package.directory - ), - ] - } +/// Executes a command to install the lastest AsyncState templates +/// Requires an xcode restart to take effect +@main public struct TemplateInstallerPlugin: CommandPlugin { + public init() { } + public func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { + let scriptPath = context.package.directory.appending("scripts").appending("install-xctemplates.sh") + // TODO: run script + } } diff --git a/Sources/Concurrency/AsyncBroadcast.swift b/Sources/Concurrency/AsyncBroadcast.swift index 47165e2..cdf744e 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,9 @@ public final class OpenAsyncBroadcast: AsyncBroadcast { public init() {} - public func erased() -> any AsyncBroadcast { - self - } - public func send(_ element: Element) { lock.lock() + guard !didFinish else { 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 d5f9eb6..fcb0b9c 100644 --- a/Sources/Events/EventReceiving.swift +++ b/Sources/Events/EventReceiving.swift @@ -20,7 +20,7 @@ public extension EventReceiving where State == Empty { func didReceive(_ event: ReceivedEvent) async { var effects = [HandledEffect]() eventReducer.reduce(event, for: Empty.state, into: &effects) - handle(all: effects) + await handle(all: effects) } } @@ -29,7 +29,7 @@ public extension EventReceiving where Self: Stateful { var effects = [HandledEffect]() let currentState = await currentState() eventReducer.reduce(event, for: currentState, into: &effects) - handle(all: effects) + await handle(all: effects) } } @@ -38,18 +38,26 @@ public extension EventReceiving { /// - Parameters: /// - eventStreamer: Object streaming ``Event``s /// - effectMapping: ``Effect`` these ``Event``s have + @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() { - guard - let mappedEvent = await eventMapping(event) - else { continue } - await self?.didReceive(mappedEvent) - } + while let event = await iterator.next(), !Task.isCancelled, let self { + guard + let mappedEvent = await eventMapping(event) + else { continue } + await self.didReceive(mappedEvent) + } } } + + @discardableResult + func receiveEvents( + from eventStreamer: some EventStreaming + ) -> Task where StreamedEvent == ReceivedEvent { + receiveEvents(from: eventStreamer, as: { $0 }) + } } diff --git a/Sources/MVVM/ModeledViewController.swift b/Sources/MVVM/ModeledViewController.swift index 025ce0a..521b3d3 100644 --- a/Sources/MVVM/ModeledViewController.swift +++ b/Sources/MVVM/ModeledViewController.swift @@ -55,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/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/_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 598646b..7935904 100644 --- a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift +++ b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift @@ -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() { - self?.render(newState) + while let newState = await stateIterator.next(), let self { + self.render(newState) } } } @@ -101,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() { - self?.render(newState) + while let newState = await stateIterator.next(), let self { + self.render(newState) } } } diff --git a/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift new file mode 100644 index 0000000..dfbbf2e --- /dev/null +++ b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift @@ -0,0 +1,80 @@ +// +// 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..69b2932 --- /dev/null +++ b/Tests/AsyncStateTests/Effects/EffectMappingTests.swift @@ -0,0 +1,55 @@ +// +// 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..3614a7f --- /dev/null +++ b/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift @@ -0,0 +1,36 @@ +// +// 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 + } +} + From 27d19534326de8f78770d7875e4c386d72d640bf Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 10:48:11 -0500 Subject: [PATCH 05/13] header comment --- Macros/ModeledViewControllerMacro.swift | 59 ++++++++++++------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index 5b93b7e..422a190 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -18,51 +18,50 @@ import SwiftSyntaxMacros /// into /// ```swift /// final class SomeViewController: UIViewController { -/// typealias State = VCState +/// private let someExistingInt: Int /// -/// typealias ViewModel = VCModel +/// typealias State = SomeState /// -/// let viewModel: ViewModel +/// typealias ViewModel = SomeViewModel /// -/// 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 { From 03e9677c3f37522204f40cb33d499762f41f466f Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 10:50:48 -0500 Subject: [PATCH 06/13] header comment fixes --- Macros/ModeledViewControllerMacro.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Macros/ModeledViewControllerMacro.swift b/Macros/ModeledViewControllerMacro.swift index 422a190..23bce49 100644 --- a/Macros/ModeledViewControllerMacro.swift +++ b/Macros/ModeledViewControllerMacro.swift @@ -18,11 +18,10 @@ import SwiftSyntaxMacros /// into /// ```swift /// final class SomeViewController: UIViewController { -/// private let someExistingInt: Int /// -/// typealias State = SomeState +/// typealias State = VCState /// -/// typealias ViewModel = SomeViewModel +/// typealias ViewModel = VCModel /// /// private var stateObservingTask: Task? /// From 6545c268a2ab41ded2c2b15516874953ae91f843 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 10:56:34 -0500 Subject: [PATCH 07/13] integration tests --- .../ModeledViewControllerMacroTests.swift | 4 +++- .../ModeledViewControllerImplementationTests.swift | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift b/Tests/AsyncStateMacrosTests/ModeledViewControllerMacroTests.swift index 7935904..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 @@ -152,3 +152,5 @@ private final class SomeViewModel: ViewModelProtocol { state } } + +#endif diff --git a/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift b/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift index 3353bbd..b0415f4 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 From f760bc72cf834738ad7c73107e824a46649b86fe Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 11:10:18 -0500 Subject: [PATCH 08/13] Silence warning in TODO component --- Plugins/TemplateInstaller/TemplateInstallerPlugin.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift index e0722c2..ea90772 100644 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ b/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift @@ -7,6 +7,7 @@ import PackagePlugin public init() { } public func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { let scriptPath = context.package.directory.appending("scripts").appending("install-xctemplates.sh") + print(scriptPath.string) // TODO: run script } } From 0a040a5d7fd3dbfd45f4fb7387bbe77e828cdf77 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 11:27:14 -0500 Subject: [PATCH 09/13] event reducer tests + lint --- Macros/AsyncStateMacros.swift | 2 +- Package.swift | 16 ++-- Sources/Events/EventReceiving.swift | 2 +- .../Effects/EffectHandlingTests.swift | 23 +++-- .../Effects/EffectMappingTests.swift | 17 ++-- .../Effects/_MockEffectHandler.swift | 7 +- .../Events/EventReducerTests.swift | 89 +++++++++++++++++++ ...ledViewControllerImplementationTests.swift | 4 +- 8 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 Tests/AsyncStateTests/Events/EventReducerTests.swift 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/Package.swift b/Package.swift index d548042..e27926d 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"]), + .library(name: "AsyncState", targets: ["AsyncState"]) ], dependencies: [ .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")), + .package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2")) // .package(url: "https://github.com/apple/swift-async-algorithms", .upToNextMajor(from: "1.0.0")), ], targets: [ @@ -29,7 +29,7 @@ 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" ), @@ -44,7 +44,7 @@ let package = Package( dependencies: [ "AsyncStateMacros", .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax") // .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Sources" @@ -52,7 +52,7 @@ let package = Package( .testTarget( name: "AsyncStateTests", dependencies: [ - "AsyncState", + "AsyncState" // .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Tests/AsyncStateTests" @@ -62,9 +62,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/Sources/Events/EventReceiving.swift b/Sources/Events/EventReceiving.swift index fcb0b9c..917af02 100644 --- a/Sources/Events/EventReceiving.swift +++ b/Sources/Events/EventReceiving.swift @@ -53,7 +53,7 @@ public extension EventReceiving { } } } - + @discardableResult func receiveEvents( from eventStreamer: some EventStreaming diff --git a/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift index dfbbf2e..fe50c3c 100644 --- a/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift +++ b/Tests/AsyncStateTests/Effects/EffectHandlingTests.swift @@ -14,43 +14,42 @@ final class EffectHandlingTests: XCTestCase { 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) @@ -65,7 +64,7 @@ final class EffectHandlingTests: XCTestCase { ) ) } - + @MainActor func test_handleAllIfNeeded_mapsEffects_andHandlesMappedEffects() async { await effectHandler.handleIfNeeded([ @@ -73,8 +72,8 @@ final class EffectHandlingTests: XCTestCase { 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 index 69b2932..fecf0a7 100644 --- a/Tests/AsyncStateTests/Effects/EffectMappingTests.swift +++ b/Tests/AsyncStateTests/Effects/EffectMappingTests.swift @@ -14,40 +14,39 @@ final class EffectMappingTests: XCTestCase { 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), + 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 index 3614a7f..59b1d75 100644 --- a/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift +++ b/Tests/AsyncStateTests/Effects/_MockEffectHandler.swift @@ -19,18 +19,17 @@ final class MockEffectHandler: EffectHandling, EffectMapping { 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 b0415f4..ce14434 100644 --- a/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift +++ b/Tests/AsyncStateTests/MacroIntegrationTests/SimpleModeledViewController/ModeledViewControllerImplementationTests.swift @@ -34,11 +34,11 @@ 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 From ebb2b0395acffe594a49096e9a0fd38cc7a674b9 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 11:33:58 -0500 Subject: [PATCH 10/13] fix lock issue --- Sources/Concurrency/AsyncBroadcast.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Concurrency/AsyncBroadcast.swift b/Sources/Concurrency/AsyncBroadcast.swift index cdf744e..bb0369d 100644 --- a/Sources/Concurrency/AsyncBroadcast.swift +++ b/Sources/Concurrency/AsyncBroadcast.swift @@ -35,7 +35,10 @@ public final class OpenAsyncBroadcast: AsyncDispatcher, Async public func send(_ element: Element) { lock.lock() - guard !didFinish else { return } + guard !didFinish else { + lock.unlock() + return + } let continuations = continuations lock.unlock() From ca125df1feb2decb5619106a1d35c2d5065a7eb7 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 11:44:16 -0500 Subject: [PATCH 11/13] Remove unused package --- Package.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Package.swift b/Package.swift index e27926d..d0539de 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,6 @@ let package = Package( dependencies: [ .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")) -// .package(url: "https://github.com/apple/swift-async-algorithms", .upToNextMajor(from: "1.0.0")), ], targets: [ .macro( @@ -45,7 +44,6 @@ let package = Package( "AsyncStateMacros", .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax") -// .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Sources" ), @@ -53,7 +51,6 @@ let package = Package( name: "AsyncStateTests", dependencies: [ "AsyncState" -// .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ], path: "Tests/AsyncStateTests" ), From 406dd750de0f2233311118108934576f4cd9fa17 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 14:06:21 -0500 Subject: [PATCH 12/13] created template installer plugin --- Package.swift | 8 +++- .../InstallTemplatesPlugin.swift | 40 +++++++++++++++++++ .../TemplateInstallerPlugin.swift | 13 ------ 3 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 Plugins/TemplateInstaller/InstallTemplatesPlugin.swift delete mode 100644 Plugins/TemplateInstaller/TemplateInstallerPlugin.swift diff --git a/Package.swift b/Package.swift index d0539de..9bc8ea3 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( .macCatalyst(.v13) ], products: [ - .plugin(name: "AsyncStateTemplateInstaller", targets: ["TemplateInstallerPlugin"]), + .plugin(name: "AsyncStateTemplates", targets: ["TemplateInstallerPlugin"]), .library(name: "AsyncState", targets: ["AsyncState"]) ], dependencies: [ @@ -34,7 +34,11 @@ let package = Package( ), .plugin( name: "TemplateInstallerPlugin", - capability: .buildTool(), + capability: .command( + intent: .custom(verb: "async-state-templates", description: "Update XCTemplates for async state"), + permissions: [] + ) + , dependencies: [], path: "Plugins/TemplateInstaller" ), 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 ea90772..0000000 --- a/Plugins/TemplateInstaller/TemplateInstallerPlugin.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import PackagePlugin - -/// Executes a command to install the lastest AsyncState templates -/// Requires an xcode restart to take effect -@main public struct TemplateInstallerPlugin: CommandPlugin { - public init() { } - public func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { - let scriptPath = context.package.directory.appending("scripts").appending("install-xctemplates.sh") - print(scriptPath.string) - // TODO: run script - } -} From a9db3bdc2ded51aa5cabc8130f63466627669daa Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 7 Oct 2024 14:41:34 -0500 Subject: [PATCH 13/13] update readme --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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.