diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..839fec7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: Build and test + +on: [push] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: swift-actions/setup-swift@v1 + with: + swift-version: "5.6.1" + - uses: actions/checkout@v3 + - name: Build + run: swift build + - name: Run tests + run: swift test diff --git a/.gitignore b/.gitignore index 330d167..72baa45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore +.DS_Store ## User settings xcuserdata/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme new file mode 100644 index 0000000..a262796 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Elevator.jpeg b/Elevator.jpeg new file mode 100644 index 0000000..e4e7622 Binary files /dev/null and b/Elevator.jpeg differ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..a8619a2 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "ef8e14e7ce1c0c304c644c6ba365d06c468ded6b", + "version" : "0.3.3" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5ea20d2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.6 + +import PackageDescription + +let package = Package( + name: "AsyncStateMachine", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6) + ], + products: [ + .library( + name: "AsyncStateMachine", + targets: ["AsyncStateMachine"] + ), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", from: "0.3.3") + ], + targets: [ + .target( + name: "AsyncStateMachine", + dependencies: [ + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay") + ], + path: "Sources/"), + .testTarget( + name: "AsyncStateMachineTests", + dependencies: ["AsyncStateMachine"], + path: "Tests/"), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..34ec0de --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +# Async State Machine +**Async State Machine** aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way thanks to a DSL. + +```swift +let stateMachine = StateMachine(initial: .state1) { + When(state: .state1) { + Execute(output: .output1) + } transitions: { + On(event: .event1) { Transition(to: .state2) } + On(event: .event2) { Transition(to: .state3) } + On(event: .event3(value:)) { value in Transition(to: .state4(value)) } + } + + When(state: .state2(value:)) { value in + Execute.noOutput + } transitions: { value in + … + } +} +``` + +## Key points: +- Each feature is a [Moore state machine](https://en.wikipedia.org/wiki/Moore_machine): no need for a global store +- State machines are declarative: a DSL offers a natural and concise syntax +- Swift concurrency is at the core: + - A state machine is an `AsyncSequence` + - Each side effect runs inside a `Task` that benefits from cooperative cancellation + - Concurrent transitions can suspend +- State machines are built in complete isolation: tests dont require mocks +- Dependencies are injected per side effect: no global bag of dependencies +- State machines are not UI related: it works with UIKit or SwiftUI + +## A Simple Example +As a picture is worth a thousand words, here’s an example of a state machine that drives the opening of an elevator‘s door: + +![](Elevator.jpeg) + +
+ +### How does it read? + +- **INITIALLY**, the elevator is `open` with 0 person inside +- **WHEN** the state is `open`, **ON** the event `personsHaveEntered`, the new state is `open` with `n + x` persons. +- **WHEN** the state is `open`, **ON** the event `closeButtonWasPressed`, the new state is `closing` if there is less than 10 persons (elevator’s capacity is limited). +- **WHEN** the state is `closing`, the `close` action is executed (the door can close at different speeds). +- **WHEN** the state is `closing`, **ON** the event `doorHasLocked`, the new state is *closed*. + +### What defines this state machine? + +- The elevator can be in 3 exclusive **states**: `open`, `closing` and `closed`. _This is the finite set of possible **states**. The initial state of the elevator is `open` with 0 person inside._ +- The elevator can receive 3 **events**: `personsHaveEntered`, `closeButtonWasPressed` and `doorHasLocked`. _This is the finite set of possible **events**._ +- The elevator can perform 1 **action**: `close` the door when the state is `closing` and the number of persons is less than 10. The speed of the doors is determined by the number of persons inside. _This is the finite set of possible **outputs**._ +- The elevator can go from one state to another when events are received. _This is the finite set of possible **transitions**._ + +The assumption we make is that almost any feature can be described in terms of state machines. And to make it as simple as possible, we use a Domain Specific Language. + +## The state machine DSL + +Here’s the translation of the aforementioned state machine using enums and the **Async State Machine** DSL: + +```swift +enum State: DSLCompatible { + case open(persons: Int) + case closing(persons: Int) + case closed +} + +enum Event: DSLCompatible { + case personsHaveEntered(persons: Int) + case closeButtonWasPressed + case doorHasLocked +} + +enum Output: DSLCompatible { + case close(speed: Int) +} + +let stateMachine = StateMachine(initial: State.open(persons: 0)) { + When(state: State.open(persons:)) { _ in + Execute.noOutput + } transitions: { persons in + On(event: Event.personsHaveEntered(persons:)) { newPersons in + Transition(to: State.open(persons: persons + newPersons)) + } + + On(event: Event.closeButtonWasPressed) { + Guard(predicate: persons < 10) + } transition: { + Transition(to: State.closing(persons: persons)) + } + } + + When(state: State.closing(persons:)) { persons in + Execute(output: Output.close(speed: persons > 5 ? 1 : 2)) + } transitions: { _ in + On(event: Event.doorHasLocked) { + Transition(to: State.closed) + } + } +} +``` + +The only requirement to be able to use enums with the DSL is to have them conform to *DSLCompatible* (which allows to use enums in a declarative manner, without the need for pattern matching). + +## The Runtime + +The DSL aims to describe a formal state machine: no side effects, only pure functions! + +The `StateMachine` declares **output** _values_ to describe the _intent_ of side effects to be performed, but the _implementation_ of those side effects are declared in the `Runtime` where one can map outputs to side effect functions. + +(Amongst other benefits, this decoupling allows for easier testing of your State Machine without depending on the implementation of the side effects.) + + +```swift +func close(speed: Int) async -> Event { + try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 / speed)) + return .doorHasLocked +} + +let runtime = Runtime() + .map(output: Output.close(speed:), to: close(speed:)) +``` + +Side effects are `async` functions that return either a single `Event`, or an `AsyncSequence`. Every time the state machine produces the expected `output`, the corresponding side effect will be executed. + +In addition, the Runtime can register _middleware_ functions executed on any `state` or `event`: + +```swift +let runtime = Runtime() + .map(output: Output.close(speed:), to: close(speed:)) + .register(middleware: { (state: State) in print("State: \(state)") }) + .register(middleware: { (event: Event) in print("Event: \(event)") }) +``` + +The `AsyncStateMachineSequence` can then be instantiated: + +```swift +let sequence = AsyncStateMachineSequence( + stateMachine: stateMachine, + runtime: runtime +) + +for await state in sequence { … } + +await sequence.send(Event.personsHaveEntered(persons: 3)) +``` + +## Swift concurrency at the core + +**Async State Machine** is 100% built with the Swift 5.5 concurrency model in mind. + +### Transitions + +- Transitions defined in the DSL are `async` functions; they will be executed in a non blocking way. +- Transitions cannot If an event previously sent is being processed by a transition, the next call to `send(_:)` will `await`. This prevents concurrent transitions to happen simultaneously (which could otherwise lead to inconsistent states). + +### Side effects + +- Side effects are `async` functions executed in the context of `Tasks`. +- Task priority can be set in the Runtime: `.map(output: Output.close(speed:), to: close(speed:), priority: .high)`. +- Collaborative task cancellation applies: when an AsyncStateMachineSequence is deinit, all the pending side effect tasks will be marked as cancelled. + +### Async sequence + +- `AsyncStateMachineSequence` benefits from all the operators associated to `AsyncSequence` (`map`, `filter`, …). (See also [swift async algorithms](https://github.com/apple/swift-async-algorithms)) +- `AsyncStateMachineSequence` is compliant with a multiple producer / multiple consumer mode in a concurrent mode. Although to output is not shared (meaning each consumer will receive the successive versions of the state), the transitions are guaranteed concurrent-safe. + +## How to inject dependencies? + +Most of the time, side effects will require dependencies to perform their duty. However, **Async State Machine** expects a side effect to be a function that eventually takes a parameter (from the `Output`) and returns an `Event` or an `AsyncSequence`. There is no place for dependencies in their signatures. + +There are several ways to overcome this: + +- Make a business object that captures the dependencies and declares a function that matches the side effect’s signature: + +```swift +class ElevatorUseCase { + let engine: Engine + + init(engine: Engine) { self.engine = engine } + + func close(speed: Int) async -> Event { + try? await Task.sleep(nanoseconds: UInt64(self.engine.delay / speed)) + return .doorHasLocked + } +} + +let useCase = ElevatorUseCase(engine: FastEngine()) +let runtime = Runtime() + .map(output: Output.close(speed:), to: useCase.close(speed:)) +``` + +- Make a factory function that provides a side effect, capturing its dependencies: + +```swift +func makeClose(engine: Engine) -> (Int) async -> Event { + return { (speed: Int) in + try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed)) + return .doorHasLocked + } +} + +let close = makeClose(engine: FastEngine()) +let runtime = Runtime() + .map(output: Output.close(speed:), to: close) +``` + +- Use the provided `inject` function (preferred way verbosity wise): + +```swift +func close(speed: Int, engine: Engine) async -> Event { + try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed)) + return .doorHasLocked +} + +let closeSideEffect = inject(dep: Engine(), in: close(speed:engine:)) +let runtime = Runtime() + .map(output: Output.close(speed:), to: closeSideEffect) +``` + +## Testable in complete isolation + +State machine definitions do not depend on any dependencies, thus they can be tested without using mocks. **Async State Machine** provides a unit test helper making it even easier: + +```swift +XCTStateMachine(stateMachine) + .assertNoOutput(when: .open(persons: 0)) + .assert( + when: .open(persons: 0), + on: .personsHaveEntered(persons: 1), + transitionTo: .open(persons: 1) + ) + .assert( + when: .open(persons: 5), + on: .closeButtonWasPressed, + transitionTo: .closing(persons: 5) + ) + .assertNoTransition(when: .open(persons: 15), on: .closeButtonWasPressed) + .assert(when: .closing(persons: 1), execute: .close(speed: 2)) + .assert( + when: .closing(persons: 1), + on: .doorHasLocked, + transitionTo: .closed + ) + .assertNoOutput(when: .closed) +``` + +## Using Async State Machine with SwiftUI and UIKit + +No matter the UI framework you use, rendering a user interface is about interpreting a state. You can use an `AsyncStateMachineSequence` as a reliable state factory, exposing the state with the provided `ViewState`. + +A simple and naive SwiftUI usage could be: + +```swift +struct ContentView: View { + @ObservedObject viewState: ViewState + + var body: some View { + VStack { + Text(self.viewState.state.description) + Button { + Task { + await self.viewState.send(Event.personsHaveEntered(persons: 1)) + } + } label: { + Text("New person") + } + Button { + Task { + await self.viewState.send(Event.closeButtonWasPressed) + } + } label: { + Text("Close the door") + } + }.task { + await viewState.start() + } + } +} + +… + +let viewState = ViewState(myAsyncStateMachine) +ContentView(viewState: viewState) +``` + +With UIKit, a simple and naive approach would be: + +```swift +let task: Task! +let viewState: ViewState! +let cancellable = AnyCancellable() + +override func viewDidLoad() { + super.viewDidLoad() + self.task = Task { [weak self] in + await self?.viewState.start() + } + + self.cancellable = self.viewState.$state.sink { [weak self] state in + self?.render(state: state) + } +} + +func render(state: State) { + … +} + +func deinit() { + self.task.cancel() +} +``` + +## Extras + +### Conditionally resumable `send()` function + +Allows to send an event while awaiting for a specific state or set of states to resume. + +```swift +await viewState.send( + .closeButtonWasPressed, + resumeWhen: .closed +)` +``` + +### Side effect cancellation + +Make `close(speed:)` side effect execution be cancelled when the state machine produces any new states. It is also possible to cancel on a specific state. + +```swift +Runtime.map( + output: Output.close(speed:), + to: close(speed:), + strategy: .cancelWhenAnyState +) +``` + +### States set + +Allows to factorize the same transition for a set of states. + +```swift +When(states: OneOf { + State.closing(persons:), + State.closed +}) { _ in + Execute.noOutput +} transitions: { + On(event: Event.closeButtonWasPressed) { _ in + Transition(to: State.opening) + } +}` +``` + +### SwiftUI bindings + +Allows to create a SwiftUI binding on the current state, sending an Event when the binding changes. + +```swift +self.viewState.binding(send: .closeButtonWasPressed) +``` + +Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes. + +```swift +self.viewState.binding(keypath: \.persons, send: .closeButtonWasPressed) +``` + +### Connecting two state machines + +This will send the event `OtherEvent.refresh` in the other state machine when the first state machine's state is `State.closed`. + +```swift +let channel = Channel() + +let runtime = Runtime() + ... + .connectAsSender(to: channel, when: State.closed, send: OtherEvent.refresh) + + +let otherRuntime = Runtime() + ... + .connectAsReceiver(to: channel) +``` diff --git a/Sample/Sample.xcodeproj/project.pbxproj b/Sample/Sample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..93110b4 --- /dev/null +++ b/Sample/Sample.xcodeproj/project.pbxproj @@ -0,0 +1,370 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 59211C71286B873E00067F44 /* StateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59211C70286B873E00067F44 /* StateMachine.swift */; }; + 59211C78286B887700067F44 /* AsyncStateMachine in Frameworks */ = {isa = PBXBuildFile; productRef = 59211C77286B887700067F44 /* AsyncStateMachine */; }; + 59872A2D286B86A6004F4157 /* SampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59872A2C286B86A6004F4157 /* SampleApp.swift */; }; + 59872A2F286B86A6004F4157 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59872A2E286B86A6004F4157 /* ContentView.swift */; }; + 59872A31286B86A8004F4157 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59872A30286B86A8004F4157 /* Assets.xcassets */; }; + 59872A34286B86A8004F4157 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59872A33286B86A8004F4157 /* Preview Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 59211C6E286B870900067F44 /* AsyncStateMachine */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AsyncStateMachine; path = ..; sourceTree = ""; }; + 59211C70286B873E00067F44 /* StateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachine.swift; sourceTree = ""; }; + 59872A29286B86A6004F4157 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 59872A2C286B86A6004F4157 /* SampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleApp.swift; sourceTree = ""; }; + 59872A2E286B86A6004F4157 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 59872A30286B86A8004F4157 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 59872A33286B86A8004F4157 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 59872A26286B86A6004F4157 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 59211C78286B887700067F44 /* AsyncStateMachine in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 59211C76286B887700067F44 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 59872A20286B86A6004F4157 = { + isa = PBXGroup; + children = ( + 59211C6E286B870900067F44 /* AsyncStateMachine */, + 59872A2B286B86A6004F4157 /* Sample */, + 59872A2A286B86A6004F4157 /* Products */, + 59211C76286B887700067F44 /* Frameworks */, + ); + sourceTree = ""; + }; + 59872A2A286B86A6004F4157 /* Products */ = { + isa = PBXGroup; + children = ( + 59872A29286B86A6004F4157 /* Sample.app */, + ); + name = Products; + sourceTree = ""; + }; + 59872A2B286B86A6004F4157 /* Sample */ = { + isa = PBXGroup; + children = ( + 59211C70286B873E00067F44 /* StateMachine.swift */, + 59872A2C286B86A6004F4157 /* SampleApp.swift */, + 59872A2E286B86A6004F4157 /* ContentView.swift */, + 59872A30286B86A8004F4157 /* Assets.xcassets */, + 59872A32286B86A8004F4157 /* Preview Content */, + ); + path = Sample; + sourceTree = ""; + }; + 59872A32286B86A8004F4157 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 59872A33286B86A8004F4157 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 59872A28286B86A6004F4157 /* Sample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 59872A37286B86A8004F4157 /* Build configuration list for PBXNativeTarget "Sample" */; + buildPhases = ( + 59872A25286B86A6004F4157 /* Sources */, + 59872A26286B86A6004F4157 /* Frameworks */, + 59872A27286B86A6004F4157 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Sample; + packageProductDependencies = ( + 59211C77286B887700067F44 /* AsyncStateMachine */, + ); + productName = Sample; + productReference = 59872A29286B86A6004F4157 /* Sample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 59872A21286B86A6004F4157 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1340; + LastUpgradeCheck = 1340; + TargetAttributes = { + 59872A28286B86A6004F4157 = { + CreatedOnToolsVersion = 13.4.1; + }; + }; + }; + buildConfigurationList = 59872A24286B86A6004F4157 /* Build configuration list for PBXProject "Sample" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 59872A20286B86A6004F4157; + productRefGroup = 59872A2A286B86A6004F4157 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 59872A28286B86A6004F4157 /* Sample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 59872A27286B86A6004F4157 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 59872A34286B86A8004F4157 /* Preview Assets.xcassets in Resources */, + 59872A31286B86A8004F4157 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 59872A25286B86A6004F4157 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 59872A2F286B86A6004F4157 /* ContentView.swift in Sources */, + 59872A2D286B86A6004F4157 /* SampleApp.swift in Sources */, + 59211C71286B873E00067F44 /* StateMachine.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 59872A35286B86A8004F4157 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 59872A36286B86A8004F4157 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 59872A38286B86A8004F4157 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\""; + DEVELOPMENT_TEAM = 3V5265LQM9; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.warpfactor.Sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 59872A39286B86A8004F4157 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\""; + DEVELOPMENT_TEAM = 3V5265LQM9; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.warpfactor.Sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 59872A24286B86A6004F4157 /* Build configuration list for PBXProject "Sample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 59872A35286B86A8004F4157 /* Debug */, + 59872A36286B86A8004F4157 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 59872A37286B86A8004F4157 /* Build configuration list for PBXNativeTarget "Sample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 59872A38286B86A8004F4157 /* Debug */, + 59872A39286B86A8004F4157 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 59211C77286B887700067F44 /* AsyncStateMachine */ = { + isa = XCSwiftPackageProductDependency; + productName = AsyncStateMachine; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 59872A21286B86A6004F4157 /* Project object */; +} diff --git a/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json b/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5a3257a --- /dev/null +++ b/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,93 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sample/Sample/Assets.xcassets/Contents.json b/Sample/Sample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sample/Sample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sample/Sample/ContentView.swift b/Sample/Sample/ContentView.swift new file mode 100644 index 0000000..2e627a5 --- /dev/null +++ b/Sample/Sample/ContentView.swift @@ -0,0 +1,35 @@ +// +// ContentView.swift +// Sample +// +// Created by Thibault WITTEMBERG on 28/06/2022. +// + +import AsyncStateMachine +import SwiftUI + +struct ContentView: View { + @ObservedObject var viewState: ViewState + + var body: some View { + Text(String(describing: self.viewState.state)) + Button { + Task { + await self.viewState.send(Event.loadingIsRequested) + } + } label: { + Text("Load") + } + .task { + await viewState.start() + } + } +} + +struct ContentView_Previews: PreviewProvider { + @MainActor static let viewState = ViewState(asyncSequence) + + static var previews: some View { + ContentView(viewState: viewState) + } +} diff --git a/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json b/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sample/Sample/SampleApp.swift b/Sample/Sample/SampleApp.swift new file mode 100644 index 0000000..dc49689 --- /dev/null +++ b/Sample/Sample/SampleApp.swift @@ -0,0 +1,20 @@ +// +// SampleApp.swift +// Sample +// +// Created by Thibault WITTEMBERG on 28/06/2022. +// + +import AsyncStateMachine +import SwiftUI + +@main +struct SampleApp: App { + @MainActor let viewState = ViewState(asyncSequence) + + var body: some Scene { + WindowGroup { + ContentView(viewState: viewState) + } + } +} diff --git a/Sample/Sample/StateMachine.swift b/Sample/Sample/StateMachine.swift new file mode 100644 index 0000000..61cb6f6 --- /dev/null +++ b/Sample/Sample/StateMachine.swift @@ -0,0 +1,107 @@ +// +// State.swift +// Sample +// +// Created by Thibault WITTEMBERG on 28/06/2022. +// + +import AsyncStateMachine +import Foundation + +enum State: DSLCompatible { + case idle + case loading + case loaded +} + +enum Event: DSLCompatible { + case loadingIsRequested + case loadingHasSucceeded +} + +enum Output: DSLCompatible { + case load +} + +let stateMachine = StateMachine(initial: .idle) { + When(state: .idle) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .loadingIsRequested) { _ in + Transition(to: .loading) + } + } + + When(state: .loading) { _ in + Execute(output: .load) + } transitions: { _ in + On(event: .loadingHasSucceeded) { _ in + Transition(to: .loaded) + } + + On(event: .loadingIsRequested) { _ in + Transition(to: .loading) + } + } + + When(state: .loaded) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .loadingIsRequested) { _ in + Transition(to: .loading) + } + + On(event: .loadingHasSucceeded) { _ in + Transition(to: .loaded) + } + } +} + +let counter = Counter() +var dateFormatterGet: DateFormatter = { + let fomatter = DateFormatter() + fomatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return fomatter +}() + +let runtime = Runtime() + .map(output: .load, to: { + let value = await counter.value + let begin = Date() + let formatBegin = begin.getFormattedDate(format: "yyyy-MM-dd HH:mm:ss") + + await counter.increase() + print("sideEffect \(value): begin loading at \(formatBegin)") + + try? await Task.sleep(nanoseconds: 5_000_000_000) + + let end = Date() + let formatEnd = end.getFormattedDate(format: "yyyy-MM-dd HH:mm:ss") + print("sideEffect \(value): end loading at \(formatEnd)") + return .loadingHasSucceeded + }) + .register(middleware: { (event: Event) in + print("middleware: received event \(event) on main: \(Thread.isMainThread)") + }) + .register(middleware: { (state: State) in + print("middleware: received state \(state) on main: \(Thread.isMainThread)") + }) + +actor Counter { + var value = 0 + + func increase() { + self.value += 1 + } +} + + +let asyncSequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + +extension Date { + func getFormattedDate(format: String) -> String { + let dateformat = DateFormatter() + dateformat.dateFormat = format + return dateformat.string(from: self) + } +} diff --git a/Sources/AsyncStateMachineSequence.swift b/Sources/AsyncStateMachineSequence.swift new file mode 100644 index 0000000..6461374 --- /dev/null +++ b/Sources/AsyncStateMachineSequence.swift @@ -0,0 +1,77 @@ +public final class AsyncStateMachineSequence: AsyncSequence, Sendable +where S: DSLCompatible & Sendable, E: DSLCompatible & Sendable, O: DSLCompatible { + public typealias Element = S + public typealias AsyncIterator = + AsyncOnEachSequence>, S>>>.Iterator + + let initialState: S + let eventChannel: AsyncBufferedChannel + let currentState: ManagedCriticalState + let engine: Engine + let deinitBlock: @Sendable () -> Void + + public convenience init( + stateMachine: StateMachine, + runtime: Runtime + ) { + self.init( + stateMachine: stateMachine, + runtime: runtime, + onDeinit: nil + ) + } + + init( + stateMachine: StateMachine, + runtime: Runtime, + onDeinit: (() -> Void)? = nil + ) { + self.initialState = stateMachine.initial + self.eventChannel = AsyncBufferedChannel() + self.currentState = ManagedCriticalState(nil) + self.deinitBlock = { + runtime.channelReceivers.forEach { channelReceiver in + channelReceiver.update(receiver: nil) + } + onDeinit?() + } + + self.engine = Engine( + resolveOutput: stateMachine.output(for:), + computeNextState: stateMachine.reduce(when:on:), + resolveSideEffect: runtime.sideEffects(for:), + eventMiddlewares: runtime.eventMiddlewares, + stateMiddlewares: runtime.stateMiddlewares + ) + + // As channals are retained as long as there is a sender using it, + // the receiver will also be retained. + // That is why it is necesssary to have a weak reference on the self here. + // Doing so, self will be deallocated event if a channel was using it as a receiver. + // Channels are resilient to nil receiver functions. + runtime.channelReceivers.forEach { channelReceiver in + channelReceiver.update { [weak self] event in + self?.send(event) + } + } + } + + deinit { + self.eventChannel.finish() + self.deinitBlock() + } + + @Sendable public func send(_ event: E) { + self.eventChannel.send(event) + } + + public func makeAsyncIterator() -> AsyncIterator { + self + .eventChannel + .onEach { [weak self] event in await self?.engine.process(event:event) } + .compactScan(self.initialState, self.engine.computeNextState) + .serial() + .onEach { [weak self] state in await self?.engine.process(state: state, sendBackEvent: self?.send(_:)) } + .makeAsyncIterator() + } +} diff --git a/Sources/Engine.swift b/Sources/Engine.swift new file mode 100644 index 0000000..3e94944 --- /dev/null +++ b/Sources/Engine.swift @@ -0,0 +1,197 @@ +// +// Engine.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +struct TaskInProgress { + let cancellationPredicate: (S) -> Bool + let task: Task +} + +actor Engine: Sendable +where S: DSLCompatible, E: DSLCompatible & Sendable, O: DSLCompatible { + let resolveOutput: @Sendable (S) -> O? + let computeNextState: @Sendable (S, E) async -> S? + let resolveSideEffect: @Sendable (O) -> SideEffect? + let onDeinit: (() -> Void)? + + var eventMiddlewares: OrderedStorage> + var stateMiddlewares: OrderedStorage> + var tasksInProgress: OrderedStorage> + + init( + resolveOutput: @Sendable @escaping (S) -> O?, + computeNextState: @Sendable @escaping (S, E) async -> S?, + resolveSideEffect: @Sendable @escaping (O) -> SideEffect?, + eventMiddlewares: [Middleware], + stateMiddlewares: [Middleware], + onDeinit: (() -> Void)? = nil + ) { + self.resolveOutput = resolveOutput + self.computeNextState = computeNextState + self.resolveSideEffect = resolveSideEffect + self.stateMiddlewares = OrderedStorage(contentOf: stateMiddlewares) + self.eventMiddlewares = OrderedStorage(contentOf: eventMiddlewares) + self.tasksInProgress = OrderedStorage() + self.onDeinit = onDeinit + } + + @discardableResult + func register( + taskInProgress task: Task, + cancelOn predicate: @Sendable @escaping (S) -> Bool + ) -> Task { + // registering task for eventual cancellation + let taskIndex = self.tasksInProgress.append( + TaskInProgress( + cancellationPredicate: predicate, + task: task + ) + ) + + // cleaning when task is done + return Task { [weak self] in + await task.value + await self?.removeTaskInProgress(index: taskIndex) + } + } + + func removeTaskInProgress(index: Int) { + self.tasksInProgress.remove(index: index) + } + + func removeTasksInProgress() { + self.tasksInProgress.removeAll() + } + + func cancelTasksInProgress( + for state: S + ) { + let tasksInProgress = self + .tasksInProgress + .indexedValues + .filter { _, taskInProgress in taskInProgress.cancellationPredicate(state) } + + for (index, taskInProgress) in tasksInProgress { + taskInProgress.task.cancel() + self.removeTaskInProgress(index: index) + } + } + + func cancelTasksInProgress() { + self + .tasksInProgress + .values + .forEach { taskInProgress in taskInProgress.task.cancel() } + + self.removeTasksInProgress() + } + + @discardableResult + func process( + event: E + ) async -> [Task] { + // executes event middlewares for this event + self.process( + middlewares: self.eventMiddlewares.indexedValues, + using: event, + removeMiddleware: { [weak self] index in await self?.removeEventMiddleware(index: index) } + ) + } + + @discardableResult + func process( + state: S, + sendBackEvent: (@Sendable (E) -> Void)? + ) async -> [Task] { + // cancels tasks that are known to be cancellable for this state + self.cancelTasksInProgress(for: state) + + // executes state middlewares for this state + let removeTasksInProgressTasks = self.process( + middlewares: self.stateMiddlewares.indexedValues, + using: state, + removeMiddleware: { [weak self] index in await self?.removeStateMiddleware(index: index) } + ) + + // executes side effect for this state if any + await self.executeSideEffect(for: state, sendBackEvent: sendBackEvent) + + return removeTasksInProgressTasks + } + + @discardableResult + func process( + middlewares: [(Int, Middleware)], + using value: T, + removeMiddleware: @escaping (Int) async -> Void + ) -> [Task] { + var removeTaskInProgressTasks = [Task]() + + for (index, middleware) in middlewares { + let task: Task = Task(priority: middleware.priority) { + let shouldRemove = await middleware.execute(value) + if shouldRemove { + await removeMiddleware(index) + } + } + + // middlewares are not cancelled on any specific state + let removeTaskInProgressTask = self.register( + taskInProgress: task, + cancelOn: { _ in false } + ) + removeTaskInProgressTasks.append(removeTaskInProgressTask) + } + + return removeTaskInProgressTasks + } + + @discardableResult + func executeSideEffect( + for state: S, + sendBackEvent: (@Sendable (E) -> Void)? + ) async -> Task? { + guard + let output = self.resolveOutput(state), + let sideEffect = self.resolveSideEffect(output), + let events = sideEffect.execute(output) else { return nil } + + let task: Task = Task(priority: sideEffect.priority) { + do { + for try await event in events { + sendBackEvent?(event) + } + } catch {} + // side effect cannot fail (should not be necessary to have a `for try await ...` loop but + // AnyAsyncSequence masks the non throwable nature of side effects + // could be fixed by saying the a side effect is (O) -> any AsyncSequence + } + + return self.register( + taskInProgress: task, + cancelOn: sideEffect.strategy.predicate + ) + } + + func register(onTheFly execute: @Sendable @escaping (S) async -> Bool) { + self.stateMiddlewares.append( + Middleware(execute: execute, priority: nil) + ) + } + + func removeEventMiddleware(index: Int) { + self.eventMiddlewares.remove(index: index) + } + + func removeStateMiddleware(index: Int) { + self.stateMiddlewares.remove(index: index) + } + + deinit { + self.cancelTasksInProgress() + self.onDeinit?() + } +} diff --git a/Sources/Runtime/Channel.swift b/Sources/Runtime/Channel.swift new file mode 100644 index 0000000..65986ac --- /dev/null +++ b/Sources/Runtime/Channel.swift @@ -0,0 +1,22 @@ +// +// Channel.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public final class Channel: Sendable +where E: DSLCompatible { + typealias Receiver = @Sendable (E) -> Void + let receiver = ManagedCriticalState(nil) + + public init() {} + + func push(_ event: E) { + self.receiver.criticalState?(event) + } + + func register(receiver: @Sendable @escaping (E) -> Void) { + self.receiver.apply(criticalState: receiver) + } +} diff --git a/Sources/Runtime/ExecutionStrategy.swift b/Sources/Runtime/ExecutionStrategy.swift new file mode 100644 index 0000000..e30c5f5 --- /dev/null +++ b/Sources/Runtime/ExecutionStrategy.swift @@ -0,0 +1,46 @@ +// +// ExecutionStrategy.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + + +public struct ExecutionStrategy: Sendable, Equatable +where S: DSLCompatible { + enum Identifier { + case cancelWhenState + case cancelWhenStateWithAssociatedValue + case cancelWhenAnyState + case continueWhenAnyState + } + + let id: Identifier + let predicate: @Sendable (S) -> Bool + + public static func cancel(when state: S) -> ExecutionStrategy { + ExecutionStrategy(id: .cancelWhenState) { input in + input.matches(state) + } + } + + public static func cancel( + when state: @escaping (StateAssociatedValue) -> S + ) -> ExecutionStrategy { + ExecutionStrategy(id: .cancelWhenStateWithAssociatedValue) { input in + input.matches(state) + } + } + + public static var cancelWhenAnyState: ExecutionStrategy { + ExecutionStrategy(id: .cancelWhenAnyState) { _ in true } + } + + public static var continueWhenAnyState: ExecutionStrategy { + ExecutionStrategy(id: .continueWhenAnyState) { _ in false } + } + + public static func == (lhs: ExecutionStrategy, rhs: ExecutionStrategy) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/Runtime/Middleware.swift b/Sources/Runtime/Middleware.swift new file mode 100644 index 0000000..f68ec2b --- /dev/null +++ b/Sources/Runtime/Middleware.swift @@ -0,0 +1,11 @@ +// +// Middleware.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +struct Middleware { + let execute: @Sendable (T) async -> Bool + let priority: TaskPriority? +} diff --git a/Sources/Runtime/Runtime.swift b/Sources/Runtime/Runtime.swift new file mode 100644 index 0000000..5963d14 --- /dev/null +++ b/Sources/Runtime/Runtime.swift @@ -0,0 +1,217 @@ +// +// Runtime.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +final class ChannelReceiver: Sendable +where E: DSLCompatible { + typealias Receiver = @Sendable (E) -> Void + let receiver = ManagedCriticalState(nil) + + func receive(_ event: E) { + self.receiver.criticalState?(event) + } + + func update(receiver: Receiver?) { + self.receiver.apply(criticalState: receiver) + } +} + +public struct Runtime: Sendable +where S: DSLCompatible, E: DSLCompatible & Sendable, O: DSLCompatible { + var sideEffects = [SideEffect]() + var stateMiddlewares = [Middleware]() + var eventMiddlewares = [Middleware]() + var channelReceivers = [ChannelReceiver]() + + public init() {} + + @discardableResult + public func map( + output: O, + to sideEffect: @Sendable @escaping () -> AS, + priority: TaskPriority? = nil, + strategy: ExecutionStrategy = .continueWhenAnyState + ) -> Self where AS.Element == E { + var mutableSelf = self + + let predicate: @Sendable (O) -> Bool = { currentOutput in + currentOutput.matches(output) + } + + let sideEffect: @Sendable (O) -> AnyAsyncSequence = { _ in + sideEffect().eraseToAnyAsyncSequence() + } + + mutableSelf.sideEffects.append( + SideEffect( + predicate: predicate, + execute: sideEffect, + priority: priority, + strategy: strategy + ) + ) + + return mutableSelf + } + + @discardableResult + public func map( + output: O, + to sideEffect: @Sendable @escaping () async -> E?, + priority: TaskPriority? = nil, + strategy: ExecutionStrategy = .continueWhenAnyState + ) -> Self { + let sideEffect: @Sendable () -> AnyAsyncSequence = { + AsyncJustSequence(sideEffect) + .eraseToAnyAsyncSequence() + } + + return self.map( + output: output, + to: sideEffect, + priority: priority, + strategy: strategy + ) + } + + @discardableResult + public func map( + output: @escaping (OutputAssociatedValue) -> O, + to sideEffect: @Sendable @escaping (OutputAssociatedValue) -> AS, + priority: TaskPriority? = nil, + strategy: ExecutionStrategy = .continueWhenAnyState + ) -> Self where AS.Element == E { + var mutableSelf = self + + let predicate: @Sendable (O) -> Bool = { currentOutput in + currentOutput.matches(output) + } + + let sideEffect: @Sendable (O) -> AnyAsyncSequence? = { currentOutput in + if let outputAssociatedValue = currentOutput.associatedValue(expecting: OutputAssociatedValue.self) { + return sideEffect(outputAssociatedValue).eraseToAnyAsyncSequence() + } + + return nil + } + + mutableSelf.sideEffects.append( + SideEffect( + predicate: predicate, + execute: sideEffect, + priority: priority, + strategy: strategy + ) + ) + + return mutableSelf + } + + @discardableResult + public func map( + output: @escaping (OutputAssociatedValue) -> O, + to sideEffect: @Sendable @escaping (OutputAssociatedValue) async -> E?, + priority: TaskPriority? = nil, + strategy: ExecutionStrategy = .continueWhenAnyState + ) -> Self { + let sideEffect: @Sendable (OutputAssociatedValue) -> AnyAsyncSequence = { outputAssociatedValue in + return AsyncJustSequence({ await sideEffect(outputAssociatedValue) }) + .eraseToAnyAsyncSequence() + } + + return self.map( + output: output, + to: sideEffect, + priority: priority, + strategy: strategy + ) + } + + @discardableResult + public func register( + middleware: @Sendable @escaping (S) async -> Void, + priority: TaskPriority? = nil + ) -> Self { + var mutableSelf = self + + mutableSelf.stateMiddlewares.append( + Middleware( + execute: { state in + await middleware(state) + return false + }, + priority: priority + ) + ) + + return mutableSelf + } + + @discardableResult + public func register( + middleware: @Sendable @escaping (E) async -> Void, + priority: TaskPriority? = nil + ) -> Self { + var mutableSelf = self + + mutableSelf.eventMiddlewares.append( + Middleware( + execute: { event in + await middleware(event) + return false + }, + priority: priority + ) + ) + + return mutableSelf + } + + @discardableResult + public func connectAsReceiver( + to channel: Channel + ) -> Self { + var mutableSelf = self + + let channelReceiver = ChannelReceiver() + channel.register { event in channelReceiver.receive(event) } + mutableSelf.channelReceivers.append(channelReceiver) + + return mutableSelf + } + + @discardableResult + public func connectAsSender( + to channel: Channel, + when state: S, + send event: OtherE + ) -> Self { + return self.register(middleware: { (inputState: S) in + guard inputState.matches(state) else { return } + channel.push(event) + }) + } + + @discardableResult + public func connectAsSender( + to channel: Channel, + when state: @escaping (StateAssociatedValue) -> S, + send event: @Sendable @escaping (StateAssociatedValue) -> OtherE + ) -> Self { + return self.register(middleware: { (inputState: S) in + guard let value = inputState.associatedValue(matching: state) + else { return } + channel.push(event(value)) + }) + } + + @Sendable func sideEffects(for output: O) -> SideEffect? { + self + .sideEffects + .first(where: { sideEffect in sideEffect.predicate(output) }) + } +} + diff --git a/Sources/Runtime/SideEffect.swift b/Sources/Runtime/SideEffect.swift new file mode 100644 index 0000000..442d564 --- /dev/null +++ b/Sources/Runtime/SideEffect.swift @@ -0,0 +1,14 @@ +// +// SideEffect.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +struct SideEffect: Sendable +where S: DSLCompatible { + let predicate: @Sendable (O) -> Bool + let execute: @Sendable (O) -> AnyAsyncSequence? + let priority: TaskPriority? + let strategy: ExecutionStrategy +} diff --git a/Sources/StateMachine/DSLCompatible.swift b/Sources/StateMachine/DSLCompatible.swift new file mode 100644 index 0000000..d499e04 --- /dev/null +++ b/Sources/StateMachine/DSLCompatible.swift @@ -0,0 +1,126 @@ +// +// DSLCompatible.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +// insppired by https://github.com/gringoireDM/EnumKit +import Foundation + +/// ``DSLCompatible`` conformance allows enum types to be +/// used in a declarative fashion by using memory fingerprint comparition +/// or case label comparition. +/// +/// ``` +/// enum Value: DSLCompatible { +/// case v1 +/// case v2(value: String) +/// } +/// +/// let me = Value.v2("foo") +/// +/// me.matches(Value.v2(value:)) // true +/// me.matches(Value.v1) // false +/// +/// me.associatedValue(expecting: String.self) // returns "foo"? +/// me.associatedValue(expecting: Int.self) // returns nil +/// ``` +public protocol DSLCompatible {} + +struct NotAnEnumError: Error {} + +public extension DSLCompatible { + /// if Self is an enum: returns the label of the case + /// if Self is a type with some properties: returns the label of the first property + /// if Self is a type without properties: returns the description of the type + var label: String { + return Mirror(reflecting: self).children.first?.label ?? String(describing: self) + } + + /// Determines if `self` matches an enum case from a memory fingerprint perspective or a label perspective. + /// + /// ``` + /// enum Value: DSLCompatible { + /// case v1 + /// case v2 + /// } + /// + /// let me = Value.v2 + /// + /// me.matches(Value.v2) // true + /// me.matches(Value.v1) // false + /// ``` + /// + /// - Parameter other: the value against which we are looking for a match. + /// - Returns: true when `other` matches `self`, false otherwise, + func matches(_ other: Self) -> Bool { + var me = self + var other = other + // compare memory bitwise (should be the priviledged comparaison point) + return memcmp(&me, &other, MemoryLayout.size) == 0 || me.label == other.label + } + + + /// Determines if `self` matches an enum case signature that has associated values. + /// + /// ``` + /// enum Value: DSLCompatible { + /// case v1(value: Int) + /// case v2(value: String) + /// } + /// + /// let me = Value.v2(value: "foo") + /// + /// me.matches(Value.v2(value:)) // true + /// me.matches(Value.v1(value:)) // false + /// ``` + /// + /// - Parameter definition: the signature of the enum case + /// - Returns: true when self matches the `definition`, false otherwise, + func matches(_ definition: (AssociatedValue) -> Self) -> Bool { + return associatedValue(matching: definition) != nil + } + + /// Extracts the associated value from `self` when `self` is an enum case with associated values of the expected type. + /// - Parameter expecting: the expected type of the associated value. + /// - Returns: the value of the associated type when the expected type matches the actual associated value, nil otherwise. + func associatedValue(expecting: AssociatedValue.Type) -> AssociatedValue? { + return decompose(expecting: expecting)?.associatedValue + } + + /// Extracts the associated value from `self` when `self` is an enum case that matches the given enum case signature. + /// - Parameter definition: the signature of the enum case + /// - Returns: the value of the associated type when `self` matches the given enum signature, nil otherwise. + func associatedValue(matching definition: (AssociatedValue) -> Self) -> AssociatedValue? { + guard + let me: (path: [String?], associatedValue: AssociatedValue) = decompose(expecting: AssociatedValue.self), + let other: (path: [String?], associatedValue: AssociatedValue) = definition(me.associatedValue).decompose(expecting: AssociatedValue.self), + me.path == other.path else { return nil } + return me.associatedValue + } +} + +extension DSLCompatible { + func decompose( + expecting: AssociatedValue.Type + ) -> (path: [String?], associatedValue: AssociatedValue)? { + let mirror = Mirror(reflecting: self) + + guard mirror.displayStyle == .enum else { return nil } + + var path: [String?] = [] + var any: Any = self + + while case let (label?, anyChild)? = Mirror(reflecting: any).children.first { + path.append(label) + path.append(String(describing: type(of: anyChild))) + if let child = anyChild as? AssociatedValue { return (path, child) } + any = anyChild + } + if MemoryLayout.size == 0 { + return (["\(self)"], unsafeBitCast((), to: AssociatedValue.self)) + } + return nil + } +} diff --git a/Sources/StateMachine/Execute.swift b/Sources/StateMachine/Execute.swift new file mode 100644 index 0000000..542433e --- /dev/null +++ b/Sources/StateMachine/Execute.swift @@ -0,0 +1,23 @@ +// +// Execute.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct Execute +where O: DSLCompatible { + let output: O? + + init() { + self.output = nil + } + + public init(output: O) { + self.output = output + } + + public static var noOutput: Execute { + Execute() + } +} diff --git a/Sources/StateMachine/Guard.swift b/Sources/StateMachine/Guard.swift new file mode 100644 index 0000000..b9cbf2f --- /dev/null +++ b/Sources/StateMachine/Guard.swift @@ -0,0 +1,14 @@ +// +// Guard.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct Guard { + let predicate: Bool + + public init(predicate: @autoclosure () -> Bool) { + self.predicate = predicate() + } +} diff --git a/Sources/StateMachine/Never+DSLCompatible.swift b/Sources/StateMachine/Never+DSLCompatible.swift new file mode 100644 index 0000000..5fd5072 --- /dev/null +++ b/Sources/StateMachine/Never+DSLCompatible.swift @@ -0,0 +1,8 @@ +// +// Never+DSLCompatible.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +extension Never: DSLCompatible {} diff --git a/Sources/StateMachine/On.swift b/Sources/StateMachine/On.swift new file mode 100644 index 0000000..3c07d6d --- /dev/null +++ b/Sources/StateMachine/On.swift @@ -0,0 +1,92 @@ +// +// On.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct On: Sendable +where S: DSLCompatible, E: DSLCompatible { + // predicate and transition are 2 separate things because we want + // to be able to evaluate predicates (which is a sync and fast operation) + // in isolation to select the good transition (we don't want to execute + // all the transitions to find out which has a non nil next state) + let predicate: @Sendable (E) -> Bool + let transition: @Sendable (E) async -> S? + + public init( + event: E, + guard: @escaping @Sendable (E) -> Guard, + transition: @escaping @Sendable (E) async -> Transition + ) { + self.predicate = { inputEvent in + inputEvent.matches(event) && `guard`(inputEvent).predicate + } + self.transition = { inputEvent in await transition(inputEvent).state } + } + + public init( + event: E, + transition: @escaping @Sendable (E) async -> Transition + ) { + self.init( + event: event, + guard: { _ in Guard(predicate: true) }, + transition: transition + ) + } + + public init( + event: @escaping (EventAssociatedValue) -> E, + guard: @escaping @Sendable (EventAssociatedValue) -> Guard, + transition: @escaping @Sendable (EventAssociatedValue) async -> Transition + ) { + self.predicate = { inputEvent in + if let inputPayload = inputEvent.associatedValue(expecting: EventAssociatedValue.self) { + return inputEvent.matches(event) && + `guard`(inputPayload).predicate + } + return false + } + + self.transition = { inputEvent in + if let eventPayload = inputEvent.associatedValue(expecting: EventAssociatedValue.self) { + return await transition(eventPayload).state + } + return nil + } + } + + public init( + event: @escaping (EventAssociatedValue) -> E, + transition: @escaping @Sendable (EventAssociatedValue) async -> Transition + ) { + self.init( + event: event, + guard: { _ in Guard(predicate: true) }, + transition: transition + ) + } + + public init( + events: OneOf, + guard: @escaping @Sendable (E) -> Guard, + transition: @escaping @Sendable (E) async -> Transition + ) { + self.predicate = { event in + events.predicate(event) && `guard`(event).predicate + } + self.transition = { event in await transition(event).state } + } + + public init( + events: OneOf, + transition: @escaping @Sendable (E) async -> Transition + ) { + self.init( + events: events, + guard: { _ in Guard(predicate: true) }, + transition: transition + ) + } +} diff --git a/Sources/StateMachine/OneOf.swift b/Sources/StateMachine/OneOf.swift new file mode 100644 index 0000000..55f7a4e --- /dev/null +++ b/Sources/StateMachine/OneOf.swift @@ -0,0 +1,43 @@ +// +// OneOf.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct OneOf: Sendable +where T: DSLCompatible { + let predicate: @Sendable (T) -> Bool + + init(predicate: @escaping @Sendable (T) -> Bool) { + self.predicate = predicate + } + + public init(@OneOfBuilder _ oneOf: () -> OneOf) { + self = oneOf() + } +} + +@resultBuilder +public enum OneOfBuilder +where T: DSLCompatible { + public static func buildExpression( + _ expression: T + ) -> (T) -> Bool { + { input in + input.matches(expression) + } + } + + public static func buildExpression( + _ expression: @escaping (AssociatedValue) -> T + ) -> (T) -> Bool { + { input in + input.matches(expression) + } + } + + public static func buildBlock(_ components: ((T) -> Bool)...) -> OneOf { + OneOf(predicate: { input in components.contains { $0(input) } }) + } +} diff --git a/Sources/StateMachine/StateMachine.swift b/Sources/StateMachine/StateMachine.swift new file mode 100644 index 0000000..25a72d1 --- /dev/null +++ b/Sources/StateMachine/StateMachine.swift @@ -0,0 +1,52 @@ +// +// StateMachine.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct StateMachine: Sendable +where S: DSLCompatible & Sendable, E: DSLCompatible, O: DSLCompatible { + let initial: S + let whenStates: [When] + + public init( + initial: S, + @WhensBuilder whenStates: () -> [When] + ) { + self.initial = initial + self.whenStates = whenStates() + } + + @Sendable func output(for state: S) -> O? { + self + .whenStates + .first { $0.predicate(state) }? + .output(state) + } + + @Sendable func reduce(when state: S, on event: E) async -> S? { + await self + .whenStates + .filter { $0.predicate(state) } + .flatMap { $0.transitions(state) } + .first { $0.predicate(event) }? + .transition(event) + } +} + +@resultBuilder +public enum WhensBuilder +where S: DSLCompatible, E: DSLCompatible, O: DSLCompatible { + public static func buildExpression( + _ expression: When + ) -> When { + expression + } + + public static func buildBlock( + _ components: When... + ) -> [When] { + components + } +} diff --git a/Sources/StateMachine/Transition.swift b/Sources/StateMachine/Transition.swift new file mode 100644 index 0000000..d95b266 --- /dev/null +++ b/Sources/StateMachine/Transition.swift @@ -0,0 +1,15 @@ +// +// Transition.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct Transition +where S: DSLCompatible { + let state: S + + public init(to state: S) { + self.state = state + } +} diff --git a/Sources/StateMachine/When.swift b/Sources/StateMachine/When.swift new file mode 100644 index 0000000..f783f18 --- /dev/null +++ b/Sources/StateMachine/When.swift @@ -0,0 +1,118 @@ +// +// When.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public struct When: Sendable +where S: DSLCompatible, E: DSLCompatible, O: DSLCompatible { + let predicate: @Sendable (S) -> Bool + let output: @Sendable (S) -> O? + let transitions: @Sendable (S) -> [On] + + init( + predicate: @Sendable @escaping (S) -> Bool, + output: @Sendable @escaping (S) -> O?, + transitions: @Sendable @escaping (S) -> [On] + ) { + self.predicate = predicate + self.output = output + self.transitions = transitions + } + + public init( + states: OneOf, + execute: @Sendable @escaping (S) -> Execute, + @TransitionsBuilder transitions: @Sendable @escaping (S) -> [On] + ) { + self.init( + predicate: states.predicate, + output: { inputState in execute(inputState).output }, + transitions: transitions + ) + } + + public init( + states: OneOf, + execute: @Sendable @escaping (S) -> Execute + ) { + self.init( + states: states, + execute: execute, + transitions: { _ in } + ) + } + + public init( + state: S, + execute: @Sendable @escaping (S) -> Execute, + @TransitionsBuilder transitions: @Sendable @escaping (S) -> [On] + ) { + self.init( + predicate: { inputState in inputState.matches(state) }, + output: { inputState in execute(inputState).output }, + transitions: transitions + ) + } + + public init( + state: S, + execute: @Sendable @escaping (S) -> Execute + ) { + self.init( + state: state, + execute: execute, + transitions: { _ in } + ) + } + + public init( + state: @escaping (StateAssociatedValue) -> S, + execute: @Sendable @escaping (StateAssociatedValue) -> Execute, + @TransitionsBuilder transitions: @Sendable @escaping (StateAssociatedValue) -> [On] + ) { + self.init( + predicate: { inputState in inputState.matches(state) }, + output: { inputState in + if let inputPayload = inputState.associatedValue(expecting: StateAssociatedValue.self) { + return execute(inputPayload).output + } + return nil + }, + transitions: { inputState in + if let inputPayload = inputState.associatedValue(expecting: StateAssociatedValue.self) { + return transitions(inputPayload) + } + return [] + } + ) + } + + public init( + state: @escaping (StateAssociatedValue) -> S, + execute: @Sendable @escaping (StateAssociatedValue) -> Execute + ) { + self.init( + state: state, + execute: execute, + transitions: { _ in } + ) + } +} + +@resultBuilder +public enum TransitionsBuilder +where S: DSLCompatible, E: DSLCompatible { + public static func buildExpression( + _ expression: On + ) -> On { + expression + } + + public static func buildBlock( + _ components: On... + ) -> [On] { + components + } +} diff --git a/Sources/Supporting/AnyAsyncSequence.swift b/Sources/Supporting/AnyAsyncSequence.swift new file mode 100644 index 0000000..c049b19 --- /dev/null +++ b/Sources/Supporting/AnyAsyncSequence.swift @@ -0,0 +1,43 @@ +// +// AnyAsyncSequence.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public extension AsyncSequence { + /// Type erase the AsyncSequence into an AnyAsyncSequence. + /// - Returns: A type erased AsyncSequence. + func eraseToAnyAsyncSequence() -> AnyAsyncSequence { + AnyAsyncSequence(self) + } +} + +/// Type erased version of an AsyncSequence. +public struct AnyAsyncSequence: AsyncSequence { + public typealias Element = Element + public typealias AsyncIterator = Iterator + + private let makeAsyncIteratorClosure: () -> AsyncIterator + + public init(_ baseAsyncSequence: BaseAsyncSequence) where BaseAsyncSequence.Element == Element { + self.makeAsyncIteratorClosure = { Iterator(baseIterator: baseAsyncSequence.makeAsyncIterator()) } + } + + public func makeAsyncIterator() -> AsyncIterator { + Iterator(baseIterator: self.makeAsyncIteratorClosure()) + } + + public struct Iterator: AsyncIteratorProtocol { + private let nextClosure: () async throws -> Element? + + public init(baseIterator: BaseAsyncIterator) where BaseAsyncIterator.Element == Element { + var baseIterator = baseIterator + self.nextClosure = { try await baseIterator.next() } + } + + public func next() async throws -> Element? { + try await self.nextClosure() + } + } +} diff --git a/Sources/Supporting/AsyncBufferedChannel.swift b/Sources/Supporting/AsyncBufferedChannel.swift new file mode 100644 index 0000000..d95a128 --- /dev/null +++ b/Sources/Supporting/AsyncBufferedChannel.swift @@ -0,0 +1,185 @@ +// +// AsyncBufferedChannel.swift +// +// +// Created by Thibault WITTEMBERG on 06/08/2022. +// + +public final class AsyncBufferedChannel: AsyncSequence, Sendable +where Element: Sendable { + public typealias Element = Element + public typealias AsyncIterator = Iterator + + struct Awaiting: Hashable { + let id: Int + let continuation: UnsafeContinuation? + + static func placeHolder(id: Int) -> Awaiting { + Awaiting(id: id, continuation: nil) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + static func ==(lhs: Awaiting, rhs: Awaiting) -> Bool { + lhs.id == rhs.id + } + } + + enum SendDecision { + case resume(Awaiting, Element) + case terminate([Awaiting]) + case nothing + } + + enum AwaitingDecision { + case resume(Element?) + case suspend + } + + enum Value { + case element(Element) + case termination + } + + enum State { + case active(queue: [Value], awaitings: Set) + case finished + + static var initial: State { + .active(queue: [], awaitings: []) + } + } + + let ids: ManagedCriticalState + let state: ManagedCriticalState + + init() { + self.ids = ManagedCriticalState(0) + self.state = ManagedCriticalState(.initial) + } + + func generateId() -> Int { + self.ids.withCriticalRegion { ids in + ids += 1 + return ids + } + } + + func send(_ value: Value) { + let decision = self.state.withCriticalRegion { state -> SendDecision in + switch state { + case .active(var queue, var awaitings): + if !awaitings.isEmpty { + switch value { + case .element(let element): + let awaiting = awaitings.removeFirst() + state = .active(queue: queue, awaitings: awaitings) + return .resume(awaiting, element) + case .termination: + state = .finished + return .terminate(Array(awaitings)) + } + } else { + switch value { + case .termination where queue.isEmpty: + state = .finished + case .element, .termination: + queue.append(value) + state = .active(queue: queue, awaitings: awaitings) + } + return .nothing + } + case .finished: + return .nothing + } + } + + switch decision { + case .nothing: + break + case .terminate(let awaitings): + awaitings.forEach { $0.continuation?.resume(returning: nil) } + case .resume(let awaiting, let element): + awaiting.continuation?.resume(returning: element) + } + } + + func send(_ element: Element) { + self.send(.element(element)) + } + + func finish() { + self.send(.termination) + } + + func next(onSuspend: (() -> Void)? = nil) async -> Element? { + let awaitingId = self.generateId() + let cancellation = ManagedCriticalState(false) + + return await withTaskCancellationHandler { [state] in + let awaiting = state.withCriticalRegion { state -> Awaiting? in + cancellation.apply(criticalState: true) + switch state { + case .active(let queue, var awaitings): + let awaiting = awaitings.remove(.placeHolder(id: awaitingId)) + state = .active(queue: queue, awaitings: awaitings) + return awaiting + case .finished: + return nil + } + } + + awaiting?.continuation?.resume(returning: nil) + } operation: { + await withUnsafeContinuation { [state] (continuation: UnsafeContinuation) in + let decision = state.withCriticalRegion { state -> AwaitingDecision in + guard !cancellation.criticalState else { return .resume(nil) } + + switch state { + case .active(var queue, var awaitings): + if !queue.isEmpty { + let value = queue.removeFirst() + switch value { + case .termination: + state = .finished + return .resume(nil) + case .element(let element): + state = .active(queue: queue, awaitings: awaitings) + return .resume(element) + } + } else { + awaitings.update(with: Awaiting(id: awaitingId, continuation: continuation)) + state = .active(queue: queue, awaitings: awaitings) + return .suspend + } + case .finished: + return .resume(nil) + } + } + + switch decision { + case .resume(let element): continuation.resume(returning: element) + case .suspend: + onSuspend?() + break + } + } + } + } + + public func makeAsyncIterator() -> AsyncIterator { + Iterator( + channel: self + ) + } + + public struct Iterator: AsyncIteratorProtocol { + let channel: AsyncBufferedChannel + + public func next() async -> Element? { + await self.channel.next() + } + } +} diff --git a/Sources/Supporting/AsyncCompactScanSequence.swift b/Sources/Supporting/AsyncCompactScanSequence.swift new file mode 100644 index 0000000..eb740c1 --- /dev/null +++ b/Sources/Supporting/AsyncCompactScanSequence.swift @@ -0,0 +1,69 @@ +// +// AsyncCompactScanSequence.swift +// +// +// Created by Thibault WITTEMBERG on 11/08/2022. +// + +extension AsyncSequence { + func compactScan( + _ initial: R, + _ transform: @Sendable @escaping (R, Element) async -> R? + ) -> AsyncCompactScanSequence { + AsyncCompactScanSequence(base: self, initial: initial, transform: transform) + } +} + +public final class AsyncCompactScanSequence: AsyncSequence, Sendable +where R: Sendable, Base: Sendable { + public typealias Element = R + public typealias AsyncIterator = Iterator + + let base: Base + let initial: R + let transform: @Sendable (R, Base.Element) async -> R? + + public init(base: Base, initial: R, transform: @Sendable @escaping (R, Base.Element) async -> R?) { + self.base = base + self.initial = initial + self.transform = transform + } + + public func makeAsyncIterator() -> Iterator { + Iterator( + baseIterator: self.base.makeAsyncIterator(), + accumulator: self.initial, + transform: self.transform + ) + } + + public struct Iterator: AsyncIteratorProtocol { + var baseIterator: Base.AsyncIterator + var accumulator: R + var isInitial = true + let transform: @Sendable (R, Base.Element) async -> R? + + public mutating func next() async rethrows -> Element? { + if isInitial { + isInitial = false + return self.accumulator + } + + var result: R? = nil + + while result == nil { + guard let element = try await self.baseIterator.next() else { + return nil + } + + result = await self.transform(accumulator, element) + } + + if let result = result { + self.accumulator = result + } + + return result + } + } +} diff --git a/Sources/Supporting/AsyncJustSequence.swift b/Sources/Supporting/AsyncJustSequence.swift new file mode 100644 index 0000000..3552562 --- /dev/null +++ b/Sources/Supporting/AsyncJustSequence.swift @@ -0,0 +1,34 @@ +public struct AsyncJustSequence: AsyncSequence { + public typealias Element = Element + public typealias AsyncIterator = Iterator + + let element: () async -> Element? + + public init(_ element: @escaping () async -> Element?) { + self.element = element + } + + public func makeAsyncIterator() -> Iterator { + Iterator(self.element) + } + + public struct Iterator: AsyncIteratorProtocol { + let element: () async -> Element? + var hasDelivered = false + + init(_ element: @escaping () async -> Element?) { + self.element = element + } + + public mutating func next() async -> Element? { + guard !Task.isCancelled else { return nil } + + guard !self.hasDelivered else { + return nil + } + + self.hasDelivered = true + return await self.element() + } + } +} diff --git a/Sources/Supporting/AsyncOnEachSequence.swift b/Sources/Supporting/AsyncOnEachSequence.swift new file mode 100644 index 0000000..9cf572a --- /dev/null +++ b/Sources/Supporting/AsyncOnEachSequence.swift @@ -0,0 +1,48 @@ +// +// AsyncOnEachSequence.swift +// +// +// Created by Thibault WITTEMBERG on 03/08/2022. +// + +extension AsyncSequence { + func onEach(_ block: @Sendable @escaping (Element) async -> Void) -> AsyncOnEachSequence { + AsyncOnEachSequence(self, onEach: block) + } +} + +public final class AsyncOnEachSequence: AsyncSequence, Sendable +where Base: Sendable { + public typealias Element = Base.Element + public typealias AsyncIterator = Iterator + + let base: Base + let onEach: @Sendable (Element) async -> Void + + init(_ base: Base, onEach: @Sendable @escaping (Element) async -> Void) { + self.base = base + self.onEach = onEach + } + + public func makeAsyncIterator() -> Iterator { + Iterator( + baseIterator: self.base.makeAsyncIterator(), + onEach: self.onEach + ) + } + + public struct Iterator: AsyncIteratorProtocol { + var baseIterator: Base.AsyncIterator + let onEach: @Sendable (Element) async -> Void + + public mutating func next() async rethrows -> Element? { + let element = try await self.baseIterator.next() + + if let element = element { + await self.onEach(element) + } + + return element + } + } +} diff --git a/Sources/Supporting/AsyncSerialSequence.swift b/Sources/Supporting/AsyncSerialSequence.swift new file mode 100644 index 0000000..eedb0c6 --- /dev/null +++ b/Sources/Supporting/AsyncSerialSequence.swift @@ -0,0 +1,154 @@ +// +// AsyncSerialMapSequence.swift +// +// +// Created by Thibault WITTEMBERG on 01/08/2022. +// + +extension AsyncSequence { + func serial() -> AsyncSerialSequence { + AsyncSerialSequence(base: self) + } +} + +public final class AsyncSerialSequence: AsyncSequence, Sendable +where Base: Sendable{ + public typealias Element = Base.Element + public typealias AsyncIterator = Iterator + + struct Token: Hashable { + let id: Int + let continuation: UnsafeContinuation? + + static func placeHolder(id: Int) -> Token { + Token(id: id, continuation: nil) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + static func ==(lhs: Token, rhs: Token) -> Bool { + lhs.id == rhs.id + } + } + + enum State: Equatable { + case unlocked + case locked(Set) + } + + let base: Base + let state: ManagedCriticalState + let ids: ManagedCriticalState + + init(base: Base) { + self.base = base + self.state = ManagedCriticalState(.unlocked) + self.ids = ManagedCriticalState(0) + } + + func generateId() -> Int { + self.ids.withCriticalRegion { ids -> Int in + ids += 1 + return ids + } + } + + func next( + _ base: inout Base.AsyncIterator, + onImmediateResume: (() -> Void)? = nil, + onSuspend: (() -> Void)? = nil + ) async rethrows -> Element? { + + let tokenId = self.generateId() + let isCancelled = ManagedCriticalState(false) + + return try await withTaskCancellationHandler { + let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in + let continuation: UnsafeContinuation? + + switch state { + case .unlocked: + continuation = nil + case .locked(var tokens): + if tokens.isEmpty { + state = .unlocked + continuation = nil + } else { + let removed = tokens.remove(.placeHolder(id: tokenId)) + state = .locked(tokens) + continuation = removed?.continuation + } + } + + isCancelled.apply(criticalState: true) + + return continuation + } + + continuation?.resume() + } operation: { + await withUnsafeContinuation { [state] (continuation: UnsafeContinuation) in + let continuation = state.withCriticalRegion { state -> UnsafeContinuation? in + guard !isCancelled.criticalState else { return continuation } + + switch state { + case .unlocked: + state = .locked([]) + return continuation + case .locked(var continuations): + continuations.update(with: Token(id: tokenId, continuation: continuation)) + state = .locked(continuations) + return nil + } + } + + if let continuation = continuation { + continuation.resume() + onImmediateResume?() + } else { + onSuspend?() + } + } + + let element = try await base.next() + + let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in + switch state { + case .unlocked: + return nil + case .locked(var tokens): + if tokens.isEmpty { + state = .unlocked + return nil + } else { + let token = tokens.removeFirst() + state = .locked(tokens) + return token.continuation + } + } + } + + continuation?.resume() + + return element + } + } + + public func makeAsyncIterator() -> Iterator { + return Iterator( + asyncSerialSequence: self, + baseIterator: self.base.makeAsyncIterator() + ) + } + + public struct Iterator: AsyncIteratorProtocol { + let asyncSerialSequence: AsyncSerialSequence + var baseIterator: Base.AsyncIterator + + public mutating func next() async rethrows -> Element? { + try await self.asyncSerialSequence.next(&baseIterator) + } + } +} diff --git a/Sources/Supporting/Inject.swift b/Sources/Supporting/Inject.swift new file mode 100644 index 0000000..8ac7ee1 --- /dev/null +++ b/Sources/Supporting/Inject.swift @@ -0,0 +1,130 @@ +// +// Inject.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +public func inject( + dep a: A, + in block: @escaping (A) async -> R +) -> () async -> R { + { + await block(a) + } +} + +public func inject( + deps a: A, + _ b: B, + in block: @escaping (A, B) async -> R +) -> () async -> R { + { + await block(a, b) + } +} + +public func inject( + deps a: A, + _ b: B, + _ c: C, + in block: @escaping (A, B, C) async -> R +) -> () async -> R { + { + await block(a, b, c) + } +} + +public func inject( + deps a: A, + _ b: B, + _ c: C, + _ d: D, + in block: @escaping (A, B, C, D) async -> R +) -> () async -> R { + { + await block(a, b, c, d) + } +} + +public func inject( + deps a: A, + _ b: B, + _ c: C, + _ d: D, + _ e: E, + in block: @escaping (A, B, C, D, E) async -> R +) -> () async -> R { + { + await block(a, b, c, d, e) + } +} + +public func inject( + deps a: A, + _ b: B, + _ c: C, + _ d: D, + _ e: E, + _ f: F, + in block: @escaping (A, B, C, D, E, F) async -> R +) -> () async -> R { + { + await block(a, b, c, d, e, f) + } +} + +public func inject( + dep b: B, + in block: @escaping (A, B) async -> R +) -> (A) async -> R { + { a in + await block(a, b) + } +} + +public func inject( + deps b: B, + _ c: C, + in block: @escaping (A, B, C) async -> R +) -> (A) async -> R { + { a in + await block(a, b, c) + } +} + +public func inject( + deps b: B, + _ c: C, + _ d: D, + in block: @escaping (A, B, C, D) async -> R +) -> (A) async -> R { + { a in + await block(a, b, c, d) + } +} + +public func inject( + deps b: B, + _ c: C, + _ d: D, + _ e: E, + in block: @escaping (A, B, C, D, E) async -> R +) -> (A) async -> R { + { a in + await block(a, b, c, d, e) + } +} + +public func inject( + deps b: B, + _ c: C, + _ d: D, + _ e: E, + _ f: F, + in block: @escaping (A, B, C, D, E, F) async -> R +) -> (A) async -> R { + { a in + await block(a, b, c, d, e, f) + } +} diff --git a/Sources/Supporting/ManagedCriticalState.swift b/Sources/Supporting/ManagedCriticalState.swift new file mode 100644 index 0000000..102b7d0 --- /dev/null +++ b/Sources/Supporting/ManagedCriticalState.swift @@ -0,0 +1,45 @@ +import Darwin + +final class LockedBuffer: ManagedBuffer { + deinit { + _ = self.withUnsafeMutablePointerToElements { lock in + lock.deinitialize(count: 1) + } + } +} + +struct ManagedCriticalState { + let buffer: ManagedBuffer + + init(_ initial: State) { + buffer = LockedBuffer.create(minimumCapacity: 1) { buffer in + buffer.withUnsafeMutablePointerToElements { lock in + lock.initialize(to: os_unfair_lock()) + } + return initial + } + } + + @discardableResult + func withCriticalRegion( + _ critical: (inout State) throws -> R + ) rethrows -> R { + try buffer.withUnsafeMutablePointers { header, lock in + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return try critical(&header.pointee) + } + } + + func apply(criticalState newState: State) { + self.withCriticalRegion { actual in + actual = newState + } + } + + var criticalState: State { + self.withCriticalRegion { $0 } + } +} + +extension ManagedCriticalState: @unchecked Sendable where State: Sendable { } diff --git a/Sources/Supporting/OrderedStorage.swift b/Sources/Supporting/OrderedStorage.swift new file mode 100644 index 0000000..10cf7c1 --- /dev/null +++ b/Sources/Supporting/OrderedStorage.swift @@ -0,0 +1,52 @@ +// +// OrderedStorage.swift +// +// +// Created by Thibault WITTEMBERG on 26/06/2022. +// + +struct OrderedStorage { + var index = 0 + var storage = [Int: Value]() + + init() {} + + init(contentOf array: [Value]) { + array.forEach { value in + self.append(value) + } + } + + @discardableResult + mutating func append(_ value: Value) -> Int { + let currentIndex = self.index + self.storage[currentIndex] = value + self.index += 1 + return currentIndex + } + + mutating func removeAll() { + self.storage.removeAll() + } + + mutating func remove(index: Int) { + self.storage[index] = nil + } + + var indexedValues: [(index: Int, value: Value)] { + self + .storage + .sorted(by: { $0.0 < $1.0 }) + .map { (index: $0.key, value: $0.value) } + } + + var values: [Value] { + self + .indexedValues + .map { $0.value } + } + + var count: Int { + self.storage.count + } +} diff --git a/Sources/ViewState.swift b/Sources/ViewState.swift new file mode 100644 index 0000000..dddeb7a --- /dev/null +++ b/Sources/ViewState.swift @@ -0,0 +1,114 @@ +// +// ViewState.swift +// +// +// Created by Thibault WITTEMBERG on 02/07/2022. +// + +public class ViewState: ObservableObject +where S: DSLCompatible & Equatable, E: DSLCompatible, O: DSLCompatible { + @Published public var state: S + + let asyncStateMachineSequence: AsyncStateMachineSequence + + public init(asyncStateMachineSequence: AsyncStateMachineSequence) { + self.asyncStateMachineSequence = asyncStateMachineSequence + self.state = self.asyncStateMachineSequence.initialState + } + + public func send(_ event: E) { + self.asyncStateMachineSequence.send(event) + } + + public func send( + _ event: E, + resumeWhen predicate: @escaping (S) -> Bool + ) async { + await withUnsafeContinuation { [asyncStateMachineSequence] (continuation: UnsafeContinuation) in + Task { + await asyncStateMachineSequence.engine.register(onTheFly: { state in + if predicate(state) { + continuation.resume() + // middleware will be unregistered after the predicate has been matched + return true + } + return false + }) + + asyncStateMachineSequence.send(event) + } + } + } + + public func send( + _ event: E, + resumeWhen state: S + ) async { + await self.send( + event, + resumeWhen: { inputState in inputState.matches(state) } + ) + } + + public func send( + _ event: E, + resumeWhen state: @escaping (StateAssociatedValue) -> S + ) async { + await self.send( + event, + resumeWhen: { inputState in inputState.matches(state) } + ) + } + + public func send( + _ event: E, + resumeWhen states: OneOf + ) async { + await self.send( + event, + resumeWhen: { inputState in states.predicate(inputState) } + ) + } + + @MainActor func publish(state: S) { + if state != self.state { + self.state = state + } + } + + nonisolated public func start() async { + for await state in self.asyncStateMachineSequence { + await self.publish(state: state) + } + } +} + +#if canImport(SwiftUI) +import SwiftUI + +public extension ViewState { + func binding(send event: @escaping (S) -> E) -> Binding { + Binding { + self.state + } set: { [asyncStateMachineSequence] value in + asyncStateMachineSequence.send(event(value)) + } + } + + func binding(send event: E) -> Binding { + self.binding(send: { _ in event }) + } + + func binding(keypath: KeyPath, send event: @escaping (T) -> E) -> Binding { + Binding { + self.state[keyPath: keypath] + } set: { [asyncStateMachineSequence] value in + asyncStateMachineSequence.send(event(value)) + } + } + + func binding(keypath: KeyPath, send event: E) -> Binding { + self.binding(keypath: keypath, send: { _ in event }) + } +} +#endif diff --git a/Sources/XCTest/XCTStateMachine.swift b/Sources/XCTest/XCTStateMachine.swift new file mode 100644 index 0000000..97d167e --- /dev/null +++ b/Sources/XCTest/XCTStateMachine.swift @@ -0,0 +1,108 @@ +// +// XCTStateMachine.swift +// +// +// Created by Thibault WITTEMBERG on 25/06/2022. +// + +#if DEBUG +import XCTestDynamicOverlay + +public class XCTStateMachine +where +S: DSLCompatible & Equatable, +E: DSLCompatible & Equatable, +O: DSLCompatible & Equatable { + let stateMachine: StateMachine + + public init(_ stateMachine: StateMachine) { + self.stateMachine = stateMachine + } + + @discardableResult + public func assert( + when states: S..., + on event: E, + transitionTo expectedState: S, + fail: (String) -> Void = XCTFail + ) async -> Self { + for state in states { + let receivedState = await self.stateMachine.reduce(when: state, on: event) + guard receivedState == expectedState else { + fail( + """ + The assertion failed for state \(state) and event \(event): + expected new state: \(expectedState), + received new state: \(String(describing: receivedState)) + """ + ) + return self + } + } + return self + } + + @discardableResult + public func assertNoTransition( + when states: S..., + on event: E, + fail: (String) -> Void = XCTFail + ) async -> Self { + for state in states { + if let receivedState = await self.stateMachine.reduce(when: state, on: event) { + fail( + """ + The assertion failed for state \(state) and event \(event): + expected no new state, + received new state: \(receivedState) + """ + ) + return self + } + } + return self + } + + @discardableResult + public func assert( + when states: S..., + execute expectedOutput: O, + fail: (String) -> Void = XCTFail + ) -> Self { + for state in states { + let receivedOutput = self.stateMachine.output(for: state) + guard receivedOutput == expectedOutput else { + fail( + """ + The assertion failed for state \(state): + expected output: \(expectedOutput), + received output: \(String(describing: receivedOutput)) + """ + ) + return self + } + } + return self + } + + @discardableResult + public func assertNoOutput( + when states: S..., + fail: (String) -> Void = XCTFail + ) -> Self { + for state in states { + if let receivedOutput = self.stateMachine.output(for: state) { + fail( + """ + The assertion failed for state \(state): + expected no output, + received output: \(receivedOutput) + """ + ) + return self + } + } + return self + } +} +#endif diff --git a/Tests/AsyncStateMachineSequenceTests.swift b/Tests/AsyncStateMachineSequenceTests.swift new file mode 100644 index 0000000..2f4d8e7 --- /dev/null +++ b/Tests/AsyncStateMachineSequenceTests.swift @@ -0,0 +1,385 @@ +@testable import AsyncStateMachine +import XCTest + +enum State: DSLCompatible, Equatable { + case s1(value: String) + case s2(value: String) + case s3(value: String) + case s4(value: String) + case s5(value: String) + case s6(value: String) + case s7(value: String) + case s8(value: String) + case s9(value: String) + case s10(value: String) + case s11(value: String) + case s12(value: String) + + var value: String { + switch self { + case .s1(let value): return value + case .s2(let value): return value + case .s3(let value): return value + case .s4(let value): return value + case .s5(let value): return value + case .s6(let value): return value + case .s7(let value): return value + case .s8(let value): return value + case .s9(let value): return value + case .s10(let value): return value + case .s11(let value): return value + case .s12(let value): return value + } + } +} + +enum Event: DSLCompatible, Equatable { + case e1(value: String) + case e2(value: String) + case e3(value: String) + case e4(value: String) + case e5(value: String) + case e6(value: String) + case e7(value: String) + case e8(value: String) + case e9(value: String) + case e10(value: String) + case e11(value: String) + case e12(value: String) + + var value: String { + switch self { + case .e1(let value): return value + case .e2(let value): return value + case .e3(let value): return value + case .e4(let value): return value + case .e5(let value): return value + case .e6(let value): return value + case .e7(let value): return value + case .e8(let value): return value + case .e9(let value): return value + case .e10(let value): return value + case .e11(let value): return value + case .e12(let value): return value + } + } +} + +enum Output: DSLCompatible, Equatable { + case o1(value: String) + case o2(value: String) + case o3(value: String) +} + +let stateMachine = StateMachine(initial: State.s1(value: "s1")) { + When(states: OneOf { + State.s1(value:) + State.s2(value:) + State.s3(value:) + State.s4(value:) + State.s5(value:) + State.s6(value:) + State.s7(value:) + State.s8(value:) + State.s9(value:) + State.s10(value:) + State.s11(value:) + State.s12(value:) + }) { state in + Execute(output: Output.o1(value: state.value)) + } transitions: { state in + On(events: OneOf { + Event.e1(value:) + Event.e2(value:) + Event.e3(value:) + Event.e4(value:) + Event.e5(value:) + Event.e6(value:) + Event.e7(value:) + Event.e8(value:) + Event.e9(value:) + Event.e10(value:) + Event.e11(value:) + Event.e12(value:) + }) { _ in + Guard(predicate: !state.value.isEmpty) + } transition: { event in + Transition(to: State.s2(value: state.value + event.value)) + } + } +} + +final class AsyncStateMachineSequenceTests: XCTestCase { +// func testPerformance() async { +// measure { +// let exp = expectation(description: "task") +// let task = Task { +// for s in (1...12) { +// for e in (1...12) { +// _ = await stateMachine.reduce(when: State.s1(value: "s\(s)"), on: Event.e1(value: "e\(e)")) +// } +// } +// exp.fulfill() +// } +// wait(for: [exp], timeout: 10.0) +// task.cancel() +// } +// } + + func test_states_and_events_match_the_expected_flow() async { + let allReceived = expectation(description: "Events and States have been received") + allReceived.expectedFulfillmentCount = 3 + + // Given + let stateMachine = StateMachine(initial: State.s1(value: "value")) { + When(state: State.s1(value:)) { stateValue in + Execute(output: Output.o1(value: stateValue)) + } transitions: { stateValue in + + On(event: Event.e1(value:)) { eventValue in + Guard(predicate: stateValue.isEmpty || eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s2(value: "new value")) + } + + On(event: Event.e2(value:)) { eventValue in + Guard(predicate: !stateValue.isEmpty && !eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s3(value: eventValue)) + } + } + + When(state: State.s2(value:)) { stateValue in + Execute(output: Output.o2(value: stateValue)) + } transitions: { stateValue in + On(event: Event.e3(value:)) { eventValue in + Guard(predicate: stateValue.isEmpty || eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s4(value: eventValue)) + } + + On(event: Event.e4(value:)) { eventValue in + Guard(predicate: !stateValue.isEmpty && !eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s5(value: eventValue)) + } + } + + When(states: OneOf { + State.s4(value:) + State.s5(value:) + }) { state in + Execute(output: Output.o3(value: state.value)) + } transitions: { state in + On(event: Event.e5(value:)) { eventValue in + Guard(predicate: state.value.isEmpty || eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s6(value: eventValue)) + } + + On(event: Event.e6(value:)) { eventValue in + Guard(predicate: !state.value.isEmpty && !eventValue.isEmpty) + } transition: { eventValue in + Transition(to: State.s7(value: eventValue)) + } + } + } + + let receivedStatesInSequence = ManagedCriticalState<[State]>([]) + let receivedStatesInMiddleware = ManagedCriticalState<[State]>([]) + let receivedEventsInMiddleware = ManagedCriticalState<[Event]>([]) + + let runtime = Runtime() + .map(output: Output.o1(value:), to: { _ in Event.e1(value: "") }) + .map(output: Output.o2(value:), to: { outputValue in Event.e4(value: outputValue) }) + .map(output: Output.o3(value:), to: { outputValue in Event.e6(value: outputValue) }) + .register(middleware: { (state: State) in + receivedStatesInMiddleware.withCriticalRegion { $0.append(state) } + if state == State.s7(value: "new value") { + allReceived.fulfill() + } + }) + .register(middleware: { (event: Event) in + receivedEventsInMiddleware.withCriticalRegion { $0.append(event) } + if event == Event.e6(value: "new value") { + allReceived.fulfill() + } + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + + // When + Task { + for await state in sequence { + receivedStatesInSequence.withCriticalRegion { $0.append(state) } + if state == State.s7(value: "new value") { + allReceived.fulfill() + } + } + } + + wait(for: [allReceived], timeout: 5.0) + + // Then + let expectedStates = [ + State.s1(value: "value"), + State.s2(value: "new value"), + State.s5(value: "new value"), + State.s7(value: "new value") + ] + + let expectedEvents = [ + Event.e1(value: ""), + Event.e4(value: "new value"), + Event.e6(value: "new value"), + ] + + XCTAssertEqual(receivedStatesInSequence.criticalState, expectedStates) + XCTAssertEqual(receivedStatesInMiddleware.criticalState, expectedStates) + XCTAssertEqual(receivedEventsInMiddleware.criticalState, expectedEvents) + } + + func test_channel_connects_sender_and_receiver() { + // Given + let channel = Channel() + + let stateMachine1 = StateMachine(initial: State.s1(value: "value")) { + When(state: State.s1(value:)) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1(value:)) { _ in + Transition(to: State.s2(value: "value2")) + } + } + } + + let receivedValue = ManagedCriticalState(nil) + + let runtime1 = Runtime() + .connectAsSender(to: channel, when: State.s2(value:), send: { value in + receivedValue.apply(criticalState: value) + return Event.e1(value: value) + }) + + let stateMachine2 = StateMachine(initial: State.s1(value: "value")) { + When(state: State.s1(value:)) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1(value:)) { _ in + Transition(to: State.s2(value: "value2")) + } + } + } + + let runtime2 = Runtime() + .connectAsReceiver(to: channel) + + let asyncStateMachineSequence1 = AsyncStateMachineSequence(stateMachine: stateMachine1, runtime: runtime1) + let asyncStateMachineSequence2 = AsyncStateMachineSequence(stateMachine: stateMachine2, runtime: runtime2) + + let firstStatesHaveBeenEmitted = expectation(description: "The first states have been emitted") + firstStatesHaveBeenEmitted.expectedFulfillmentCount = 2 + + let secondStatesHaveBeenEmitted = expectation(description: "The second states have been emitted") + secondStatesHaveBeenEmitted.expectedFulfillmentCount = 2 + + Task { + for await state in asyncStateMachineSequence1 { + if state == .s1(value: "value") { + firstStatesHaveBeenEmitted.fulfill() + } + + if state == .s2(value: "value2") { + secondStatesHaveBeenEmitted.fulfill() + } + } + } + + Task { + for await state in asyncStateMachineSequence2 { + if state == .s1(value: "value") { + firstStatesHaveBeenEmitted.fulfill() + } + + if state == .s2(value: "value2") { + secondStatesHaveBeenEmitted.fulfill() + } + } + } + + wait(for: [firstStatesHaveBeenEmitted], timeout: 1.0) + + // When + asyncStateMachineSequence1.send(.e1(value: "value")) + + // Then + wait(for: [secondStatesHaveBeenEmitted], timeout: 1.0) + + XCTAssertEqual(receivedValue.criticalState, "value2") + } + + func test_deinit_finishes_the_eventChannel() { + let asyncStateMachineSequenceIsDeinit = expectation(description: "The AasyncStateMachineSequence has been deinit") + let eventChannelIsFinished = expectation(description: "The underlying eventChannel has been finished") + + let stateMachine = StateMachine(initial: State.s1(value: "value")) {} + let runtime = Runtime() + + // Given + var sut: AsyncStateMachineSequence? = AsyncStateMachineSequence( + stateMachine: stateMachine, + runtime: runtime, + onDeinit: { asyncStateMachineSequenceIsDeinit.fulfill() } + ) + + let eventChannel = sut!.eventChannel + + Task { + for await _ in eventChannel {} + eventChannelIsFinished.fulfill() + } + + // When + sut = nil + + // Then + wait(for: [asyncStateMachineSequenceIsDeinit, eventChannelIsFinished], timeout: 1.0) + XCTAssertNil(sut) + } + + func test_deinit_unregisters_asyncStateMachineSequence_from_channel_receivers() { + let asyncStateMachineSequenceIsDeinit = expectation(description: "on deinit") + + let channel1 = Channel() + let channel2 = Channel() + + // Given + let stateMachine = StateMachine(initial: State.s1(value: "value")) {} + + let runtime = Runtime() + .connectAsReceiver(to: channel1) + .connectAsReceiver(to: channel2) + + var sut: AsyncStateMachineSequence? = AsyncStateMachineSequence( + stateMachine: stateMachine, + runtime: runtime, + onDeinit: { asyncStateMachineSequenceIsDeinit.fulfill() } + ) + + runtime.channelReceivers.forEach { receiver in + XCTAssertNotNil(receiver.receiver.criticalState) + } + + // When + sut = nil + + // Then + wait(for: [asyncStateMachineSequenceIsDeinit], timeout: 1.0) + + runtime.channelReceivers.forEach { receiver in + XCTAssertNil(receiver.receiver.criticalState) + } + + XCTAssertNil(sut) + } +} diff --git a/Tests/EngineTests.swift b/Tests/EngineTests.swift new file mode 100644 index 0000000..9ff544f --- /dev/null +++ b/Tests/EngineTests.swift @@ -0,0 +1,687 @@ +// +// EngineTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class EngineTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + case s2(value: String) + case s3 + case s4(value: Int) + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2(value: String) + case e3 + case e4 + } + + enum Output: DSLCompatible, Equatable { + case o1 + case o2 + } + + func test_init_sets_all_the_properties_when_called_with_functions() async throws { + let resolvedOutputIsCalled = ManagedCriticalState(false) + let computeNextStateIsCalled = ManagedCriticalState(false) + let resolveSideEffectIsCalled = ManagedCriticalState(false) + let eventMiddlewareIsCalled = ManagedCriticalState(false) + let stateMiddlewareIsCalled = ManagedCriticalState(false) + + let eventMiddleware = Middleware(execute: { _ in eventMiddlewareIsCalled.apply(criticalState: true); return false }, priority: nil) + let stateMiddleware = Middleware(execute: { _ in stateMiddlewareIsCalled.apply(criticalState: true); return false }, priority: nil) + + // Given + let sut = Engine( + resolveOutput: { _ in resolvedOutputIsCalled.apply(criticalState: true); return nil }, + computeNextState: { _, _ in computeNextStateIsCalled.apply(criticalState: true); return nil}, + resolveSideEffect: { _ in resolveSideEffectIsCalled.apply(criticalState: true); return nil }, + eventMiddlewares: [eventMiddleware], + stateMiddlewares: [stateMiddleware] + ) + + // When + _ = await sut.resolveOutput(State.s1) + // Then + XCTAssertTrue(resolvedOutputIsCalled.criticalState) + + // When + _ = await sut.computeNextState(State.s1, Event.e1) + XCTAssertTrue(computeNextStateIsCalled.criticalState) + + // When + _ = await sut.resolveSideEffect(Output.o1) + // Then + XCTAssertTrue(resolveSideEffectIsCalled.criticalState) + + // When + let stateMiddlewaresCount = await sut.stateMiddlewares.count + // Then + XCTAssertEqual(stateMiddlewaresCount, 1) + + // When + _ = await sut.stateMiddlewares.values.first?.execute(State.s1) + // Then + XCTAssertTrue(stateMiddlewareIsCalled.criticalState) + + // When + let eventMiddlewaresCount = await sut.eventMiddlewares.count + // Then + XCTAssertEqual(eventMiddlewaresCount, 1) + + // When + _ = await sut.eventMiddlewares.values.first?.execute(Event.e1) + // Then + XCTAssertTrue(eventMiddlewareIsCalled.criticalState) + + // When + let tasksInProgressCount = await sut.tasksInProgress.count + // Then + XCTAssertEqual(tasksInProgressCount, 0) + } + + func test_register_adds_a_task_in_progress_and_removes_when_finished() async { + let taskInProgressHasBeenAdded = expectation(description: "The task has been added to the tasks in progress") + + // Given + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + let task = Task { + wait(for: [taskInProgressHasBeenAdded], timeout: 10.0) + } + + // When + let removeTask = await sut.register(taskInProgress: task, cancelOn: { _ in true }) + + // Then + let tasksInProgressCountBefore = await sut.tasksInProgress.count + XCTAssertEqual(tasksInProgressCountBefore, 1) + + taskInProgressHasBeenAdded.fulfill() + + await removeTask.value + + let tasksInProgressCountAfter = await sut.tasksInProgress.count + XCTAssertEqual(tasksInProgressCountAfter, 0) + } + + func test_register_adds_a_task_in_progress_that_can_cancel() async { + let taskHasStarted = expectation(description: "The task has started") + + let receivedState = ManagedCriticalState(nil) + + // Given + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + let task = Task.forEver { + taskHasStarted.fulfill() + } onCancel: { + } + + + // When + await sut.register(taskInProgress: task, cancelOn: { state in + receivedState.apply(criticalState: state) + return true + }) + + wait(for: [taskHasStarted], timeout: 10.0) + + // Then + let tasksInProgress = await sut.tasksInProgress.values + let receivedShouldCancel = tasksInProgress.first?.cancellationPredicate(State.s1) + + XCTAssertTrue(receivedShouldCancel!) + XCTAssertEqual(receivedState.criticalState, State.s1) + + task.cancel() + } + + func test_cancelTaskInProgress_cancels_the_expected_task_when_called() async { + let tasksHaveStarted = expectation(description: "Tasks have started") + tasksHaveStarted.expectedFulfillmentCount = 2 + + let taskAHasBeenCancelled = expectation(description: "The task A has been cancelled") + let taskBHasBeenCancelled = ManagedCriticalState(false) + + // Given + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + let taskToCancel = Task.forEver { + tasksHaveStarted.fulfill() + } onCancel: { + taskAHasBeenCancelled.fulfill() + } + + let taskToRemain = Task.forEver { + tasksHaveStarted.fulfill() + } onCancel: { + taskBHasBeenCancelled.apply(criticalState: true) + } + + await sut.register(taskInProgress: taskToCancel, cancelOn: { state in state == .s1 }) + await sut.register(taskInProgress: taskToRemain, cancelOn: { state in state == .s3 }) + + wait(for: [tasksHaveStarted], timeout: 10.0) + + // When + await sut.cancelTasksInProgress(for: State.s1) + + wait(for: [taskAHasBeenCancelled], timeout: 0.5) + XCTAssertFalse(taskBHasBeenCancelled.criticalState) + + let tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 1) + + taskToRemain.cancel() + } + + func test_cancelTasksInProgress_cancels_all_tasks_when_called() async { + let tasksHaveBeenCancelled = expectation(description: "The tasks have been cancelled") + tasksHaveBeenCancelled.expectedFulfillmentCount = 2 + + // Given + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + let taskToCancelA = Task.forEver { + } onCancel: { + tasksHaveBeenCancelled.fulfill() + } + + let taskToCancelB = Task.forEver { + } onCancel: { + tasksHaveBeenCancelled.fulfill() + } + + await sut.register(taskInProgress: taskToCancelA, cancelOn: { state in state == .s1 }) + await sut.register(taskInProgress: taskToCancelB, cancelOn: { state in state == .s3 }) + + // When + await sut.cancelTasksInProgress() + + wait(for: [tasksHaveBeenCancelled], timeout: 10.0) + + let tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_process_executes_event_middleware_when_called() async { + let middlewareACanFinish = expectation(description: "The middleware A can finish") + let middlewareBCanFinish = expectation(description: "The middleware B can finish") + + let middlewaresHaveBeenCalled = expectation(description: "The middlewares have been called") + middlewaresHaveBeenCalled.expectedFulfillmentCount = 2 + + let receivedEventInMiddlewareA = ManagedCriticalState(nil) + let receivedEventInMiddlewareB = ManagedCriticalState(nil) + + let receivedTaskPriorityInMiddlewareA = ManagedCriticalState(nil) + let receivedTaskPriorityInMiddlewareB = ManagedCriticalState(nil) + + // Given + let middlewareA = Middleware(execute: { (event: Event) in + receivedEventInMiddlewareA.apply(criticalState: event) + receivedTaskPriorityInMiddlewareA.apply(criticalState: Task.currentPriority) + + middlewaresHaveBeenCalled.fulfill() + + self.wait(for: [middlewareACanFinish], timeout: 10.0) + return false + }, priority: .utility) + + let middlewareB = Middleware(execute: { (event: Event) in + receivedEventInMiddlewareB.apply(criticalState: event) + receivedTaskPriorityInMiddlewareB.apply(criticalState: Task.currentPriority) + + middlewaresHaveBeenCalled.fulfill() + + self.wait(for: [middlewareBCanFinish], timeout: 10.0) + return true + }, priority: .high) + + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [middlewareA, middlewareB], + stateMiddlewares: [] + ) + + // When + let removeTasksInProgressTasks = await sut.process(event: Event.e1) + + wait(for: [middlewaresHaveBeenCalled], timeout: 10.0) + + // Then + XCTAssertEqual(receivedEventInMiddlewareA.criticalState, Event.e1) + XCTAssertEqual(receivedEventInMiddlewareB.criticalState, Event.e1) + + XCTAssertTrue(receivedTaskPriorityInMiddlewareA.criticalState.unsafelyUnwrapped >= .utility) + XCTAssertTrue(receivedTaskPriorityInMiddlewareB.criticalState.unsafelyUnwrapped >= .high) + + var eventMiddlewares = await sut.eventMiddlewares.values + XCTAssertEqual(eventMiddlewares.count, 2) + + var tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 2) + + middlewareACanFinish.fulfill() + middlewareBCanFinish.fulfill() + + // waiting for all tasks in progress (middlewares) to finish + for task in removeTasksInProgressTasks { + await task.value + } + + eventMiddlewares = await sut.eventMiddlewares.values + XCTAssertEqual(eventMiddlewares.count, 1) + + tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_process_registers_non_cancellable_tasks_on_specific_state_when_called() async { + let middlewaresHaveStarted = expectation(description: "The middlewares have started") + middlewaresHaveStarted.expectedFulfillmentCount = 2 + + let middlewareAHasBeenCancelled = ManagedCriticalState(false) + let middlewareBHasBeenCancelled = ManagedCriticalState(false) + + // Given + let middlewareA = Middleware(execute: { (state: State) in + await Task.forEver { + middlewaresHaveStarted.fulfill() + } onCancel: { + middlewareAHasBeenCancelled.apply(criticalState: true) + }.value + + return false + }, priority: nil) + + let middlewareB = Middleware(execute: { (state: State) in + await Task.forEver { + middlewaresHaveStarted.fulfill() + } onCancel: { + middlewareBHasBeenCancelled.apply(criticalState: true) + }.value + + return false + }, priority: nil) + + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + // When + await sut.process(middlewares: [(0, middlewareA), (1, middlewareB)], using: State.s1, removeMiddleware: { _ in }) + + wait(for: [middlewaresHaveStarted], timeout: 10.0) + + // Then + var tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 2) + + await sut.cancelTasksInProgress(for: State.s1) + + tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 2) + + await sut.cancelTasksInProgress() + + tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_process_state_cancels_all_eligible_tasks_when_called() async { + let tasksAreStarted = expectation(description: "All the tasks are started") + tasksAreStarted.expectedFulfillmentCount = 3 + + let tasksAreCancelled = expectation(description: "Elligible tasks are cancelled") + tasksAreCancelled.expectedFulfillmentCount = 2 + + let notElligibleTaskHasBeenCancelled = ManagedCriticalState(false) + + // Given + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + let taskToCancelA = Task.forEver { + tasksAreStarted.fulfill() + } onCancel: { + tasksAreCancelled.fulfill() + } + + let taskToNotCancel = Task.forEver { + tasksAreStarted.fulfill() + } onCancel: { + notElligibleTaskHasBeenCancelled.apply(criticalState: true) + } + + let taskToCancelB = Task.forEver { + tasksAreStarted.fulfill() + } onCancel: { + tasksAreCancelled.fulfill() + } + + await sut.register(taskInProgress: taskToCancelA, cancelOn: { $0 == .s3 }) + await sut.register(taskInProgress: taskToNotCancel, cancelOn: { $0.matches(State.s2(value:)) }) + await sut.register(taskInProgress: taskToCancelB, cancelOn: { $0 == .s3 }) + + wait(for: [tasksAreStarted], timeout: 10.0) + + // When + await sut.process(state: State.s3, sendBackEvent: nil) + + // Then + wait(for: [tasksAreCancelled], timeout: 10.0) + XCTAssertFalse(notElligibleTaskHasBeenCancelled.criticalState) + + let tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 1) + + taskToNotCancel.cancel() + } + + func test_process_state_executes_state_middleware_when_called() async { + let middlewareACanFinish = expectation(description: "The middleware A can finish") + let middlewareBCanFinish = expectation(description: "The middleware B can finish") + + let middlewaresHaveBeenCalled = expectation(description: "The middlewares have been called") + middlewaresHaveBeenCalled.expectedFulfillmentCount = 2 + + let receivedStateInMiddlewareA = ManagedCriticalState(nil) + let receivedStateInMiddlewareB = ManagedCriticalState(nil) + + let receivedTaskPriorityInMiddlewareA = ManagedCriticalState(nil) + let receivedTaskPriorityInMiddlewareB = ManagedCriticalState(nil) + + // Given + let middlewareA = Middleware(execute: { (state: State) in + receivedStateInMiddlewareA.apply(criticalState: state) + receivedTaskPriorityInMiddlewareA.apply(criticalState: Task.currentPriority) + + middlewaresHaveBeenCalled.fulfill() + + self.wait(for: [middlewareACanFinish], timeout: 10.0) + return false + }, priority: .utility) + + let middlewareB = Middleware(execute: { (state: State) in + receivedStateInMiddlewareB.apply(criticalState: state) + receivedTaskPriorityInMiddlewareB.apply(criticalState: Task.currentPriority) + + middlewaresHaveBeenCalled.fulfill() + + self.wait(for: [middlewareBCanFinish], timeout: 10.0) + return true + }, priority: .high) + + let sut = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [middlewareA, middlewareB] + ) + + // When + let removeTasksInProgressTasks = await sut.process(state: State.s1, sendBackEvent: nil) + + wait(for: [middlewaresHaveBeenCalled], timeout: 10.0) + + // Then + XCTAssertEqual(receivedStateInMiddlewareA.criticalState, State.s1) + XCTAssertEqual(receivedStateInMiddlewareB.criticalState, State.s1) + + XCTAssertTrue(receivedTaskPriorityInMiddlewareA.criticalState.unsafelyUnwrapped >= .utility) + XCTAssertTrue(receivedTaskPriorityInMiddlewareB.criticalState.unsafelyUnwrapped >= .high) + + var stateMiddlewares = await sut.stateMiddlewares.values + XCTAssertEqual(stateMiddlewares.count, 2) + + var tasksInProgress = await sut.tasksInProgress.values + XCTAssertEqual(tasksInProgress.count, 2) + + middlewareACanFinish.fulfill() + middlewareBCanFinish.fulfill() + + // waiting for all tasks in progress (middlewares) to finish + for task in removeTasksInProgressTasks { + await task.value + } + + stateMiddlewares = await sut.stateMiddlewares.values + XCTAssertEqual(stateMiddlewares.count, 1) + + tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_process_state_execute_side_effects_when_called() async { + let sideEffectIsCalled = expectation(description: "Side effect is called") + + let resolveSideEffectIsCalled = ManagedCriticalState(false) + let receivedEvent = ManagedCriticalState(nil) + + // Given + let sut = Engine( + resolveOutput: { _ in Output.o1 }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in + resolveSideEffectIsCalled.apply(criticalState: true) + return SideEffect( + predicate: { _ in true}, + execute: { _ in + AsyncJustSequence { + return Event.e1 + }.eraseToAnyAsyncSequence() + }, + priority: nil, + strategy: .continueWhenAnyState + ) + }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + // When + await sut.process(state: State.s1, sendBackEvent: { event in + receivedEvent.apply(criticalState: event) + sideEffectIsCalled.fulfill() + }) + + // Then + wait(for: [sideEffectIsCalled], timeout: 10.0) + + XCTAssertTrue(resolveSideEffectIsCalled.criticalState) + XCTAssertEqual(receivedEvent.criticalState, Event.e1) + } + + func test_executeSideEffect_execute_side_effect_when_called() async { + let eventIsSent = expectation(description: "Event from side effect is sent") + + let receivedStateInResolveOutput = ManagedCriticalState(nil) + let receivedOutputInResolveSideEffect = ManagedCriticalState(nil) + let receivedOutputInExecute = ManagedCriticalState(nil) + let receivedEventInSendEvent = ManagedCriticalState(nil) + let receivedPriority = ManagedCriticalState(nil) + + // Given + let sut = Engine( + resolveOutput: { state in + receivedStateInResolveOutput.apply(criticalState: state) + return Output.o1 + }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { output in + receivedOutputInResolveSideEffect.apply(criticalState: output) + return SideEffect( + predicate: { _ in true }, + execute: { output in + receivedOutputInExecute.apply(criticalState: output) + return AsyncJustSequence { + receivedPriority.apply(criticalState: Task.currentPriority) + return Event.e1 + }.eraseToAnyAsyncSequence() + }, + priority: .high, + strategy: .continueWhenAnyState + ) + }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + // When + let cleaningTask = await sut.executeSideEffect(for: State.s3, sendBackEvent: { event in + receivedEventInSendEvent.apply(criticalState: event) + eventIsSent.fulfill() + }) + + wait(for: [eventIsSent], timeout: 10.0) + + // Then + XCTAssertEqual(receivedStateInResolveOutput.criticalState, .s3) + XCTAssertEqual(receivedOutputInResolveSideEffect.criticalState, .o1) + XCTAssertEqual(receivedOutputInExecute.criticalState, .o1) + XCTAssertEqual(receivedEventInSendEvent.criticalState, .e1) + XCTAssertTrue(receivedPriority.criticalState.unsafelyUnwrapped >= .high) + + await cleaningTask?.value + + let tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_executeSideEffect_execute_side_effect_when_side_effect_fails() async { + // Given + let sut = Engine( + resolveOutput: { _ in Output.o1 }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in + return SideEffect( + predicate: { _ in true }, + execute: { _ in AsyncThrowingSequence().eraseToAnyAsyncSequence() }, + priority: nil, + strategy: .continueWhenAnyState + ) + }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + // When + let cleaningTask = await sut.executeSideEffect(for: State.s3, sendBackEvent: nil) + + // Then + await cleaningTask?.value + + let tasksInProgress = await sut.tasksInProgress.values + XCTAssertTrue(tasksInProgress.isEmpty) + } + + func test_register_adds_onTheFly_state_middleware() async { + let receivedState = ManagedCriticalState(nil) + + // Given + let sut = Engine( + resolveOutput: { _ in Output.o1 }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [] + ) + + // When + await sut.register { state in + receivedState.apply(criticalState: state) + return false + } + + // Then + let stateMiddlewares = await sut.stateMiddlewares + XCTAssertEqual(stateMiddlewares.count, 1) + + let shouldBeRemoved = await stateMiddlewares.values.first?.execute(State.s2(value: "value")) + + XCTAssertFalse(shouldBeRemoved.unsafelyUnwrapped) + XCTAssertEqual(receivedState.criticalState, State.s2(value: "value")) + } + + func test_deinit_cancels_all_tasks_when_called () async { + let engineHasBeenDeinit = expectation(description: "The engine has been deinit") + + let tasksHaveBeenCancelled = expectation(description: "The tasks have been cancelled") + tasksHaveBeenCancelled.expectedFulfillmentCount = 2 + + // Given + var sut: Engine? = Engine( + resolveOutput: { _ in nil }, + computeNextState: { _, _ in nil}, + resolveSideEffect: { _ in nil }, + eventMiddlewares: [], + stateMiddlewares: [], + onDeinit: { engineHasBeenDeinit.fulfill() } + ) + + let taskToCancelA = Task.forEver { + } onCancel: { + tasksHaveBeenCancelled.fulfill() + } + + let taskToCancelB = Task.forEver { + } onCancel: { + tasksHaveBeenCancelled.fulfill() + } + + await sut?.register(taskInProgress: taskToCancelA, cancelOn: { state in state == .s1 }) + await sut?.register(taskInProgress: taskToCancelB, cancelOn: { state in state == .s3 }) + + // When + sut = nil + + wait(for: [engineHasBeenDeinit, tasksHaveBeenCancelled], timeout: 1.0) + + XCTAssertNil(sut) + } +} diff --git a/Tests/Runtime/ChannelTests.swift b/Tests/Runtime/ChannelTests.swift new file mode 100644 index 0000000..695179a --- /dev/null +++ b/Tests/Runtime/ChannelTests.swift @@ -0,0 +1,53 @@ +// +// ChannelTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class ChannelTests: XCTestCase { + enum Event: DSLCompatible, Equatable { + case e1 + } + + func test_register_sets_the_receiver() async { + let expectedEvent = Event.e1 + let receivedEvent = ManagedCriticalState(nil) + + let spyReceiver: @Sendable (Event) -> Void = { event in + receivedEvent.apply(criticalState: event) + } + + // Given + let sut = Channel() + sut.register(receiver: spyReceiver) + + // When + sut.receiver.criticalState?(expectedEvent) + + // Then + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } + + func test_push_calls_the_receiver() async { + let expectedEvent = Event.e1 + let receivedEvent = ManagedCriticalState(nil) + + let spyReceiver: @Sendable (Event) -> Void = { event in + receivedEvent.apply(criticalState: event) + } + + // Given + let sut = Channel() + sut.register(receiver: spyReceiver) + + // When + sut.push(expectedEvent) + + // Then + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } +} diff --git a/Tests/Runtime/ExecutionStrategyTests.swift b/Tests/Runtime/ExecutionStrategyTests.swift new file mode 100644 index 0000000..ec89ce7 --- /dev/null +++ b/Tests/Runtime/ExecutionStrategyTests.swift @@ -0,0 +1,62 @@ +// +// ExecutionStrategyTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class ExecutionStrategyTests: XCTestCase { + enum State: DSLCompatible { + case s1 + case s2(value: String) + } + + func test_predicate_matches_state_when_cancel() { + // Given + let sut = ExecutionStrategy.cancel(when: State.s1) + + // When expected state + // Then + XCTAssertTrue(sut.predicate(.s1)) + + // When unexpected state + // Then + XCTAssertFalse(sut.predicate(.s2(value: ""))) + } + + func test_predicate_matches_state_when_cancel_with_associated_value() { + // Given + let sut = ExecutionStrategy.cancel(when: State.s2(value:)) + + // When expected state + // Then + XCTAssertTrue(sut.predicate(.s2(value: ""))) + + // When unexpected state + // Then + XCTAssertFalse(sut.predicate(.s1)) + } + + func test_predicate_matches_any_state_when_cancel_any_state() { + // Given + let sut = ExecutionStrategy.cancelWhenAnyState + + // When any state + // Then + XCTAssertTrue(sut.predicate(.s1)) + XCTAssertTrue(sut.predicate(.s2(value: ""))) + } + + func test_predicate_matches_any_state_when_continue_any_state() { + // Given + let sut = ExecutionStrategy.continueWhenAnyState + + // When any state + // Then + XCTAssertFalse(sut.predicate(.s1)) + XCTAssertFalse(sut.predicate(.s2(value: ""))) + } +} diff --git a/Tests/Runtime/RuntimeTests.swift b/Tests/Runtime/RuntimeTests.swift new file mode 100644 index 0000000..5665406 --- /dev/null +++ b/Tests/Runtime/RuntimeTests.swift @@ -0,0 +1,294 @@ +// +// RuntimeTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class RuntimeTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + case s2(value: String) + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2(value: String) + } + + enum Output: DSLCompatible, Equatable { + case o1 + case o2(value: String) + case o3(value: Int) + } + + func test_map_registers_side_effect_when_called_with_side_effect_that_returns_async_sequence() async throws { + // Given + let sut = Runtime() + .map( + output: .o1, + to: { AsyncJustSequence { Event.e1 } }, + priority: .userInitiated, + strategy: .continueWhenAnyState + ) + + // When + let sideEffect = sut.sideEffects.first! + + // Then + XCTAssertEqual(sut.sideEffects.count, 1) + XCTAssertTrue(sideEffect.predicate(Output.o1)) + XCTAssertFalse(sideEffect.predicate(Output.o2(value: ""))) + let sequence = sideEffect.execute(Output.o1)! + let receivedEvents = try await sequence.collect() + XCTAssertEqual(receivedEvents, [.e1]) + XCTAssertEqual(sideEffect.priority, .userInitiated) + XCTAssertEqual(sideEffect.strategy, .continueWhenAnyState) + } + + func test_map_registers_side_effect_when_called_with_side_effect_that_returns_event() async throws { + // Given + let sut = Runtime() + .map( + output: .o1, + to: { Event.e1 }, + priority: .userInitiated, + strategy: .continueWhenAnyState + ) + + // When + let sideEffect = sut.sideEffects.first! + + // Then + XCTAssertEqual(sut.sideEffects.count, 1) + XCTAssertTrue(sideEffect.predicate(Output.o1)) + XCTAssertFalse(sideEffect.predicate(Output.o2(value: ""))) + let sequence = sideEffect.execute(Output.o1)! + let receivedEvents = try await sequence.collect() + XCTAssertEqual(receivedEvents, [.e1]) + XCTAssertEqual(sideEffect.priority, .userInitiated) + XCTAssertEqual(sideEffect.strategy, .continueWhenAnyState) + } + + func test_map_registers_side_effect_when_called_with_side_effect_that_returns_async_sequence_and_output_with_associated_type() async throws { + let receivedValue = ManagedCriticalState(nil) + + // Given + let sut = Runtime() + .map( + output: Output.o2(value:), + to: { value -> AsyncJustSequence in + receivedValue.apply(criticalState: value) + return AsyncJustSequence { Event.e1 } + }, + priority: .userInitiated, + strategy: .continueWhenAnyState + ) + + // When + let sideEffect = sut.sideEffects.first! + + // Then + XCTAssertEqual(sut.sideEffects.count, 1) + XCTAssertFalse(sideEffect.predicate(Output.o1)) + XCTAssertTrue(sideEffect.predicate(Output.o2(value: "value"))) + let sequence = sideEffect.execute(Output.o2(value: "value"))! + let receivedEvents = try await sequence.collect() + XCTAssertEqual(receivedEvents, [.e1]) + XCTAssertEqual(receivedValue.criticalState, "value") + XCTAssertEqual(sideEffect.priority, .userInitiated) + XCTAssertEqual(sideEffect.strategy, .continueWhenAnyState) + } + + func test_map_registers_side_effect_when_called_with_side_effect_that_returns_event_and_output_with_associated_type() async throws { + let receivedValue = ManagedCriticalState(nil) + + // Given + let sut = Runtime() + .map( + output: Output.o2(value:), + to: { value in + receivedValue.apply(criticalState: value) + return Event.e1 + }, + priority: .userInitiated, + strategy: .continueWhenAnyState + ) + + // When + let sideEffect = sut.sideEffects.first! + + // Then + XCTAssertEqual(sut.sideEffects.count, 1) + XCTAssertFalse(sideEffect.predicate(Output.o1)) + XCTAssertTrue(sideEffect.predicate(Output.o2(value: "value"))) + let sequence = sideEffect.execute(Output.o2(value: "value"))! + let receivedEvents = try await sequence.collect() + XCTAssertEqual(receivedEvents, [.e1]) + XCTAssertEqual(receivedValue.criticalState, "value") + XCTAssertEqual(sideEffect.priority, .userInitiated) + XCTAssertEqual(sideEffect.strategy, .continueWhenAnyState) + } + + func test_map_registers_side_effect_with_nil_async_sequence_when_output_associated_type_does_not_match() async throws { + // Given + let sut = Runtime() + .map( + output: Output.o2(value:), + to: { _ in Event.e1 }, + priority: .userInitiated, + strategy: .continueWhenAnyState + ) + + // When + let sideEffect = sut.sideEffects.first! + + // Then + let sequence = sideEffect.execute(Output.o3(value: 3)) + XCTAssertNil(sequence) + } + + func test_register_adds_middleware_for_state_when_called() async { + let receivedState = ManagedCriticalState(nil) + + // Given + let sut = Runtime() + .register( + middleware: { receivedState.apply(criticalState: $0) }, + priority: .userInitiated + ) + + // When + let middleware = sut.stateMiddlewares.first! + + // Then + let shouldRemoveAfterExecution = await middleware.execute(State.s2(value: "value")) + XCTAssertEqual(receivedState.criticalState, State.s2(value: "value")) + XCTAssertFalse(shouldRemoveAfterExecution) + XCTAssertEqual(middleware.priority, .userInitiated) + } + + func test_register_adds_middleware_for_event_when_called() async { + let receivedEvent = ManagedCriticalState(nil) + + // Given + let sut = Runtime() + .register( + middleware: { receivedEvent.apply(criticalState: $0) }, + priority: .userInitiated + ) + + // When + let middleware = sut.eventMiddlewares.first! + + // Then + let shouldRemoveAfterExecution = await middleware.execute(Event.e2(value: "value")) + XCTAssertEqual(receivedEvent.criticalState, Event.e2(value: "value")) + XCTAssertFalse(shouldRemoveAfterExecution) + XCTAssertEqual(middleware.priority, .userInitiated) + } + + func test_connectAsReceiver_registers_a_channelReceiver_when_called() { + let receivedEvent = ManagedCriticalState(nil) + + let channel = Channel() + + // Given + let sut = Runtime() + .connectAsReceiver(to: channel) + + sut.channelReceivers.forEach { $0.update(receiver: { receivedEvent.apply(criticalState: $0) }) } + + // When + channel.push(Event.e2(value: "value")) + + // Then + XCTAssertEqual(receivedEvent.criticalState, Event.e2(value: "value")) + } + + func test_connectAsSender_registers_state_middleware_that_pushes_to_channel_when_called() async { + let receivedEvent = ManagedCriticalState(nil) + + let channel = Channel() + channel.register { event in + receivedEvent.apply(criticalState: event) + } + + // Given + let sut = Runtime() + .connectAsSender(to: channel, when: State.s1, send: Event.e1) + + let middleware = sut.stateMiddlewares.first! + + // When + _ = await middleware.execute(State.s2(value: "")) + + // Then + XCTAssertNil(receivedEvent.criticalState) + + // When + _ = await middleware.execute(State.s1) + + // Then + XCTAssertEqual(receivedEvent.criticalState, Event.e1) + } + + func test_connectAsSender_registers_state_middleware_that_pushes_to_channel_when_called_with_associated_value() async { + let receivedEvent = ManagedCriticalState(nil) + let receivedValue = ManagedCriticalState(nil) + + let channel = Channel() + channel.register { event in + receivedEvent.apply(criticalState: event) + } + + // Given + let sut = Runtime() + .connectAsSender(to: channel, when: State.s2(value:)) { value in + receivedValue.apply(criticalState: value) + return Event.e1 + } + + let middleware = sut.stateMiddlewares.first! + + // When + _ = await middleware.execute(State.s1) + + // Then + XCTAssertNil(receivedEvent.criticalState) + XCTAssertNil(receivedValue.criticalState) + + // When + _ = await middleware.execute(State.s2(value: "value")) + + // Then + XCTAssertEqual(receivedEvent.criticalState, Event.e1) + XCTAssertEqual(receivedValue.criticalState, "value") + } + + func test_sideEffectForOutput_returns_side_effect_when_mapping_exists() async throws { + // Given + let sut = Runtime() + .map( + output: .o1, + to: { Event.e1 } + ) + + // When + let receivedSideEffectWhenNoMapping = sut.sideEffects(for: Output.o2(value: "")) + + // Then + XCTAssertNil(receivedSideEffectWhenNoMapping) + + // When + let receivedSideEffectWhenMapping = sut.sideEffects(for: Output.o1) + + // Then + let asyncSequence = receivedSideEffectWhenMapping?.execute(Output.o1) + let receivedEvent = try await asyncSequence?.collect() + XCTAssertEqual(receivedEvent?.first, Event.e1) + } +} diff --git a/Tests/StateMachine/DSLCompatibleTests.swift b/Tests/StateMachine/DSLCompatibleTests.swift new file mode 100644 index 0000000..842e7ae --- /dev/null +++ b/Tests/StateMachine/DSLCompatibleTests.swift @@ -0,0 +1,122 @@ +// +// DSLCompatibleTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class DSLCompatibleTests: XCTestCase { + enum State: DSLCompatible { + case s1 + case s2(value: String) + case s3 + case s4(value: Int) + } + + struct Value: DSLCompatible {} + + func test_label_returns_label_when_enum_without_associated_value() { + let sut = State.s1 + XCTAssertEqual(sut.label, "s1") + } + + func test_label_returns_label_when_enum_with_associated_value() { + let sut = State.s2(value: "1") + XCTAssertEqual(sut.label, "s2") + } + + func test_label_returns_description_when_no_children() { + let sut = Value() + XCTAssertEqual(sut.label, String(describing: sut)) + } + + func test_decompose_return_path_when_no_associated_value() { + // Given + let sut = State.s1 + + // When + let expected = (path: ["s1"], associatedValue: ()) + let received = sut.decompose(expecting: Void.self) + + // Then + XCTAssertEqual(received?.path, expected.path) + XCTAssert(received?.associatedValue is Void) + } + + func test_matches_returns_true_when_self_has_no_associated_value_and_other_is_same_case_without_associated_value() { + let sut = State.s1 + XCTAssertTrue(sut.matches(State.s1)) + } + + func test_matches_returns_false_when_self_has_no_associated_value_and_other_is_another_case_without_associated_value() { + let sut = State.s1 + XCTAssertFalse(sut.matches(State.s3)) + } + + func test_matches_returns_false_when_self_has_no_associate_value_and_other_is_another_case_with_associated_value() { + let sut = State.s1 + XCTAssertFalse(sut.matches(State.s2(value:))) + } + + func test_matches_returns_true_when_self_has_associated_value_and_other_is_same_case_with_associated_value() { + let sut = State.s2(value: "1") + XCTAssertTrue(sut.matches(State.s2(value:))) + } + + func test_matches_returns_false_when_self_has_associated_value_and_other_is_another_case_with_associated_value() { + let sut = State.s2(value: "1") + XCTAssertFalse(sut.matches(State.s4(value:))) + } + + func test_matches_returns_false_when_self_has_associated_value_and_other_is_another_case_without_associated_value() { + let sut = State.s2(value: "1") + XCTAssertFalse(sut.matches(State.s1)) + } + + func test_associatedValue_returns_value_when_expected_type() { + let sut = State.s2(value: "value") + XCTAssertEqual(sut.associatedValue(expecting: String.self), "value") + } + + func test_associatedValue_returns_nil_when_unexpected_type() { + let sut = State.s2(value: "value") + XCTAssertNil(sut.associatedValue(expecting: Int.self)) + } + + func test_associatedValue_returns_value_when_other_matches() { + let sut = State.s2(value: "value") + XCTAssertEqual(sut.associatedValue(matching: State.s2(value:)), "value") + } + + func test_associatedValue_returns_nil_when_other_does_not_matche() { + let sut = State.s2(value: "value") + XCTAssertNil(sut.associatedValue(matching: State.s4(value:))) + } + + func test_decompose_return_path_and_value_when_associated_value() { + // Given + let sut = State.s2(value: "value") + + // When + let expected = (path: ["s2", "(value: String)", "value", "String"], associatedValue: "value") + let received = sut.decompose(expecting: String.self) + + // Then + XCTAssertEqual(received?.path, expected.path) + XCTAssertEqual(received?.associatedValue, expected.associatedValue) + } + + func test_decompose_returns_nil_when_not_an_enum() { + struct NotAnEnum: DSLCompatible {} + + // Given + let sut = NotAnEnum() + + // When + // Then + XCTAssertNil(sut.decompose(expecting: String.self)) + } +} diff --git a/Tests/StateMachine/ExecuteTests.swift b/Tests/StateMachine/ExecuteTests.swift new file mode 100644 index 0000000..aa06763 --- /dev/null +++ b/Tests/StateMachine/ExecuteTests.swift @@ -0,0 +1,27 @@ +// +// ExecuteTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class ExecuteTests: XCTestCase { + enum Output: DSLCompatible, Equatable { + case o1 + } + + func test_init_sets_output() { + let sut = Execute(output: Output.o1) + + XCTAssertEqual(sut.output, .o1) + } + + func test_noOutput_sets_nil_output() { + let sut = Execute.noOutput + + XCTAssertNil(sut.output) + } +} diff --git a/Tests/StateMachine/GuardTests.swift b/Tests/StateMachine/GuardTests.swift new file mode 100644 index 0000000..fda7806 --- /dev/null +++ b/Tests/StateMachine/GuardTests.swift @@ -0,0 +1,17 @@ +// +// GuardTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class GuardTests: XCTestCase { + func test_init_sets_predicate() { + let sut = Guard(predicate: true) + + XCTAssertTrue(sut.predicate) + } +} diff --git a/Tests/StateMachine/OnTests.swift b/Tests/StateMachine/OnTests.swift new file mode 100644 index 0000000..9a6259c --- /dev/null +++ b/Tests/StateMachine/OnTests.swift @@ -0,0 +1,249 @@ +// +// OnTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class OnTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2 + case e3(value: String) + } + + func test_init_sets_predicate_and_transition_when_passing_event_and_transition() async { + let receivedEvent = ManagedCriticalState(nil) + + let expectedEvent = Event.e1 + let unexpectedEvent = Event.e2 + + let expectedState = State.s1 + + // Given + let sut = On(event: .e1) { event in + receivedEvent.apply(criticalState: event) + return Transition(to: expectedState) + } + + // When checking predicate + // Then + XCTAssertTrue(sut.predicate(expectedEvent)) + XCTAssertFalse(sut.predicate(unexpectedEvent)) + + // When applying transition + let receivedState = await sut.transition(expectedEvent) + + // Then + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + XCTAssertEqual(receivedState, expectedState) + } + + func test_init_sets_predicate_and_transition_when_passing_event_guard_and_transition() async { + let receivedEventInGuard = ManagedCriticalState(nil) + let receivedEventInTransition = ManagedCriticalState(nil) + + let expectedEvent = Event.e1 + let unexpectedEvent = Event.e2 + let expectedState = State.s1 + + let guardValue = ManagedCriticalState(true) + + // Given + let sut = On(event: .e1) { event in + receivedEventInGuard.apply(criticalState: event) + return Guard(predicate: guardValue.criticalState) + } transition: { event in + receivedEventInTransition.apply(criticalState: event) + return Transition(to: expectedState) + } + + // When checking predicate with true/false guard + // Then + XCTAssertTrue(sut.predicate(expectedEvent)) + XCTAssertFalse(sut.predicate(unexpectedEvent)) + + guardValue.apply(criticalState: false) + + XCTAssertFalse(sut.predicate(expectedEvent)) + + // When applying transition with expected event + let receivedState = await sut.transition(expectedEvent) + + // Then + XCTAssertEqual(receivedEventInTransition.criticalState, expectedEvent) + XCTAssertEqual(receivedEventInTransition.criticalState, expectedEvent) + XCTAssertEqual(receivedState, expectedState) + } + + func test_init_sets_predicate_and_transition_when_passing_event_with_associated_type_and_transition() async { + let receivedValue = ManagedCriticalState(nil) + + let expectedValue = "value" + let expectedEvent = Event.e3(value: expectedValue) + let unexpectedEvent = Event.e2 + + let expectedState = State.s1 + + // Given + let sut = On(event: Event.e3(value:)) { value in + receivedValue.apply(criticalState: value) + return Transition(to: expectedState) + } + + // When expected event + var receivedState = await sut.transition(expectedEvent) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent)) + XCTAssertEqual(receivedValue.criticalState, expectedValue) + XCTAssertEqual(receivedState, expectedState) + + // When unexpected event + // Then + XCTAssertFalse(sut.predicate(unexpectedEvent)) + receivedState = await sut.transition(unexpectedEvent) + XCTAssertNil(receivedState) + } + + func test_init_sets_predicate_and_transition_when_passing_event_with_associated_type_guard_and_transition() async { + let receivedValueInGuard = ManagedCriticalState(nil) + let receivedValueInTransition = ManagedCriticalState(nil) + + let expectedValue = "value" + let expectedEvent = Event.e3(value: expectedValue) + let unexpectedEvent = Event.e2 + let expectedState = State.s1 + + let guardValue = ManagedCriticalState(true) + + // Given + let sut = On(event: Event.e3(value:)) { value in + receivedValueInGuard.apply(criticalState: value) + return Guard(predicate: guardValue.criticalState) + } transition: { value in + receivedValueInTransition.apply(criticalState: value) + return Transition(to: expectedState) + } + + // When predicate is true with expected event + var receivedState = await sut.transition(expectedEvent) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent)) + XCTAssertEqual(receivedValueInGuard.criticalState, expectedValue) + XCTAssertEqual(receivedValueInTransition.criticalState, expectedValue) + XCTAssertEqual(receivedState, expectedState) + + // When predicate is true with unexpected event + // Then + XCTAssertFalse(sut.predicate(unexpectedEvent)) + receivedState = await sut.transition(unexpectedEvent) + XCTAssertNil(receivedState) + + // When predicate is false + guardValue.apply(criticalState: false) + + // Then + XCTAssertFalse(sut.predicate(expectedEvent)) + } + + func test_init_sets_predicate_and_transition_when_passing_oneOf_and_transition() async { + let receivedEvent = ManagedCriticalState(nil) + + let expectedEvent1 = Event.e1 + let expectedEvent3 = Event.e3(value: "value") + let unexpectedEvent = Event.e2 + + let expectedState = State.s1 + + // Given + let sut = On(events: OneOf { + Event.e1 + Event.e3(value:) + }) { event in + receivedEvent.apply(criticalState: event) + return Transition(to: expectedState) + } + + // When expected event 1 + var receivedState = await sut.transition(expectedEvent1) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent1)) + XCTAssertEqual(receivedEvent.criticalState, expectedEvent1) + XCTAssertEqual(receivedState, expectedState) + + // When expected event 3 + receivedState = await sut.transition(expectedEvent3) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent3)) + XCTAssertEqual(receivedEvent.criticalState, expectedEvent3) + XCTAssertEqual(receivedState, expectedState) + + // When unexpected event + // Then + XCTAssertFalse(sut.predicate(unexpectedEvent)) + } + + func test_init_sets_predicate_and_transition_when_passing_oneOf_guard_and_transition() async { + let receivedEventInGuard = ManagedCriticalState(nil) + let receivedEventInTransition = ManagedCriticalState(nil) + + let expectedEvent1 = Event.e1 + let expectedEvent3 = Event.e3(value: "value") + let unexpectedEvent = Event.e2 + + let expectedState = State.s1 + + let guardValue = ManagedCriticalState(true) + + // Given + let sut = On(events: OneOf { + Event.e1 + Event.e3(value:) + }) { event in + receivedEventInGuard.apply(criticalState: event) + return Guard(predicate: guardValue.criticalState) + } transition: { event in + receivedEventInTransition.apply(criticalState: event) + return Transition(to: expectedState) + } + + // When predicate is true with expected event 1 + var receivedState = await sut.transition(expectedEvent1) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent1)) + XCTAssertEqual(receivedEventInGuard.criticalState, expectedEvent1) + XCTAssertEqual(receivedEventInTransition.criticalState, expectedEvent1) + XCTAssertEqual(receivedState, expectedState) + + // When predicate is true with expected event 3 + receivedState = await sut.transition(expectedEvent3) + + // Then + XCTAssertTrue(sut.predicate(expectedEvent3)) + XCTAssertEqual(receivedEventInGuard.criticalState, expectedEvent3) + XCTAssertEqual(receivedEventInTransition.criticalState, expectedEvent3) + XCTAssertEqual(receivedState, expectedState) + + // When predicate is true with unexpected event + // Then + XCTAssertFalse(sut.predicate(unexpectedEvent)) + + // When predicate is false + guardValue.apply(criticalState: false) + + // Then + XCTAssertFalse(sut.predicate(expectedEvent1)) + } +} diff --git a/Tests/StateMachine/OneOfTests.swift b/Tests/StateMachine/OneOfTests.swift new file mode 100644 index 0000000..dc1e5fc --- /dev/null +++ b/Tests/StateMachine/OneOfTests.swift @@ -0,0 +1,28 @@ +// +// OneOfTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class OneOfTests: XCTestCase { + enum State: DSLCompatible { + case s1 + case s2(value: String) + case s3 + } + + func test_init_sets_predicate_when_called_with_resultBuilder() { + let sut = OneOf { + State.s1 + State.s2(value:) + } + + XCTAssertTrue(sut.predicate(State.s1)) + XCTAssertTrue(sut.predicate(State.s2(value: "1"))) + XCTAssertFalse(sut.predicate(State.s3)) + } +} diff --git a/Tests/StateMachine/StateMachineTests.swift b/Tests/StateMachine/StateMachineTests.swift new file mode 100644 index 0000000..88497c6 --- /dev/null +++ b/Tests/StateMachine/StateMachineTests.swift @@ -0,0 +1,104 @@ +//// +//// StateMachineTests.swift +//// +//// +//// Created by Thibault WITTEMBERG on 20/06/2022. +//// +// +//@testable import AsyncStateMachine +//import XCTest +// +//final class StateMachineTests: XCTestCase { +// enum State: DSLCompatible, Equatable { +// case s1 +// case s2(value: String) +// case s3 +// case s4(value: Int) +// } +// +// enum Event: DSLCompatible, Equatable { +// case e1 +// case e2(value: String) +// case e3 +// case e4 +// } +// +// enum Output: DSLCompatible, Equatable { +// case o1 +// case o2 +// } +// +// let sut = StateMachine(initial: State.s1) { +// When(state: State.s1) { _ in +// Execute(output: Output.o1) +// } transitions: { _ in +// On(event: Event.e1) { _ in +// Transition(to: State.s2(value: "2")) +// } +// +// On(event: Event.e2(value:)) { _ in +// Transition(to: State.s3) +// } +// } +// +// When(states: OneOf { +// State.s1 +// State.s2(value:) +// }) { _ in +// Execute(output: Output.o2) +// } transitions: { _ in +// On(events: OneOf{ +// Event.e2(value:) +// Event.e3 +// }) { _ in +// Transition(to: State.s4(value: 4)) +// } +// } +// } +// +// func test_init_sets_initial() { +// let receivedInitial = sut.initial +// XCTAssertEqual(receivedInitial, State.s1) +// } +// +// func test_output_returns_non_nil_when_called_with_expected_state() { +// var receivedOutput = sut.output(for: State.s1) +// XCTAssertEqual(receivedOutput, Output.o1) +// +// receivedOutput = sut.output(for: State.s2(value: "value")) +// XCTAssertEqual(receivedOutput, Output.o2) +// } +// +// func test_output_returns_nil_when_called_with_unexpected_state() { +// let receivedOutput = sut.output(for: State.s3) +// XCTAssertNil(receivedOutput) +// } +// +// func test_reducer_returns_non_nil_when_called_with_expected_state_and_event() async { +// var receivedState = await sut.reduce(when: State.s1, on: Event.e1) +// XCTAssertEqual(receivedState, State.s2(value: "2")) +// +// receivedState = await sut.reduce(when: State.s1, on: Event.e2(value: "")) +// XCTAssertEqual(receivedState, State.s3) +// +// receivedState = await sut.reduce(when: State.s1, on: Event.e3) +// XCTAssertEqual(receivedState, State.s4(value: 4)) +// +// receivedState = await sut.reduce(when: State.s2(value: ""), on: Event.e2(value: "")) +// XCTAssertEqual(receivedState, State.s4(value: 4)) +// +// receivedState = await sut.reduce(when: State.s2(value: ""), on: Event.e3) +// XCTAssertEqual(receivedState, State.s4(value: 4)) +// } +// +// func testReducer_returns_non_nil_when_called_with_expected_state_and_unexpected_event() async { +// var receivedState = await sut.reduce(when: State.s1, on: Event.e4) +// XCTAssertNil(receivedState) +// +// receivedState = await sut.reduce(when: State.s2(value: ""), on: Event.e1) +// XCTAssertNil(receivedState) +// +// receivedState = await sut.reduce(when: State.s2(value: ""), on: Event.e4) +// XCTAssertNil(receivedState) +// } +//} diff --git a/Tests/StateMachine/TransitionTests.swift b/Tests/StateMachine/TransitionTests.swift new file mode 100644 index 0000000..5bb78b6 --- /dev/null +++ b/Tests/StateMachine/TransitionTests.swift @@ -0,0 +1,21 @@ +// +// TransitionTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class TransitionTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + } + + func test_init_sets_state() { + let sut = Transition(to: State.s1) + + XCTAssertEqual(sut.state, State.s1) + } +} diff --git a/Tests/StateMachine/WhenTests.swift b/Tests/StateMachine/WhenTests.swift new file mode 100644 index 0000000..c063e9b --- /dev/null +++ b/Tests/StateMachine/WhenTests.swift @@ -0,0 +1,272 @@ +// +// WhenTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class WhenTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + case s2(value: String) + case s3 + case s4(value: Int) + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2(value: String) + case e3 + } + + enum Output: DSLCompatible, Equatable { + case o1 + } + + func test_init_sets_predicate_output_and_transitions_when_passing_oneOf_execute_and_transitions() async { + let receivedStateInExecute = ManagedCriticalState(nil) + let receivedStateInTransitions = ManagedCriticalState(nil) + + let expectedState = State.s1 + let expectedOutput = Output.o1 + + // Given + let sut = When(states: OneOf { + State.s1 + State.s2(value:) + }) { state in + receivedStateInExecute.apply(criticalState: state) + return Execute(output: expectedOutput) + } transitions: { state in + receivedStateInTransitions.apply(criticalState: state) + return [On(event: Event.e1) { _ in + return Transition(to: .s3) + }] + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(State.s1)) + XCTAssertTrue(sut.predicate(State.s2(value: "value"))) + XCTAssertFalse(sut.predicate(State.s3)) + + // When checking for output + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertEqual(receivedStateInExecute.criticalState, expectedState) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertEqual(receivedTransitionsFromExpectedState.count, 1) + XCTAssertEqual(receivedStateInTransitions.criticalState, expectedState) + } + + func test_init_sets_predicate_output_and_transitions_when_passing_oneOf_and_execute() async { + let receivedStateInExecute = ManagedCriticalState(nil) + + let expectedState = State.s1 + let expectedOutput = Output.o1 + + // Given + let sut = When(states: OneOf { + State.s1 + State.s2(value:) + }) { state in + receivedStateInExecute.apply(criticalState: state) + return Execute(output: expectedOutput) + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(State.s1)) + XCTAssertTrue(sut.predicate(State.s2(value: "value"))) + XCTAssertFalse(sut.predicate(State.s3)) + + // When checking for output + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertEqual(receivedStateInExecute.criticalState, expectedState) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertTrue(receivedTransitionsFromExpectedState.isEmpty) + } + + func test_init_sets_predicate_output_and_transitions_when_passing_state_execute_and_transitions() async { + let receivedStateInExecute = ManagedCriticalState(nil) + let receivedStateInTransitions = ManagedCriticalState(nil) + + let expectedState = State.s1 + let expectedOutput = Output.o1 + + // Given + let sut = When(state: State.s1) { state in + receivedStateInExecute.apply(criticalState: state) + return Execute(output: expectedOutput) + } transitions: { state in + receivedStateInTransitions.apply(criticalState: state) + return [On(event: Event.e1) { _ in + return Transition(to: .s3) + }] + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(expectedState)) + XCTAssertFalse(sut.predicate(State.s3)) + + // When checking for output + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertEqual(receivedStateInExecute.criticalState, expectedState) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertEqual(receivedTransitionsFromExpectedState.count, 1) + XCTAssertEqual(receivedStateInTransitions.criticalState, expectedState) + } + + func test_init_sets_predicate_output_and_transitions_when_passing_state_and_execute() async { + let receivedStateInExecute = ManagedCriticalState(nil) + + let expectedState = State.s1 + let expectedOutput = Output.o1 + + // Given + let sut = When(state: State.s1) { state in + receivedStateInExecute.apply(criticalState: state) + return Execute(output: expectedOutput) + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(expectedState)) + + // When checking for output + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertEqual(receivedStateInExecute.criticalState, expectedState) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertTrue(receivedTransitionsFromExpectedState.isEmpty) + } + + func test_init_sets_predicate_output_and_transitions_when_passing_state_with_associated_value_execute_and_transitions() async { + let receivedValueInExecute = ManagedCriticalState(nil) + let receivedValueInTransitions = ManagedCriticalState(nil) + + let expectedValue = "value" + let expectedState = State.s2(value: expectedValue) + let unexpectedState = State.s4(value: 1) + let expectedOutput = Output.o1 + + // Given + let sut = When(state: State.s2(value:)) { value in + receivedValueInExecute.apply(criticalState: value) + return Execute(output: expectedOutput) + } transitions: { value in + receivedValueInTransitions.apply(criticalState: value) + return [On(event: Event.e1) { _ in + return Transition(to: .s3) + }] + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(expectedState)) + XCTAssertFalse(sut.predicate(State.s3)) + + // When checking for output + let receivedOutputFromUnexpectedState = sut.output(unexpectedState) + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertNil(receivedOutputFromUnexpectedState) + XCTAssertEqual(receivedValueInExecute.criticalState, expectedValue) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromUnexpectedState = sut.transitions(unexpectedState) + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertTrue(receivedTransitionsFromUnexpectedState.isEmpty) + XCTAssertEqual(receivedTransitionsFromExpectedState.count, 1) + XCTAssertEqual(receivedValueInTransitions.criticalState, expectedValue) + } + + func test_init_sets_predicate_output_and_transitions_when_passing_state_with_associated_value_and_execute() async { + let receivedValueInExecute = ManagedCriticalState(nil) + + let expectedValue = "value" + let expectedState = State.s2(value: expectedValue) + let unexpectedState = State.s4(value: 1) + let expectedOutput = Output.o1 + + // Given + let sut = When(state: State.s2(value:)) { value in + receivedValueInExecute.apply(criticalState: value) + return Execute(output: expectedOutput) + } + + // When checking for prediate + // Then + XCTAssertTrue(sut.predicate(expectedState)) + XCTAssertFalse(sut.predicate(unexpectedState)) + + // When checking for output + let receivedOutputFromUnexpectedState = sut.output(unexpectedState) + let receivedOutputFromExpectedState = sut.output(expectedState) + + // Then + XCTAssertNil(receivedOutputFromUnexpectedState) + XCTAssertEqual(receivedValueInExecute.criticalState, expectedValue) + XCTAssertEqual(receivedOutputFromExpectedState, expectedOutput) + + // When getting transitions + let receivedTransitionsFromUnexpectedState = sut.transitions(unexpectedState) + let receivedTransitionsFromExpectedState = sut.transitions(expectedState) + + // Then + XCTAssertTrue(receivedTransitionsFromUnexpectedState.isEmpty) + XCTAssertTrue(receivedTransitionsFromExpectedState.isEmpty) + } + + func test_transitionsBuilder_returns_expression() async { + let transitionIsCalled = ManagedCriticalState(false) + + // Given + let receivedOn = TransitionsBuilder.buildExpression( + On(event: Event.e1, transition: { _ in + transitionIsCalled.apply(criticalState: true) + return Transition(to: .s1) + }) + ) + + // When + _ = await receivedOn.transition(.e1) + + // Then + XCTAssertTrue(transitionIsCalled.criticalState) + } +} diff --git a/Tests/Supporting/AsyncCompactScanSequenceTests.swift b/Tests/Supporting/AsyncCompactScanSequenceTests.swift new file mode 100644 index 0000000..fffa535 --- /dev/null +++ b/Tests/Supporting/AsyncCompactScanSequenceTests.swift @@ -0,0 +1,82 @@ +// +// AsyncCompactScanSequenceTests.swift +// +// +// Created by Thibault WITTEMBERG on 11/08/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class AsyncCompactScanSequenceTests: XCTestCase { + func test_asyncCompactScanSequence_emits_initial_result() async { + let expected = Int.random(in: 0...1000) + + // Given + let sut = AsyncEmptySequence() + .compactScan(expected) { accumulator, value in + accumulator + value.count + } + + // When + var iterator = sut.makeAsyncIterator() + let received = await iterator.next() + + // Them + XCTAssertEqual(received, expected) + } + + func test_asyncCompactScanSequence_applies_transform_and_finishes_when_base_finished() async { + let expected = ["0", "0-1", "0-1-2", "0-1-2-3", "0-1-2-3-4", "0-1-2-3-4-5"] + + // Given + let sut = AsyncLazySequence([1, 2, 3, 4, 5]) + .compactScan("0") { accumulator, value in + "\(accumulator)-\(value)" + } + + // When + var received = [String]() + + for await element in sut { + received.append(element) + } + + // Then + XCTAssertEqual(received, expected) + } + + func test_asyncCompactScanSequence_returns_nil_pastEnd() async { + // Given + let sut = AsyncEmptySequence() + .compactScan("0") { accumulator, value in + "\(accumulator)-\(value)" + } + + // When + var iterator = sut.makeAsyncIterator() + while let _ = await iterator.next() {} + + let received = await iterator.next() + + // Then + XCTAssertNil(received) + } + + func test_asyncCompactScanSequence_throws_when_base_throws() async { + // Given + let sut = AsyncThrowingSequence() + .compactScan("0") { accumulator, value in + "\(accumulator)-\(value)" + } + + // When + do { + for try await _ in sut {} + XCTFail("The sequence should throw") + } catch { + // Then + XCTAssert(error is MockError) + } + } +} diff --git a/Tests/Supporting/AsyncJustSequenceTests.swift b/Tests/Supporting/AsyncJustSequenceTests.swift new file mode 100644 index 0000000..8040f5a --- /dev/null +++ b/Tests/Supporting/AsyncJustSequenceTests.swift @@ -0,0 +1,52 @@ +// +// AsyncJustSequenceTests.swift +// +// +// Created by Thibault WITTEMBERG on 02/07/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class AsyncJustSequenceTests: XCTestCase { + func test_init_sets_element() async { + let element = Int.random(in: 0...100) + let sut = AsyncJustSequence { element } + let value = await sut.element() + XCTAssertEqual(value, element) + } + + func test_just_outputs_expected_element_and_finishes() async { + var receivedResult = [Int]() + + let element = Int.random(in: 0...100) + let sut = AsyncJustSequence { element } + + for await result in sut { + receivedResult.append(result) + } + + XCTAssertEqual(receivedResult, [element]) + } + + func test_just_returns_an_asyncSequence_that_finishes_without_elements_when_task_is_cancelled() { + let hasCancelledExpectation = expectation(description: "The task has been cancelled") + let hasFinishedExpectation = expectation(description: "The AsyncSequence has finished") + + let sut = AsyncJustSequence { 1 } + + let task = Task { + wait(for: [hasCancelledExpectation], timeout: 1) + for await _ in sut { + XCTFail("The AsyncSequence should not output elements") + } + hasFinishedExpectation.fulfill() + } + + task.cancel() + + hasCancelledExpectation.fulfill() + + wait(for: [hasFinishedExpectation], timeout: 1) + } +} diff --git a/Tests/Supporting/AsyncOnEachSequenceTests.swift b/Tests/Supporting/AsyncOnEachSequenceTests.swift new file mode 100644 index 0000000..bdb9719 --- /dev/null +++ b/Tests/Supporting/AsyncOnEachSequenceTests.swift @@ -0,0 +1,42 @@ +// +// AsyncOnEachSequenceTests.swift +// +// +// Created by Thibault WITTEMBERG on 04/08/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class AsyncOnEachSequenceTests: XCTestCase { + func test_onEach_calls_block_for_each_element_and_ends_when_base_ends() async { + let receivedElements = ManagedCriticalState<[Int]>([]) + let expectedElements = [1, 2, 3, 4, 5] + + // Given + let sut = AsyncLazySequence([1, 2, 3, 4, 5]).onEach { element in + receivedElements.withCriticalRegion { received in + received.append(element) + } + } + + // When + for await _ in sut {} + + // Then + XCTAssertEqual(receivedElements.criticalState, expectedElements) + } + + func test_onEach_throws_when_base_throws() async { + // Given + let sut = AsyncThrowingSequence().onEach { _ in } + + // When + do { + for try await _ in sut {} + } catch { + // Then + XCTAssertEqual(error as? MockError, MockError()) + } + } +} diff --git a/Tests/Supporting/AsyncSerialSequenceTests.swift b/Tests/Supporting/AsyncSerialSequenceTests.swift new file mode 100644 index 0000000..6bc1d89 --- /dev/null +++ b/Tests/Supporting/AsyncSerialSequenceTests.swift @@ -0,0 +1,276 @@ +// +// AsyncSerialSequenceTests.swift +// +// +// Created by Thibault WITTEMBERG on 01/08/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class AsyncSerialSequenceTests: XCTestCase { + func test_asyncSerialSequence_forwards_element_from_base_and_finishes_when_base_finishes() async { + // Given + let sut = AsyncJustSequence { 1 } + .serial() + + // When + var received = [Int]() + for await element in sut { + received.append(element) + } + + // Then + XCTAssertEqual(received, [1]) + } + + func test_asyncSerialSequence_returns_nil_when_pastEnd() async { + // Given + let sut = AsyncJustSequence { 1 } + .serial() + + // When + var iterator = sut.makeAsyncIterator() + while let _ = await iterator.next() {} + let received = await iterator.next() + + // Then + XCTAssertNil(received) + } + + func test_asyncSerialSequence_throws_when_base_throws() async { + // Given + let sut = AsyncThrowingSequence() + .serial() + + // When + do { + for try await _ in sut {} + XCTFail("The sequence should throw") + } catch { + // Then + XCTAssert(error is MockError) + } + } + + func test_asyncSerialSequence_locks_iteration_when_another_iteration_in_progress() { + let base = AsyncSuspendableChannel() + + // Given + let sut = base.serial() + let received = ManagedCriticalState<[Int]>([]) + + let firstIterationHasImmediatelyResumed = expectation(description: "The first iteration has resumed immediately, because ... it is the first") + let secondIterationHasSuspended = expectation(description: "The second iteration has suspended because the serial sequence is locked by the first iteration") + + let firstIterationHasReceivedAValue = expectation(description: "The first iteration has received its value") + + let firstIterationHasSuspended = expectation(description: "The first iteration has suspended because the serial sequence is locked by the second iteration") + + let secondIterationHasFinished = expectation(description: "The second iteration has finished with a nil element") + let firstIterationHasFinished = expectation(description: "The first iteration has finished with a nil element") + + // When: running to concurrent iterations + Task { + var iterator = base.makeAsyncIterator() + while let element = await sut.next( + &iterator, + onImmediateResume: { firstIterationHasImmediatelyResumed.fulfill() }, + onSuspend: { firstIterationHasSuspended.fulfill() }) { + received.withCriticalRegion { state in + state.append(element) + } + if element == 1 { + firstIterationHasReceivedAValue.fulfill() + } + } + firstIterationHasFinished.fulfill() + } + + wait(for: [firstIterationHasImmediatelyResumed], timeout: 1.0) + + Task { + var iterator = base.makeAsyncIterator() + while let element = await sut.next(&iterator, onSuspend: { secondIterationHasSuspended.fulfill() }) { + received.withCriticalRegion { state in + state.append(element) + } + } + secondIterationHasFinished.fulfill() + } + + // Then: iteration are mutually exclusive and finishes when base finishes + wait(for: [secondIterationHasSuspended], timeout: 1.0) + + base.unsuspend(1) + + wait(for: [firstIterationHasReceivedAValue], timeout: 1.0) + + wait(for: [firstIterationHasSuspended], timeout: 1.0) + + base.unsuspend(nil) + + wait(for: [secondIterationHasFinished], timeout: 1.0) + + base.unsuspend(nil) + + wait(for: [firstIterationHasFinished], timeout: 1.0) + + XCTAssertEqual(received.criticalState.first, 1) + } + + func test_asyncSerialSequence_finishes_all_blocked_iterations_when_base_finishes() async { + let base = AsyncSuspendableChannel() + + // Given + let sut = base.serial() + + let iteration1HasImmediatelyResumed = expectation(description: "The first iteration has resumed immediately, because ... it is the first") + let iteration1HasFinished = expectation(description: "The first iteration has finished with a nil element") + + let iteration2HasSuspended = expectation(description: "The second iteration has suspended because the serial sequence is locked by the first iteration") + let iteration3HasSuspended = expectation(description: "The third iteration has suspended because the serial sequence is locked by the first iteration") + let iteration4HasSuspended = expectation(description: "The forth iteration has suspended because the serial sequence is locked by the first iteration") + + let iteration2HasFinished = expectation(description: "The second iteration has finished with a nil element") + let iteration3HasFinished = expectation(description: "The third iteration has finished with a nil element") + let iteration4HasFinished = expectation(description: "The forth iteration has finished with a nil element") + + // When: several iterations are suspended and finishing the base sequence + Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator, onImmediateResume: { iteration1HasImmediatelyResumed.fulfill() }) {} + iteration1HasFinished.fulfill() + } + + wait(for: [iteration1HasImmediatelyResumed], timeout: 1.0) + + Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next( + &iterator, + onSuspend: { + iteration2HasSuspended.fulfill() + }) {} + iteration2HasFinished.fulfill() + } + + Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator, onSuspend: { iteration3HasSuspended.fulfill() }) {} + iteration3HasFinished.fulfill() + } + + Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator, onSuspend: { iteration4HasSuspended.fulfill() }) {} + iteration4HasFinished.fulfill() + } + + wait(for: [iteration2HasSuspended, iteration3HasSuspended, iteration4HasSuspended], timeout: 1.0) + + base.finish() + + // Then: all suspended iterations are finished + wait(for: [iteration1HasFinished, iteration2HasFinished, iteration3HasFinished, iteration4HasFinished], timeout: 1.0) + + var iterator = sut.makeAsyncIterator() + let received = await iterator.next() + XCTAssertNil(received) + } + + func test_asyncSerialSequence_unlocks_iteration_when_task_is_cancelled() async { + let base = AsyncSuspendableChannel() + + // Given + let sut = base.serial() + + let iteration1HasImmediatelyResumed = expectation(description: "The first iteration has resumed immediately, because ... it is the first") + let iteration1HasFinished = expectation(description: "The first iteration has finished with a nil element") + + let task = Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator, onImmediateResume: { iteration1HasImmediatelyResumed.fulfill() }) {} + iteration1HasFinished.fulfill() + } + + wait(for: [iteration1HasImmediatelyResumed], timeout: 1.0) + + // When + task.cancel() + + // Then + wait(for: [iteration1HasFinished], timeout: 1.0) + + base.unsuspend(1) + + var iterator = sut.makeAsyncIterator() + let received = await iterator.next() + + XCTAssertEqual(received, 1) + } + + func test_asyncSerialSequence_unlocks_iteration_when_task_is_immediately_cancelled() async { + let base = AsyncSuspendableChannel() + + // Given + let sut = base.serial() + + let iteration1HasFinished = expectation(description: "The first iteration has finished with a nil element") + + Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator) {} + iteration1HasFinished.fulfill() + }.cancel() + + wait(for: [iteration1HasFinished], timeout: 1.0) + + base.unsuspend(1) + + var iterator = sut.makeAsyncIterator() + let received = await iterator.next() + + XCTAssertEqual(received, 1) + } + + func test_asyncSerialSequence_unlocks_iteration_and_releases_next_awaiting_when_task_is_cancelled() async { + let base = AsyncSuspendableChannel() + + // Given + let sut = base.serial() + + let iteration1HasImmediatelyResumed = expectation(description: "The first iteration has resumed immediately, because ... it is the first") + let iteration1HasFinished = expectation(description: "The first iteration has finished with a nil element") + let iteration2HasSuspended = expectation(description: "The second iteration has suspended because the serial sequence is locked by the first iteration") + + let task1 = Task { + var iterator = base.makeAsyncIterator() + while let _ = await sut.next(&iterator, onImmediateResume: { iteration1HasImmediatelyResumed.fulfill() }) {} + iteration1HasFinished.fulfill() + } + + wait(for: [iteration1HasImmediatelyResumed], timeout: 1.0) + + let task2 = Task<[Int], Never> { + var received = [Int]() + var iterator = base.makeAsyncIterator() + while let element = await sut.next(&iterator, onSuspend: { iteration2HasSuspended.fulfill() }) { + received.append(element) + } + return received + } + + wait(for: [iteration2HasSuspended], timeout: 1.0) + + task1.cancel() + + wait(for: [iteration1HasFinished], timeout: 1.0) + + base.unsuspend(1) + base.unsuspend(nil) + + let received = await task2.value + XCTAssertEqual(received, [1]) + } +} diff --git a/Tests/Supporting/AsyncSubjectTests.swift b/Tests/Supporting/AsyncSubjectTests.swift new file mode 100644 index 0000000..4c75d5c --- /dev/null +++ b/Tests/Supporting/AsyncSubjectTests.swift @@ -0,0 +1,209 @@ +// +// AsyncSubjectTests.swift +// +// +// Created by Thibault WITTEMBERG on 06/08/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class AsyncSubjectTests: XCTestCase { + func test_send_queues_elements_and_delivers_to_one_consumer() async { + let expected = [1, 2, 3, 4, 5] + + // Given + let sut = AsyncBufferedChannel() + + // When + sut.send(1) + sut.send(2) + sut.send(3) + sut.send(4) + sut.send(5) + sut.finish() + + var received = [Int]() + for await element in sut { + received.append(element) + } + + // Then + XCTAssertEqual(received, expected) + } + + func test_send_from_several_producers_queues_elements_and_delivers_to_several_consumers() async { + let expected1 = Set((1...24)) + let expected2 = Set((25...50)) + let expected = expected1.union(expected2) + + // Given + let sut = AsyncBufferedChannel() + + // When + let sendTask1 = Task { + for i in expected1 { + sut.send(i) + } + } + + let sendTask2 = Task { + for i in expected2 { + sut.send(i) + } + } + + await sendTask1.value + await sendTask2.value + + sut.finish() + + let task1 = Task<[Int], Never> { + var received = [Int]() + for await element in sut { + received.append(element) + } + return received + } + + let task2 = Task<[Int], Never> { + var received = [Int]() + for await element in sut { + received.append(element) + } + return received + } + + let received1 = await task1.value + let received2 = await task2.value + + let received: Set = Set(received1).union(received2) + + // Then + XCTAssertEqual(received, expected) + } + + func test_asyncSubject_ends_iteration_when_task_is_cancelled() async { + let taskCanBeCancelled = expectation(description: "The can be cancelled") + let taskWasCancelled = expectation(description: "The task was cancelled") + let iterationHasFinished = expectation(description: "The iteration we finished") + + let sut = AsyncBufferedChannel() + sut.send(1) + sut.send(2) + sut.send(3) + sut.send(4) + sut.send(5) + + let task = Task { + var received: Int? + for await element in sut { + received = element + taskCanBeCancelled.fulfill() + wait(for: [taskWasCancelled], timeout: 1.0) + } + iterationHasFinished.fulfill() + return received + } + + wait(for: [taskCanBeCancelled], timeout: 1.0) + + // When + task.cancel() + taskWasCancelled.fulfill() + + wait(for: [iterationHasFinished], timeout: 1.0) + + // Then + let received = await task.value + XCTAssertEqual(received, 1) + } + + func test_finish_ends_awaiting_consumers_and_immediately_resumes_pastEnd() async { + let iteration1IsAwaiting = expectation(description: "") + let iteration1IsFinished = expectation(description: "") + + let iteration2IsAwaiting = expectation(description: "") + let iteration2IsFinished = expectation(description: "") + + // Given + let sut = AsyncBufferedChannel() + + let task1 = Task { + let received = await sut.next { + iteration1IsAwaiting.fulfill() + } + iteration1IsFinished.fulfill() + return received + } + + let task2 = Task { + let received = await sut.next { + iteration2IsAwaiting.fulfill() + } + iteration2IsFinished.fulfill() + return received + } + + wait(for: [iteration1IsAwaiting, iteration2IsAwaiting], timeout: 1.0) + + // When + sut.finish() + + wait(for: [iteration1IsFinished, iteration2IsFinished], timeout: 1.0) + + let received1 = await task1.value + let received2 = await task2.value + + XCTAssertNil(received1) + XCTAssertNil(received2) + + let iterator = sut.makeAsyncIterator() + let received = await iterator.next() + XCTAssertNil(received) + } + + func test_send_does_not_queue_when_already_finished() async { + // Given + let sut = AsyncBufferedChannel() + + // When + sut.finish() + sut.send(1) + + // Then + let iterator = sut.makeAsyncIterator() + let received = await iterator.next() + + XCTAssertNil(received) + } + + func test_cancellation_immediately_resumes_when_already_finished() async { + let iterationIsFinished = expectation(description: "The task was cancelled") + + // Given + let sut = AsyncBufferedChannel() + sut.finish() + + // When + Task { + for await _ in sut {} + iterationIsFinished.fulfill() + }.cancel() + + // Then + wait(for: [iterationIsFinished], timeout: 1.0) + } + + func test_awaiting_uses_id_for_equatable() { + // Given + let awaiting1 = AsyncBufferedChannel.Awaiting.placeHolder(id: 1) + let awaiting2 = AsyncBufferedChannel.Awaiting.placeHolder(id: 2) + let awaiting3 = AsyncBufferedChannel.Awaiting.placeHolder(id: 1) + + // When + // Then + XCTAssertEqual(awaiting1, awaiting3) + XCTAssertNotEqual(awaiting1, awaiting2) + } +} diff --git a/Tests/Supporting/InjectTests.swift b/Tests/Supporting/InjectTests.swift new file mode 100644 index 0000000..b6f1788 --- /dev/null +++ b/Tests/Supporting/InjectTests.swift @@ -0,0 +1,406 @@ +// +// InjectTests.swift +// +// +// Created by Thibault WITTEMBERG on 02/07/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class InjectTests: XCTestCase { + func test_inject_returns_function_with_no_parameter_when_inject_1_parameter() async { + let expectedParam = Int.random(in: 0...100) + var receivedParam: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + dep: expectedParam, + in: { param -> Int in + receivedParam = param + return expectedResult + } + ) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam, expectedParam) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_no_parameter_when_inject_2_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + deps: expectedParam1, + expectedParam2, + in: { param1, param2 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + return expectedResult + } + ) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_no_parameter_when_inject_3_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + deps: expectedParam1, + expectedParam2, + expectedParam3, + in: { param1, param2, param3 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + return expectedResult + } + ) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_no_parameter_when_inject_4_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + deps: expectedParam1, + expectedParam2, + expectedParam3, + expectedParam4, + in: { param1, param2, param3, param4 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + return expectedResult + }) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_no_parameter_when_inject_5_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + let expectedParam5 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + var receivedParam5: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + deps: expectedParam1, + expectedParam2, + expectedParam3, + expectedParam4, + expectedParam5, + in: { param1, param2, param3, param4, param5 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + receivedParam5 = param5 + return expectedResult + }) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedParam5, expectedParam5) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_no_parameter_when_inject_6_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + let expectedParam5 = Int.random(in: 0...100) + let expectedParam6 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + var receivedParam5: Int? + var receivedParam6: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received = inject( + deps: expectedParam1, + expectedParam2, + expectedParam3, + expectedParam4, + expectedParam5, + expectedParam6, + in: { param1, param2, param3, param4, param5, param6 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + receivedParam5 = param5 + receivedParam6 = param6 + return expectedResult + }) + + // When + let receivedResult = await received() + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedParam5, expectedParam5) + XCTAssertEqual(receivedParam6, expectedParam6) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_1_parameter_when_inject_2_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received: (Int) async -> Int = inject( + dep: expectedParam2, + in: { param1, param2 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + return expectedResult + } + ) + + // When + let receivedResult = await received(expectedParam1) + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_1_parameter_when_inject_3_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received: (Int) async -> Int = inject( + deps: expectedParam2, + expectedParam3, + in: { param1, param2, param3 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + return expectedResult + } + ) + + // When + let receivedResult = await received(expectedParam1) + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_1_parameter_when_inject_4_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received: (Int) async -> Int = inject( + deps: expectedParam2, + expectedParam3, + expectedParam4, + in: { param1, param2, param3, param4 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + return expectedResult + } + ) + + // When + let receivedResult = await received(expectedParam1) + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_1_parameter_when_inject_5_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + let expectedParam5 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + var receivedParam5: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received: (Int) async -> Int = inject( + deps: expectedParam2, + expectedParam3, + expectedParam4, + expectedParam5, + in: { param1, param2, param3, param4, param5 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + receivedParam5 = param5 + return expectedResult + } + ) + + // When + let receivedResult = await received(expectedParam1) + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedParam5, expectedParam5) + XCTAssertEqual(receivedResult, expectedResult) + } + + func test_inject_returns_function_with_1_parameter_when_inject_6_parameters() async { + let expectedParam1 = Int.random(in: 0...100) + let expectedParam2 = Int.random(in: 0...100) + let expectedParam3 = Int.random(in: 0...100) + let expectedParam4 = Int.random(in: 0...100) + let expectedParam5 = Int.random(in: 0...100) + let expectedParam6 = Int.random(in: 0...100) + var receivedParam1: Int? + var receivedParam2: Int? + var receivedParam3: Int? + var receivedParam4: Int? + var receivedParam5: Int? + var receivedParam6: Int? + + let expectedResult = Int.random(in: 0...100) + + // Given + let received: (Int) async -> Int = inject( + deps: expectedParam2, + expectedParam3, + expectedParam4, + expectedParam5, + expectedParam6, + in: { param1, param2, param3, param4, param5, param6 -> Int in + receivedParam1 = param1 + receivedParam2 = param2 + receivedParam3 = param3 + receivedParam4 = param4 + receivedParam5 = param5 + receivedParam6 = param6 + return expectedResult + } + ) + + // When + let receivedResult = await received(expectedParam1) + + // Then + XCTAssertEqual(receivedParam1, expectedParam1) + XCTAssertEqual(receivedParam2, expectedParam2) + XCTAssertEqual(receivedParam3, expectedParam3) + XCTAssertEqual(receivedParam4, expectedParam4) + XCTAssertEqual(receivedParam5, expectedParam5) + XCTAssertEqual(receivedParam6, expectedParam6) + XCTAssertEqual(receivedResult, expectedResult) + } +} diff --git a/Tests/Supporting/OrderedStorageTests.swift b/Tests/Supporting/OrderedStorageTests.swift new file mode 100644 index 0000000..26d1cbf --- /dev/null +++ b/Tests/Supporting/OrderedStorageTests.swift @@ -0,0 +1,109 @@ +// +// OrderedStorageTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class OrderedStorageTests: XCTestCase { + func test_init_appends_values_when_called_with_content_of_array() { + let content = [1, 2, 3, 4, 5] + let expected = [0: 1, 1: 2, 2: 3, 3: 4, 4: 5] + + // Given + // When + let sut = OrderedStorage(contentOf: content) + + // Then + XCTAssertEqual(sut.storage, expected) + } + + func test_append_adds_new_value_with_incremented_index() { + let content = [1, 2, 3, 4, 5] + let expected = [0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6] + + // Given + var sut = OrderedStorage(contentOf: content) + + // When + sut.append(6) + + // Then + XCTAssertEqual(sut.storage, expected) + } + + func test_removeAll_clears_storage() { + let content = [1, 2, 3, 4, 5] + + // Given + var sut = OrderedStorage(contentOf: content) + + // When + sut.removeAll() + + // Then + XCTAssertTrue(sut.storage.isEmpty) + } + + func test_remove_clears_value() { + let content = [1, 2, 3, 4, 5] + let expected = [0: 1, 1: 2, 2: 3, 4: 5] + + // Given + var sut = OrderedStorage(contentOf: content) + + // When + sut.remove(index: 3) + + // Then + XCTAssertEqual(sut.storage, expected) + } + + func test_indexed_values_returns_sorted_values() { + let content = [1, 2, 3, 4, 5] + let expected = content.map { (index: $0 - 1, value: $0) } + + // Given + let sut = OrderedStorage(contentOf: content) + + // When + let indexedValues = sut.indexedValues + + // Then + let assertIndexedValuesIsExpected = zip(indexedValues, expected).allSatisfy { received, expected in + return received.index == expected.index && + received.value == expected.value + } + + XCTAssertTrue(assertIndexedValuesIsExpected) + } + + func test_values_returns_values_sorted_by_index() { + let content = [5, 4, 3, 2, 1] + + // Given + let sut = OrderedStorage(contentOf: content) + + // When + let values = sut.values + + // Then + XCTAssertEqual(values, content) + } + + func test_count_returns_value() { + let content = [5, 4, 3, 2, 1] + + // Given + let sut = OrderedStorage(contentOf: content) + + // When + let received = sut.count + + // Then + XCTAssertEqual(received, 5) + } +} diff --git a/Tests/Tools/AsyncEmptySequence.swift b/Tests/Tools/AsyncEmptySequence.swift new file mode 100644 index 0000000..e298d5f --- /dev/null +++ b/Tests/Tools/AsyncEmptySequence.swift @@ -0,0 +1,23 @@ +// +// AsyncEmptySequence.swift +// +// +// Created by Thibault WITTEMBERG on 10/08/2022. +// + +final class AsyncEmptySequence: AsyncSequence { + typealias Element = Element + typealias AsyncIterator = Iterator + + init() {} + + func makeAsyncIterator() -> Iterator { + Iterator() + } + + struct Iterator: AsyncIteratorProtocol { + func next() async -> Element? { + nil + } + } +} diff --git a/Tests/Tools/AsyncLazySequence.swift b/Tests/Tools/AsyncLazySequence.swift new file mode 100644 index 0000000..f767e25 --- /dev/null +++ b/Tests/Tools/AsyncLazySequence.swift @@ -0,0 +1,37 @@ +// +// AsyncLazySequence.swift +// +// +// Created by Thibault WITTEMBERG on 01/08/2022. +// + +struct AsyncLazySequence: AsyncSequence { + typealias Element = Base.Element + + let base: Base + + init(_ base: Base) { + self.base = base + } + + func makeAsyncIterator() -> Iterator { + Iterator(base.makeIterator()) + } + + struct Iterator: AsyncIteratorProtocol { + var iterator: Base.Iterator? + + init(_ iterator: Base.Iterator) { + self.iterator = iterator + } + + mutating func next() async -> Base.Element? { + if !Task.isCancelled, let value = iterator?.next() { + return value + } else { + iterator = nil + return nil + } + } + } +} diff --git a/Tests/Tools/AsyncSequence+Collect.swift b/Tests/Tools/AsyncSequence+Collect.swift new file mode 100644 index 0000000..8cc37a1 --- /dev/null +++ b/Tests/Tools/AsyncSequence+Collect.swift @@ -0,0 +1,16 @@ +// +// AsyncSequence+Collect.swift +// +// +// Created by Thibault WITTEMBERG on 02/07/2022. +// + +extension AsyncSequence { + func collect() async rethrows -> [Element] { + var result = [Element]() + for try await element in self { + result.append(element) + } + return result + } +} diff --git a/Tests/Tools/AsyncSuspendableChannel.swift b/Tests/Tools/AsyncSuspendableChannel.swift new file mode 100644 index 0000000..0486d9a --- /dev/null +++ b/Tests/Tools/AsyncSuspendableChannel.swift @@ -0,0 +1,120 @@ +// +// AsyncSuspendableChannel.swift +// +// +// Created by Thibault WITTEMBERG on 11/08/2022. +// + +@testable import AsyncStateMachine + +final class AsyncSuspendableChannel: AsyncSequence, Sendable where Element: Sendable { + typealias Element = Element + typealias AsyncIterator = Iterator + + enum State { + case idle + case awaitingProducer(UnsafeContinuation?) + case awaitingConsumer(Element?) + case finished + + var continuation: UnsafeContinuation? { + if case .awaitingProducer(let continuation) = self { + return continuation + } + return nil + } + } + + let state = ManagedCriticalState(.awaitingProducer(nil)) + + func makeAsyncIterator() -> Iterator { + Iterator( + asyncSuspendablechannel: self + ) + } + + func next() async -> Element? { + let isCancelled = ManagedCriticalState(false) + + return await withTaskCancellationHandler(handler: { [state] in + let contination = state.withCriticalRegion { state -> UnsafeContinuation? in + isCancelled.apply(criticalState: true) + + switch state { + case .finished, .idle, .awaitingConsumer: + return nil + case .awaitingProducer(let continuation): + state = .idle + return continuation + } + } + + contination?.resume(returning: nil) + }, operation: { + await withUnsafeContinuation { [state] (newContinuation: UnsafeContinuation) in + let decision = state.withCriticalRegion { state -> (Element?, UnsafeContinuation?)? in + if isCancelled.criticalState { return (nil, newContinuation) } + + switch state { + case .finished: + return (nil, newContinuation) + case .idle: + state = .awaitingProducer(newContinuation) + return nil + case .awaitingProducer: + state = .awaitingProducer(newContinuation) + return nil + case .awaitingConsumer(let element): + state = .idle + return (element, newContinuation) + } + } + + if let decision = decision { + let element = decision.0 + let continuation = decision.1 + + continuation?.resume(returning: element) + } + } + }) + } + + func unsuspend(_ newElement: Element?) { + let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in + switch state { + case .finished: + return nil + case .awaitingConsumer: + state = .awaitingConsumer(newElement) + return nil + case .awaitingProducer(let continuation): + state = .idle + return continuation + case .idle: + state = .awaitingConsumer(newElement) + return nil + } + } + + continuation?.resume(returning: newElement) + } + + func finish() { + let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in + let continuation = state.continuation + state = .finished + return continuation + } + + continuation?.resume(returning: nil) + } + + struct Iterator: AsyncIteratorProtocol { + let asyncSuspendablechannel: AsyncSuspendableChannel + + func next() async -> Element? { + return await self.asyncSuspendablechannel.next() + } + } +} diff --git a/Tests/Tools/AsyncThrowingSequence.swift b/Tests/Tools/AsyncThrowingSequence.swift new file mode 100644 index 0000000..4dc391b --- /dev/null +++ b/Tests/Tools/AsyncThrowingSequence.swift @@ -0,0 +1,23 @@ +// +// AsyncThrowingSequence.swift +// +// +// Created by Thibault WITTEMBERG on 09/07/2022. +// + +struct MockError: Error, Equatable {} + +struct AsyncThrowingSequence: AsyncSequence { + typealias Element = Element + typealias AsyncIterator = Iterator + + func makeAsyncIterator() -> AsyncIterator { + Iterator() + } + + struct Iterator: AsyncIteratorProtocol { + func next() async throws -> Element? { + throw MockError() + } + } +} diff --git a/Tests/Tools/Task+ForEver.swift b/Tests/Tools/Task+ForEver.swift new file mode 100644 index 0000000..f12cc29 --- /dev/null +++ b/Tests/Tools/Task+ForEver.swift @@ -0,0 +1,23 @@ +// +// Task+ForEver.swift +// +// +// Created by Thibault WITTEMBERG on 09/07/2022. +// + +extension Task where Success == Void, Failure == Never { + static func forEver( + execute: @Sendable @escaping () -> Void = {}, + onCancel: @Sendable @escaping () -> Void = {} + ) -> Task { + Task { + await withTaskCancellationHandler { + await withUnsafeContinuation { continuation in + execute() + } + } onCancel: { + onCancel() + } + } + } +} diff --git a/Tests/ViewStateTests.swift b/Tests/ViewStateTests.swift new file mode 100644 index 0000000..165cc4d --- /dev/null +++ b/Tests/ViewStateTests.swift @@ -0,0 +1,379 @@ +// +// BindingsTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +#if canImport(SwiftUI) +@testable import AsyncStateMachine +import XCTest + +final class ViewStateTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + case s2 + case s3 + case s4(value: String) + + var value: String? { + switch self { + case let .s4(value): return value + default: return nil + } + } + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2 + } + + enum Output: DSLCompatible, Equatable { + case o1 + } + + func test_send_pushes_event_in_state_machine_when_called() async { + let eventWasReceived = expectation(description: "Event was received") + let receivedEvent = ManagedCriticalState(nil) + let expectedEvent = Event.e1 + + // Given + let stateMachine = StateMachine(initial: State.s1) {} + let runtime = Runtime() + .register(middleware: { (event: Event) in + receivedEvent.apply(criticalState: event) + eventWasReceived.fulfill() + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // When + sut.send(expectedEvent) + + // Then + wait(for: [eventWasReceived], timeout: 1.0) + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } + + func test_send_pushes_event_in_state_machine_and_resumes_when_predicate_is_true() async { + let expectedEvent = Event.e1 + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: .s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .e1) { _ in + Transition(to: .s2) + } + } + + When(state: .s2) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: .e2) { _ in + Transition(to: .s3) + } + } + } + let runtime = Runtime() + .map(output: .o1, to: { Event.e2 }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // When + await sut.send(expectedEvent, resumeWhen: { $0 == .s3 }) + + // Then + } + + func test_send_pushes_event_in_state_machine_and_resumes_when_state_is_reached() async { + let expectedEvent = Event.e1 + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: .s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .e1) { _ in + Transition(to: .s2) + } + } + + When(state: .s2) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: .e2) { _ in + Transition(to: .s3) + } + } + } + let runtime = Runtime() + .map(output: .o1, to: { Event.e2 }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // When + await sut.send(expectedEvent, resumeWhen: .s3) + + // Then + } + + func test_send_pushes_event_in_state_machine_and_resumes_when_state_with_associated_value_is_reached() async { + let expectedEvent = Event.e1 + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: .s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .e1) { _ in + Transition(to: .s2) + } + } + + When(state: .s2) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: .e2) { _ in + Transition(to: State.s4(value: "value")) + } + } + } + let runtime = Runtime() + .map(output: .o1, to: { Event.e2 }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // When + await sut.send(expectedEvent, resumeWhen: State.s4(value:)) + + // Then + } + + func test_send_pushes_event_in_state_machine_and_resumes_when_state_is_oneOf() async { + let expectedEvent = Event.e1 + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: .s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: .e1) { _ in + Transition(to: .s2) + } + } + + When(state: .s2) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: .e2) { _ in + Transition(to: State.s4(value: "value")) + } + } + } + let runtime = Runtime() + .map(output: .o1, to: { Event.e2 }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // When + await sut.send(expectedEvent, resumeWhen: OneOf { + State.s3 + State.s4(value:) + }) + + // Then + } + + func test_binding_returns_binding_that_sends_event_when_passing_event() async { + let eventWasReceived = expectation(description: "Event was received") + let receivedEvent = ManagedCriticalState(nil) + let expectedEvent = Event.e1 + + let stateMachine = StateMachine(initial: State.s1) {} + let runtime = Runtime() + .register(middleware: { (event: Event) in + receivedEvent.apply(criticalState: event) + eventWasReceived.fulfill() + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // Given + let received = sut.binding(send: expectedEvent) + + XCTAssertEqual(received.wrappedValue, State.s1) + + // When + received.wrappedValue = .s2 + + wait(for: [eventWasReceived], timeout: 1.0) + + // Then + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } + + func test_binding_returns_binding_that_receives_state_and_sends_event_when_passing_event_closure() async { + let eventWasReceived = expectation(description: "Event was received") + var receivedState: State? + let expectedState = State.s2 + let receivedEvent = ManagedCriticalState(nil) + let expectedEvent = Event.e1 + + let stateMachine = StateMachine(initial: State.s1) {} + let runtime = Runtime() + .register(middleware: { (event: Event) in + receivedEvent.apply(criticalState: event) + eventWasReceived.fulfill() + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // Given + let received = sut.binding(send: { state in + receivedState = state + return expectedEvent + }) + + XCTAssertEqual(received.wrappedValue, State.s1) + + // When + received.wrappedValue = .s2 + + wait(for: [eventWasReceived], timeout: 1.0) + + // Then + XCTAssertEqual(receivedState, expectedState) + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } + + func test_binding_returns_binding_that_receives_value_and_sends_event_when_passing_keypath_and_event_closure() { + let eventWasReceived = expectation(description: "Event was received") + let receivedValue = ManagedCriticalState(nil) + let expectedValue = "value" + + let receivedEvent = ManagedCriticalState(nil) + let expectedEvent = Event.e1 + + let stateMachine = StateMachine(initial: State.s1) {} + let runtime = Runtime() + .register(middleware: { (event: Event) in + receivedEvent.apply(criticalState: event) + eventWasReceived.fulfill() + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + // Given + let received = sut.binding(keypath: \.value) { value in + receivedValue.apply(criticalState: value) + return expectedEvent + } + + // initial state is State.s1 (\.value is nil) + XCTAssertNil(received.wrappedValue) + + sut.state = State.s4(value: expectedValue) + XCTAssertEqual(received.wrappedValue, expectedValue) + + // When + received.wrappedValue = expectedValue + + wait(for: [eventWasReceived], timeout: 1.0) + + // Then + XCTAssertEqual(receivedValue.criticalState, expectedValue) + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + } + + func test_binding_returns_binding_that_receives_value_and_sends_event_when_passing_keypath_and_event() async { + let initialStateWasPublished = expectation(description: "The initial state was published") + let eventWasReceived = expectation(description: "Event was received") + let expectedValue = "value" + + let receivedEvent = ManagedCriticalState(nil) + let expectedEvent = Event.e1 + + let stateMachine = StateMachine(initial: State.s1) {} + let runtime = Runtime() + .register(middleware: { (event: Event) in + receivedEvent.apply(criticalState: event) + eventWasReceived.fulfill() + }) + + let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime) + let sut = ViewState(asyncStateMachineSequence: sequence) + + Task { + await sut.start() + } + + let cancellable = sut.$state.first().sink { state in + initialStateWasPublished.fulfill() + } + + wait(for: [initialStateWasPublished], timeout: 1.0) + + // Given + let received = sut.binding(keypath: \.value, send: expectedEvent) + + // initial state is State.s1 (\.value is nil) + XCTAssertNil(received.wrappedValue) + + sut.state = State.s4(value: expectedValue) + XCTAssertEqual(received.wrappedValue, expectedValue) + + // When + received.wrappedValue = expectedValue + + wait(for: [eventWasReceived], timeout: 1.0) + + // Then + XCTAssertEqual(receivedEvent.criticalState, expectedEvent) + + cancellable.cancel() + } +} +#endif diff --git a/Tests/XCTest/XCTStateMachineTests.swift b/Tests/XCTest/XCTStateMachineTests.swift new file mode 100644 index 0000000..13a40de --- /dev/null +++ b/Tests/XCTest/XCTStateMachineTests.swift @@ -0,0 +1,265 @@ +// +// XCTStateMachineTests.swift +// +// +// Created by Thibault WITTEMBERG on 20/06/2022. +// + +@testable import AsyncStateMachine +import XCTest + +final class XCTStateMachineTests: XCTestCase { + enum State: DSLCompatible, Equatable { + case s1 + case s2(value: String) + case s3 + } + + enum Event: DSLCompatible, Equatable { + case e1 + case e2(value: String) + } + + enum Output: DSLCompatible, Equatable { + case o1 + case o2 + } + + func test_assertNoOutput_succeeds_when_no_output() { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + } + + When(state: State.s2(value:)) { _ in + Execute.noOutput + } transitions: { _ in + } + } + + // When + // Then + XCTStateMachine(stateMachine).assertNoOutput(when: State.s1, State.s2(value: "value"), fail: { _ in failIsCalled = true }) + XCTAssertFalse(failIsCalled) + } + + func test_assertNoOutput_fails_when_output() { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + } + } + + // When + XCTStateMachine(stateMachine) + .assertNoOutput(when: State.s1, State.s2(value: "value"), fail: { _ in failIsCalled = true }) + + // Then + XCTAssertTrue(failIsCalled) + } + + func test_assert_whenStates_succeeds_when_experted_output() { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + } + } + + // When + // Then + XCTStateMachine(stateMachine) + .assert(when: State.s2(value: "value"), execute: Output.o1, fail: { _ in failIsCalled = true }) + + XCTAssertFalse(failIsCalled) + } + + func test_assert_whenStates_fails_when_unexperted_output() { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + } + } + + // When + // Then + XCTStateMachine(stateMachine) + .assert(when: State.s2(value: "value"), execute: Output.o2, fail: { _ in failIsCalled = true }) + + XCTAssertTrue(failIsCalled) + } + + func test_assertNoTransition_succeeds_when_no_transition() async { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + } + + // When + // Then + await XCTStateMachine(stateMachine) + .assertNoTransition(when: State.s1, State.s2(value: "value"), on: Event.e2(value: "value"), fail: { _ in failIsCalled = true }) + + XCTAssertFalse(failIsCalled) + } + + func test_assertNoTransition_succeeds_when_transition() async { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + } + + // When + // Then + await XCTStateMachine(stateMachine) + .assertNoTransition(when: State.s1, State.s2(value: "value"), on: Event.e1, fail: { _ in failIsCalled = true }) + + XCTAssertTrue(failIsCalled) + } + + func test_assertTransitionTo_succeeds_when_transition() async { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + } + + // When + // Then + await XCTStateMachine(stateMachine) + .assert(when: State.s1, State.s2(value: "value"), on: Event.e1, transitionTo: State.s3, fail: { _ in failIsCalled = true }) + + XCTAssertFalse(failIsCalled) + } + + func test_assertTransitionTo_fails_when_transition_to_wrong_state() async { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + On(event: Event.e1) { _ in + Transition(to: State.s3) + } + } + } + + // When + // Then + await XCTStateMachine(stateMachine) + .assert(when: State.s1, State.s2(value: "value"), on: Event.e1, transitionTo: State.s1, fail: { _ in failIsCalled = true }) + + XCTAssertTrue(failIsCalled) + } + + func test_assertTransitionTo_fails_when_no_transition() async { + var failIsCalled = false + + // Given + let stateMachine = StateMachine(initial: State.s1) { + When(state: State.s1) { _ in + Execute.noOutput + } transitions: { _ in + } + + When(state: State.s2(value:)) { _ in + Execute(output: .o1) + } transitions: { _ in + } + } + + // When + // Then + await XCTStateMachine(stateMachine) + .assert(when: State.s1, State.s2(value: "value"), on: Event.e1, transitionTo: State.s3, fail: { _ in failIsCalled = true }) + + XCTAssertTrue(failIsCalled) + } +}