diff --git a/Package.swift b/Package.swift index 8c11573..3f3b5e8 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,12 @@ import PackageDescription let package = Package( name: "SwiftState", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6) + ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -25,6 +31,6 @@ let package = Package( .testTarget( name: "SwiftStateTests", dependencies: ["SwiftState"], - path:"Sources"), + path:"Tests/SwiftStateTests"), ] ) diff --git a/Sources/AsyncStateMachine.swift b/Sources/AsyncStateMachine.swift new file mode 100644 index 0000000..330a056 --- /dev/null +++ b/Sources/AsyncStateMachine.swift @@ -0,0 +1,303 @@ +// +// AsyncStateMachine.swift +// SwiftState +// +// Async/await-friendly wrapper around `StateMachine`. +// + +/// +/// An async/await-friendly wrapper around `StateMachine`. +/// +/// `AsyncStateMachine` mirrors the `StateMachine` API but lets transition handlers be +/// `async`, and exposes `async` `tryEvent()` / `tryState()` that only return **after** all +/// matching handlers have finished. This is useful when a transition needs to `await` work +/// (navigation, networking, …) before the caller continues. +/// +/// It is a thin wrapper: all routing, conditions, ordering and error matching are delegated +/// to an internal `StateMachine`. Async handlers are registered on the wrapped machine as +/// lightweight synchronous collectors that enqueue the matched `async` work; the wrapper then +/// commits the transition synchronously (state is updated by the wrapped machine, exactly as +/// in `StateMachine`) and `await`s the enqueued handlers in order. +/// +/// - Note: This type is **not** re-entrancy/concurrency safe. Drive it from a single +/// concurrency context (e.g. a `@MainActor` flow coordinator) and avoid overlapping +/// `tryEvent()` / `tryState()` calls. +/// +public final class AsyncStateMachine +{ + /// Closure argument for `Condition` & `AsyncHandler` (shared with `Machine`). + public typealias Context = Machine.Context + + /// Closure for validating a transition (stays synchronous — routing is sync). + public typealias Condition = Machine.Condition + + /// Transition callback invoked, and `await`ed, when state has changed successfully. + public typealias AsyncHandler = (Context) async -> () + + /// Closure-based route for `tryEvent()`. + public typealias RouteMapping = Machine.RouteMapping + + /// Closure-based route for `tryState()`. + public typealias StateRouteMapping = StateMachine.StateRouteMapping + + /// The wrapped synchronous state-machine that owns all routing & state. + private let _machine: StateMachine + + /// Async handlers matched (in `order`) by the most recent sync drive, awaiting execution. + private var _pending: [(handler: AsyncHandler, context: Context)] = [] + + //-------------------------------------------------- + // MARK: - Init + //-------------------------------------------------- + + public init(state: S, initClosure: ((AsyncStateMachine) -> ())? = nil) + { + self._machine = StateMachine(state: state) + initClosure?(self) + } + + public func configure(_ closure: (AsyncStateMachine) -> ()) + { + closure(self) + } + + public var state: S + { + return self._machine.state + } + + //-------------------------------------------------- + // MARK: - hasRoute / canTry + //-------------------------------------------------- + + public func hasRoute(event: E, transition: Transition, userInfo: Any? = nil) -> Bool + { + return self._machine.hasRoute(event: event, transition: transition, userInfo: userInfo) + } + + public func hasRoute(event: E, fromState: S, toState: S, userInfo: Any? = nil) -> Bool + { + return self._machine.hasRoute(event: event, fromState: fromState, toState: toState, userInfo: userInfo) + } + + public func hasRoute(_ transition: Transition, userInfo: Any? = nil) -> Bool + { + return self._machine.hasRoute(transition, userInfo: userInfo) + } + + public func hasRoute(fromState: S, toState: S, userInfo: Any? = nil) -> Bool + { + return self._machine.hasRoute(fromState: fromState, toState: toState, userInfo: userInfo) + } + + /// - Returns: Preferred-`toState`. + public func canTryEvent(_ event: E, userInfo: Any? = nil) -> S? + { + return self._machine.canTryEvent(event, userInfo: userInfo) + } + + public func canTryState(_ toState: S, userInfo: Any? = nil) -> Bool + { + return self._machine.canTryState(toState, userInfo: userInfo) + } + + //-------------------------------------------------- + // MARK: - tryEvent / tryState + //-------------------------------------------------- + + /// Drive an event-based transition, `await`ing all matching handlers (or error handlers). + @discardableResult + public func tryEvent(_ event: E, userInfo: Any? = nil) async -> Bool + { + let success = self._machine.tryEvent(event, userInfo: userInfo) + await self._drainPending() + return success + } + + /// Drive a state-based transition, `await`ing all matching handlers (or error handlers). + @discardableResult + public func tryState(_ toState: S, userInfo: Any? = nil) async -> Bool + { + let success = self._machine.tryState(toState, userInfo: userInfo) + await self._drainPending() + return success + } + + /// Run, in `order`, the handlers the wrapped machine matched during the last sync drive. + private func _drainPending() async + { + let pending = self._pending + self._pending = [] + + for entry in pending { + await entry.handler(entry.context) + } + } + + /// Wrap an `async` handler as a sync collector that enqueues onto `_pending` when matched. + private func _collector(_ handler: @escaping AsyncHandler) -> Machine.Handler + { + return { [weak self] context in + self?._pending.append((handler, context)) + } + } + + //-------------------------------------------------- + // MARK: - Route (event-based) + //-------------------------------------------------- + + @discardableResult + public func addRoutes(event: E, transitions: [Transition], condition: Condition? = nil) -> Disposable + { + return self._machine.addRoutes(event: event, transitions: transitions, condition: condition) + } + + @discardableResult + public func addRoutes(event: Event, transitions: [Transition], condition: Condition? = nil) -> Disposable + { + return self._machine.addRoutes(event: event, transitions: transitions, condition: condition) + } + + @discardableResult + public func addRoutes(event: E, transitions: [Transition], condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable + { + return self.addRoutes(event: .some(event), transitions: transitions, condition: condition, handler: handler) + } + + @discardableResult + public func addRoutes(event: Event, transitions: [Transition], condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable + { + let routeDisposable = self._machine.addRoutes(event: event, transitions: transitions, condition: condition) + let handlerDisposable = self.addHandler(event: event, handler: handler) + + return ActionDisposable { + routeDisposable.dispose() + handlerDisposable.dispose() + } + } + + //-------------------------------------------------- + // MARK: - Route (state-based) + //-------------------------------------------------- + + @discardableResult + public func addRoute(_ transition: Transition, condition: Condition? = nil) -> Disposable + { + return self._machine.addRoute(transition, condition: condition) + } + + @discardableResult + public func addRoute(_ transition: Transition, condition: Condition? = nil, handler: @escaping AsyncHandler) -> Disposable + { + let routeDisposable = self._machine.addRoute(transition, condition: condition) + + // Re-check the condition in the handler: the transition-keyed handler can match via + // `.any` wildcards, so it must be gated by this route's own condition (mirrors `StateMachine`). + let handlerDisposable = self._machine.addHandler(transition) { [weak self] context in + if _canPassCondition(condition, forEvent: nil, fromState: context.fromState, toState: context.toState, userInfo: context.userInfo) { + self?._pending.append((handler, context)) + } + } + + return ActionDisposable { + routeDisposable.dispose() + handlerDisposable.dispose() + } + } + + //-------------------------------------------------- + // MARK: - Handler + //-------------------------------------------------- + + /// Add an `async` handler invoked when `tryEvent()` succeeds for `event`. + @discardableResult + public func addHandler(event: E, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + return self.addHandler(event: .some(event), order: order, handler: handler) + } + + @discardableResult + public func addHandler(event: Event, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + return self._machine.addHandler(event: event, order: order, handler: self._collector(handler)) + } + + /// Add an `async` handler invoked when `tryState()` succeeds for `transition`. + /// - Note: This handler will not be invoked for `tryEvent()`. + @discardableResult + public func addHandler(_ transition: Transition, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + return self._machine.addHandler(transition, order: order, handler: self._collector(handler)) + } + + /// Add an `async` handler invoked when either `tryEvent()` or `tryState()` succeeds for `transition`. + @discardableResult + public func addAnyHandler(_ transition: Transition, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + return self._machine.addAnyHandler(transition, order: order, handler: self._collector(handler)) + } + + /// Add an `async` handler invoked when `tryEvent()` / `tryState()` fails. + @discardableResult + public func addErrorHandler(order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + return self._machine.addErrorHandler(order: order, handler: self._collector(handler)) + } + + //-------------------------------------------------- + // MARK: - RouteMapping + //-------------------------------------------------- + + @discardableResult + public func addRouteMapping(_ routeMapping: @escaping RouteMapping) -> Disposable + { + return self._machine.addRouteMapping(routeMapping) + } + + @discardableResult + public func addRouteMapping(_ routeMapping: @escaping RouteMapping, order: HandlerOrder = _defaultOrder, handler: @escaping AsyncHandler) -> Disposable + { + let routeDisposable = self._machine.addRouteMapping(routeMapping) + + let handlerDisposable = self._machine.addHandler(event: .any, order: order) { [weak self] context in + guard let preferredToState = routeMapping(context.event, context.fromState, context.userInfo), + preferredToState == context.toState else + { + return + } + self?._pending.append((handler, context)) + } + + return ActionDisposable { + routeDisposable.dispose() + handlerDisposable.dispose() + } + } + + @discardableResult + public func addStateRouteMapping(_ routeMapping: @escaping StateRouteMapping) -> Disposable + { + return self._machine.addStateRouteMapping(routeMapping) + } + + @discardableResult + public func addStateRouteMapping(_ routeMapping: @escaping StateRouteMapping, handler: @escaping AsyncHandler) -> Disposable + { + let routeDisposable = self._machine.addStateRouteMapping(routeMapping) + + let handlerDisposable = self._machine.addHandler(.any => .any) { [weak self] context in + guard context.event == nil else { return } + guard let preferredToStates = routeMapping(context.fromState, context.userInfo), + preferredToStates.contains(context.toState) else + { + return + } + self?._pending.append((handler, context)) + } + + return ActionDisposable { + routeDisposable.dispose() + handlerDisposable.dispose() + } + } +} diff --git a/Tests/SwiftStateTests/AsyncStateMachineTests.swift b/Tests/SwiftStateTests/AsyncStateMachineTests.swift new file mode 100644 index 0000000..d6b07b8 --- /dev/null +++ b/Tests/SwiftStateTests/AsyncStateMachineTests.swift @@ -0,0 +1,160 @@ +// +// AsyncStateMachineTests.swift +// SwiftState +// + +import SwiftState +import XCTest + +class AsyncStateMachineTests: _TestCase +{ + /// `tryEvent()` commits the transition and awaits the async handler before returning. + func testTryEvent_awaitsAsyncHandler() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { _ in + // simulate async work that must finish before `tryEvent` returns + try? await Task.sleep(nanoseconds: 1_000_000) + handlerRan = true + }) + + let success = await machine.tryEvent(.event0) + + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state1) + XCTAssertTrue(handlerRan, "tryEvent must await the async handler to completion") + } + + /// A failed `tryEvent()` invokes the (async) error handler and leaves state unchanged. + func testTryEvent_failure_runsErrorHandler() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoutes(event: .event0, transitions: [.state0 => .state1]) + + var errorRan = false + machine.addErrorHandler { _ in errorRan = true } + + // .event1 has no route + let success = await machine.tryEvent(.event1) + + XCTAssertFalse(success) + XCTAssertEqual(machine.state, .state0) + XCTAssertTrue(errorRan) + } + + /// Handlers run in `order`, and `tryState()` drives state-based transitions. + func testTryState_handlerOrdering() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoute(.state0 => .state1) + + var calls: [Int] = [] + + machine.addHandler(.state0 => .state1, order: 200) { _ in + try? await Task.sleep(nanoseconds: 1_000_000) + calls.append(2) + } + machine.addHandler(.state0 => .state1, order: 100) { _ in + calls.append(1) + } + + let success = await machine.tryState(.state1) + + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state1) + XCTAssertEqual(calls, [1, 2], "handlers must run sequentially in `order`") + } + + /// `addAnyHandler` fires for both event- and state-based transitions. + func testAddAnyHandler() async + { + let machine = AsyncStateMachine(state: .state0) + machine.addRoute(.any => .any) + machine.addRoutes(event: .event0, transitions: [.any => .any]) + + var count = 0 + machine.addAnyHandler(.any => .any) { _ in count += 1 } + + await machine.tryState(.state1) + await machine.tryEvent(.event0) + + XCTAssertEqual(count, 2) + } + + /// A condition gates the transition (and therefore the handler). + func testCondition() async + { + let machine = AsyncStateMachine(state: .state0) + + var allow = false + var handlerRan = false + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], condition: { _ in allow }, handler: { _ in + handlerRan = true + }) + + let blocked = await machine.tryEvent(.event0) + XCTAssertFalse(blocked) + XCTAssertEqual(machine.state, .state0) + XCTAssertFalse(handlerRan) + + allow = true + let passed = await machine.tryEvent(.event0) + XCTAssertTrue(passed) + XCTAssertEqual(machine.state, .state1) + XCTAssertTrue(handlerRan) + } + + /// Disposing a route/handler removes it. + func testDisposable() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + let disposable = machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { _ in + handlerRan = true + }) + + disposable.dispose() + + let success = await machine.tryEvent(.event0) + XCTAssertFalse(success) + XCTAssertEqual(machine.state, .state0) + XCTAssertFalse(handlerRan) + } + + /// `userInfo` is forwarded through to the handler's `Context`. + func testUserInfoForwarded() async + { + let machine = AsyncStateMachine(state: .state0) + + var received: String? + machine.addRoutes(event: .event0, transitions: [.state0 => .state1], handler: { context in + received = context.userInfo as? String + }) + + await machine.tryEvent(.event0, userInfo: "hello") + XCTAssertEqual(received, "hello") + } + + /// `addRouteMapping` resolves the destination dynamically and runs the async handler. + func testRouteMapping() async + { + let machine = AsyncStateMachine(state: .state0) + + var handlerRan = false + machine.addRouteMapping({ event, fromState, _ -> MyState? in + guard event == .event0, fromState == .state0 else { return nil } + return .state2 + }, handler: { _ in + handlerRan = true + }) + + let success = await machine.tryEvent(.event0) + XCTAssertTrue(success) + XCTAssertEqual(machine.state, .state2) + XCTAssertTrue(handlerRan) + } +}