diff --git a/README.md b/README.md index b8b6416..f2a92cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![KStateMachine](./docs/kstatemachine-logo.png) -![Build and test with Gradle](https://github.com/nsk90/kstatemachine/workflows/Build%20and%20test%20with%20Gradle/badge.svg) +![Build and test with Gradle](https://github.com/KStateMachine/kstatemachine/workflows/Build%20and%20test%20with%20Gradle/badge.svg) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nsk90_kstatemachine&metric=alert_status)](https://sonarcloud.io/dashboard?id=nsk90_kstatemachine) [![codecov](https://codecov.io/gh/nsk90/kstatemachine/branch/master/graph/badge.svg?token=IR2JR43FOZ)](https://codecov.io/gh/nsk90/kstatemachine) [![Maven Central Version](https://img.shields.io/maven-central/v/io.github.nsk90/kstatemachine?logo=sonatype)](https://central.sonatype.com/artifact/io.github.nsk90/kstatemachine) @@ -19,7 +19,7 @@ [KDoc](https://kstatemachine.github.io/kstatemachine/kdoc/index.html) | [Sponsors](#-sponsors) | [Quick start](#-quick-start-sample) | -[Samples](#-samples) | +[Samples](#-samples) | [Install](#-install) | [Contribution](#-contribution) | [Support](#-support) | @@ -29,8 +29,10 @@ # KStateMachine -KStateMachine is a powerful Kotlin Multiplatform library with clean DSL syntax for creating complex [state machines](https://en.wikipedia.org/wiki/Finite-state_machine) -and [statecharts](https://www.sciencedirect.com/science/article/pii/0167642387900359/pdf) driven by Kotlin Coroutines. +**KStateMachine** is a powerful **Kotlin Multiplatform** library with clean DSL syntax for creating +complex [state machines](https://en.wikipedia.org/wiki/Finite-state_machine) +and [statecharts](https://www.sciencedirect.com/science/article/pii/0167642387900359/pdf) driven by +**Kotlin Coroutines**. ## 🌏 Overview @@ -38,48 +40,71 @@ and [statecharts](https://www.sciencedirect.com/science/article/pii/016764238790 * **[Kotlin DSL](https://kotlinlang.org/docs/type-safe-builders.html#scope-control-dslmarker) syntax** - declarative and clear state machine structure. Using without DSL is also possible. -* **[Kotlin Coroutines](https://kstatemachine.github.io/kstatemachine/pages/multithreading.html#kotlin-coroutines) support** - +* **[Kotlin Coroutines](https://kstatemachine.github.io/kstatemachine/pages/multithreading.html#kotlin-coroutines) + support** - call suspendable functions within the library. You can fully use KStateMachine without Kotlin Coroutines dependency if necessary. * **[Kotlin Multiplatform](https://kstatemachine.github.io/kstatemachine/pages/multiplatform.html) support** -* **Zero dependency** - it is written in pure Kotlin, it does not depend on any third party libraries or Android SDK. +* **Zero dependency** - it is written in pure Kotlin, main library artifact does not depend on any third party libraries + or Android SDK. ### ⚙️ State management features -* **[Event based](https://kstatemachine.github.io/kstatemachine/pages/events.html)** - [transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html) are performed by +* **[Event based](https://kstatemachine.github.io/kstatemachine/pages/events.html) + ** - [transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html) are performed by processing incoming events -* **[Reactive](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#listen-states)** - listen for machine, states, - [state groups](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#listen-group-of-states) and transitions +* **[Reactive](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#listen-states)** - listen for + machine, states, + [state groups](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#listen-group-of-states) and + transitions * **[Guarded](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#guarded-transitions) - and [Conditional transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#conditional-transitions)** - dynamic + and [Conditional transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#conditional-transitions) + ** - dynamic target state which is calculated in a moment of event processing depending on application business logic -* **[Nested states](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#nested-states)** - build hierarchical state machines +* **[Nested states](https://kstatemachine.github.io/kstatemachine/pages/states/states.html#nested-states)** - build + hierarchical state machines (statecharts) - with [cross-level transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#cross-level-transitions) support + with [cross-level transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#cross-level-transitions) + support * **[Composed (nested) state machines]( https://kstatemachine.github.io/kstatemachine/pages/states/states.html#composed-nested-state-machines )** - use state machines as atomic child states -* **[Pseudo states](https://kstatemachine.github.io/kstatemachine/pages/states/pseudo_states.html)** for additional logic in machine +* **[Pseudo states](https://kstatemachine.github.io/kstatemachine/pages/states/pseudo_states.html)** for additional + logic in machine behaviour -* **[Typesafe transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/typesafe_transitions.html)** - pass data in +* **[Typesafe transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/typesafe_transitions.html) + ** - pass data in typesafe way from event to state -* **[Parallel states](https://kstatemachine.github.io/kstatemachine/pages/states.html#parallel-states)** - avoid a combinatorial +* **[Parallel states](https://kstatemachine.github.io/kstatemachine/pages/states.html#parallel-states)** - avoid a + combinatorial explosion of states -* **[Undo transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#undo-transitions)** - navigate back to previous - state (like stack based FSMs do) -* **[Optional argument](https://kstatemachine.github.io/kstatemachine/pages/events.html#event-argument)** passing for events and +* + * + +*[Undo transitions](https://kstatemachine.github.io/kstatemachine/pages/transitions/transitions.html#undo-transitions) +** - navigate back to previous +state (like stack based FSMs do) + +* **[Optional argument](https://kstatemachine.github.io/kstatemachine/pages/events.html#event-argument)** passing for + events and transitions * **[Export](https://kstatemachine.github.io/kstatemachine/pages/export.html)** state machine structure to [PlantUML](https://plantuml.com/) and [Mermaid](https://mermaid.js.org/) diagrams -* **[Persist (serialize)](https://kstatemachine.github.io/kstatemachine/pages/persistence.html)** state machine's active - configuration and restore it later -* **[Testable](https://kstatemachine.github.io/kstatemachine/pages/testing.html)** - run state machine from specified state and enable internal logging -* **[Well tested](https://github.com/kstatemachine/kstatemachine/tree/master/tests/src/commonTest/kotlin/ru/nsk/kstatemachine)** - all features are covered - by tests +* **[Persist (serialize)](https://kstatemachine.github.io/kstatemachine/pages/persistence.html)** state machine's + active + configuration and restore it later. Built-in `kotlinx.serialization` support. +* **[Testable](https://kstatemachine.github.io/kstatemachine/pages/testing.html)** - run state machine from specified + state and enable internal logging +* + * + +*[Well tested](https://github.com/kstatemachine/kstatemachine/tree/master/tests/src/commonTest/kotlin/ru/nsk/kstatemachine) +** - all features are covered +by tests ## 📄 Documentation @@ -93,7 +118,7 @@ I highly appreciate that you donate or become a sponsor to support the project. If you find this project useful you can support it by: * Pushing the ⭐ star-button -* Using ❤️github-sponsors button to see supported donation methods +* Using ❤️github-sponsors button to see supported donation methods ## 🚀 Quick start sample @@ -125,6 +150,7 @@ object SwitchEvent : Event sealed class States : DefaultState() { object RedState : States() object YellowState : States() + // machine finishes when enters [FinalState] object GreenState : States(), FinalState } @@ -152,9 +178,9 @@ fun main() = runBlocking { addState(YellowState) { transition(targetState = GreenState) } - + addFinalState(GreenState) - + onFinished { println("Finished") } } // you can observe state machine changes using [Flow] along with simple listeners @@ -168,7 +194,7 @@ fun main() = runBlocking { ## 🧪 Samples -* [Simple Android 2D shooter game sample](https://github.com/kstatemachine/android-kstatemachine-sample) +* [Android 2D shooter game sample](https://github.com/kstatemachine/android-kstatemachine-sample) The library itself does not depend on Android. @@ -176,6 +202,7 @@ fun main() = runBlocking { Android sample app

+* [Compose 2D shooter game sample](https://github.com/KStateMachine/compose-kstatemachine-sample) * [Finished state sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedStateSample.kt) * [Transition on FinishedEvent sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventSample.kt) * [FinishedEvent using with DataState sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventDataStateSample.kt) @@ -190,6 +217,7 @@ fun main() = runBlocking { * [Guarded transition sample](./samples/src/commonMain/kotlin/ru/nsk/samples/GuardedTransitionSample.kt) * [Cross-level transition sample](./samples/src/commonMain/kotlin/ru/nsk/samples/CrossLevelTransitionSample.kt) * [Typesafe transition sample](./samples/src/commonMain/kotlin/ru/nsk/samples/TypesafeTransitionSample.kt) +* [Event recording sample](./samples/src/commonMain/kotlin/ru/nsk/samples/SerializationEventRecordingSample.kt) * [Complex syntax sample](./samples/src/commonMain/kotlin/ru/nsk/samples/ComplexSyntaxSample.kt) shows many syntax variants and library possibilities, so it looks messy @@ -223,11 +251,11 @@ See [CONTRIBUTING](./CONTRIBUTING.md) file. I am open to answer you questions and feature requests. Fill free to use any of communication channels to give your feedback. -* [Slack channel](https://kotlinlang.slack.com/archives/C07DVAEKLM8) or +* [Slack channel](https://kotlinlang.slack.com/archives/C07DVAEKLM8) or [GitHub discussions](https://github.com/kstatemachine/kstatemachine/discussions) - best for questions and discussions * [GitHub issues](https://github.com/KStateMachine/kstatemachine/issues) - best for bugs and feature requests -If you use some other platforms to ask questions or mention the library, I recommend adding a link to this +If you use some other platforms to ask questions or mention the library, I recommend adding a link to this GitHub project or using `#kstatemachine` tag. ## 🗺️ Roadmap diff --git a/build.gradle.kts b/build.gradle.kts index 146e182..0b4935a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("jvm") version Versions.kotlin apply false id("org.jetbrains.dokka") version Versions.kotlinDokka - id("org.jetbrains.kotlinx.binary-compatibility-validator") version Versions.kotlinBinaryCompatibilityValidator + id("org.jetbrains.kotlinx.binary-compatibility-validator") version Versions.kotlinBinaryCompatibilityValidatorPlugin } group = Versions.libraryMavenCentralGroup diff --git a/buildSrc/src/main/kotlin/ru/nsk/Versions.kt b/buildSrc/src/main/kotlin/ru/nsk/Versions.kt index 27a70d4..fd10da8 100644 --- a/buildSrc/src/main/kotlin/ru/nsk/Versions.kt +++ b/buildSrc/src/main/kotlin/ru/nsk/Versions.kt @@ -2,12 +2,12 @@ object Versions { // library const val libraryMavenCentralGroup = "io.github.nsk90" const val libraryJitPackGroup = "com.github.nsk90" - const val libraryVersion = "0.31.1" + const val libraryVersion = "0.32.0" // tools - const val kotlin = "2.0.0" + const val kotlin = "2.0.20" const val kotlinDokka = "1.9.20" - const val kotlinBinaryCompatibilityValidator = "0.16.3" + const val kotlinBinaryCompatibilityValidatorPlugin = "0.16.3" const val jacocoTool = "0.8.11" // compatibility @@ -17,6 +17,7 @@ object Versions { // dependencies const val coroutinesCore = "1.8.1" + const val serialization = "1.7.3" // test dependencies const val mockk = "1.13.11" diff --git a/buildSrc/src/main/kotlin/ru/nsk/maven-publish.gradle.kts b/buildSrc/src/main/kotlin/ru/nsk/maven-publish.gradle.kts index 5f988d0..9d1de00 100644 --- a/buildSrc/src/main/kotlin/ru/nsk/maven-publish.gradle.kts +++ b/buildSrc/src/main/kotlin/ru/nsk/maven-publish.gradle.kts @@ -50,17 +50,17 @@ publishing { "KStateMachine is a Kotlin DSL library for creating state machines and " + "hierarchical state machines (statecharts)." ) - url.set("https://github.com/nsk90/kstatemachine") + url.set("https://github.com/KStateMachine/kstatemachine") inceptionYear.set("2020") issueManagement { system.set("GitHub") - url.set("https://github.com/nsk90/kstatemachine/issues") + url.set("https://github.com/KStateMachine/kstatemachine/issues") } licenses { license { name.set("Boost Software License 1.0") - url.set("https://raw.githubusercontent.com/nsk90/kstatemachine/master/LICENSE") + url.set("https://raw.githubusercontent.com/KStateMachine/kstatemachine/master/LICENSE") distribution.set("repo") } } @@ -73,9 +73,9 @@ publishing { } } scm { - url.set("https://github.com/nsk90/kstatemachine") - connection.set("scm:git:git://github.com/nsk90/kstatemachine.git") - developerConnection.set("scm:git:ssh://git@github.com/nsk90/kstatemachine.git") + url.set("https://github.com/KStateMachine/kstatemachine") + connection.set("scm:git:git://github.com/KStateMachine/kstatemachine.git") + developerConnection.set("scm:git:ssh://git@github.com/KStateMachine/kstatemachine.git") } } } diff --git a/docs/pages/export.md b/docs/pages/export.md index cfba285..85aec0d 100644 --- a/docs/pages/export.md +++ b/docs/pages/export.md @@ -41,7 +41,7 @@ println(machine.exportToPlantUml()) Copy/paste resulting output to [Plant UML online editor](http://www.plantuml.com/plantuml/) -See [PlantUML nested states export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportSample.kt) +See [PlantUML nested states export sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportSample.kt) ## Mermaid @@ -60,7 +60,7 @@ println(machine.exportToMermaid()) to view diagrams directly in IDE for file types: `.mmd` and `.mermaid`. * or copy/paste resulting output to [Mermaid live editor](https://mermaid.live/) -See [Mermaid nested states export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MermaidExportSample.kt) +See [Mermaid nested states export sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MermaidExportSample.kt) ## Controlling export output @@ -77,5 +77,5 @@ state("State1") { } ``` -See [PlantUML with MetaInfo export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportWithMetaInfoSample.kt) +See [PlantUML with MetaInfo export sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportWithMetaInfoSample.kt) diff --git a/docs/pages/install.md b/docs/pages/install.md index ecf6ad0..7ba07db 100644 --- a/docs/pages/install.md +++ b/docs/pages/install.md @@ -14,12 +14,14 @@ title: Install KStateMachine is available on `Maven Central` and `JitPack` repositories. -The library consists of 2 components: +The library consists of the fallowing components (artifacts): -* `kstatemachine` - (mandatory) state machine implementation (depends only on Kotlin Standard library) -* `kstatemachine-coroutines` - (optional) add-ons for working with coroutines (depends on Kotlin Coroutines library) +* `kstatemachine` - (mandatory) state machine implementation (depends only on **Kotlin Standard library**) +* `kstatemachine-coroutines` - (optional) add-ons for working with coroutines (depends on **Kotlin Coroutines library**) +* `kstatemachine-serialization` - (optional) add-ons for serialization (depends on **Kotlin Serialization library**). + Released in `v0.32.0` -Please note that starting from `v0.22.0` the library switched to Kotlin Multiplatform and artifact naming has changed. +Please note that starting from `v0.22.0` the library switched to **Kotlin Multiplatform** and artifact naming has changed. ## Maven Central @@ -31,24 +33,31 @@ dependencies { // multiplatform artifacts (starting from 0.22.0) implementation("io.github.nsk90:kstatemachine:") implementation("io.github.nsk90:kstatemachine-coroutines:") + implementation("io.github.nsk90:kstatemachine-serialization:") // or JVM/Android artifacts (starting from 0.22.0) implementation("io.github.nsk90:kstatemachine-jvm:") implementation("io.github.nsk90:kstatemachine-coroutines-jvm:") + implementation("io.github.nsk90:kstatemachine-serialization-jvm:") // or iOS artifacts (starting from 0.22.1) implementation("io.github.nsk90:kstatemachine-iosarm64:") implementation("io.github.nsk90:kstatemachine-coroutines-iosarm64:") + implementation("io.github.nsk90:kstatemachine-serialization-iosarm64:") implementation("io.github.nsk90:kstatemachine-iosx64:") implementation("io.github.nsk90:kstatemachine-coroutines-iosx64:") + implementation("io.github.nsk90:kstatemachine-serialization-iosx64:") implementation("io.github.nsk90:kstatemachine-iossimulatorarm64:") implementation("io.github.nsk90:kstatemachine-coroutines-iossimulatorarm64:") + implementation("io.github.nsk90:kstatemachine-serialization-iossimulatorarm64:") // or JS implementation("io.github.nsk90:kstatemachine-js:") implementation("io.github.nsk90:kstatemachine-coroutines-js:") + implementation("io.github.nsk90:kstatemachine-serialization-js:") // or WebAssembly (Wasm) implementation("io.github.nsk90:kstatemachine-wasm-js:") implementation("io.github.nsk90:kstatemachine-coroutines-wasm-js:") + implementation("io.github.nsk90:kstatemachine-serialization-wasm-js:") } ``` @@ -58,6 +67,7 @@ dependencies { // multiplatform artifacts implementation 'io.github.nsk90:kstatemachine:' implementation 'io.github.nsk90:kstatemachine-coroutines:' // optional + implementation 'io.github.nsk90:kstatemachine-serialization:' // optional // etc.. } ``` @@ -104,6 +114,7 @@ dependencies { // note that group is different in second artifact, long group name also works for first artifact but not vise versa // it is some strange JitPack behaviour implementation("com.github.nsk90.kstatemachine:kstatemachine-coroutines:") // optional + implementation("com.github.nsk90.kstatemachine:kstatemachine-serialization:") // optional } ``` @@ -114,6 +125,7 @@ dependencies { // note that group is different in second artifact, long group name also works for first artifact but not vise versa // it is some strange JitPack behaviour implementation 'com.github.nsk90.kstatemachine:kstatemachine-coroutines:' // optional + implementation 'com.github.nsk90.kstatemachine:kstatemachine-serialization:' // optional } ``` diff --git a/docs/pages/persistence.md b/docs/pages/persistence.md index 6b019c1..65666d4 100644 --- a/docs/pages/persistence.md +++ b/docs/pages/persistence.md @@ -4,16 +4,18 @@ title: Persistence --- # Persistence + {: .no_toc } ## Table of contents + {: .no_toc .text-delta } - TOC -{:toc} + {:toc} * **Persist** `StateMachine` - means transform it into serializable representation, such as `Serializable` object or - JSON text, and possibly saving it into some persistent storage like file or sending by network. + JSON text, and possibly saving it into some persistent storage like a file or sending by a network. * **Restoration** - is a process of restoring the `StateMachine` from the serializable representation. There are several kinds or levels of `StateMachine` persistence (serialization). Let's look at sample use cases: @@ -48,29 +50,58 @@ val machine = createStateMachine( } ``` -When the machine had processed necessary events, and you want to save its state configuration, first you have to -get the recorded events: +When the machine had processed your business logic events, and you want to save its state configuration, first you have +to get the recorded events: ```kotlin val recordedEvents = machine.eventRecorder.getRecordedEvents() ``` -`RecordedEvents` object now is ready to be serialized. Currently, the library does not provide an implementation -of serialization process, so it is up to user to write serialization code. The serialization support is planned -using `kotlinx.serialization` library in further `KStateMachine` versions. +`RecordedEvents` object now is ready to be serialized. The library provides an implementation +of serialization process using `kotlinx.serialization` library starting from `KStateMachine` version `v0.32.0`. + +Initialize serialization format (JSON for instance): + +```kotlin + val jsonFormat = Json { + // use special, library provided SerializersModule for RecordedEvents and its internals + // from kstatemachine-serialization artifact + serializersModule = KStateMachineSerializersModule + SerializersModule { /* ... */ } +} +``` + +And encode (serialize) `RecordedEvents` object: + +```kotlin +val recordedEventsJson = jsonFormat.encodeToString(recordedEvents) +``` + +### Custom serialization library + +Alternatively you can use some other serialization library to serialize `RecordedEvents` class by your own +(you will also need to serialize `SerializableGeneratedEvent` class for internal events generated by the library +itself). ## Restoring StateMachine When a user wants to restore the StateMachine, he deserializes `RecordedEvents` object and -creates StateMachine instance having exactly the same structure as original one. +creates StateMachine instance having exactly the same structure as original one. Typically, both instances are created by the same code. +```kotlin +val restoredRecordedEvents = jsonFormat.decodeFromString(recordedEventsJson) +``` + Calling `restoreByRecordedEvents()` or its blocking analog `restoreByRecordedEventsBlocking()` will process recorded events over just created StateMachine instance. ```kotlin -machine.restoreByRecordedEvents(recordedEvents) +machine2.restoreByRecordedEvents(restoredRecordedEvents) ``` `restoreByRecordedEvents()` method will start the machine if necessary. You can configure restoration process by `restoreByRecordedEvents()` arguments. +The machine should not process any events before its restoration (in such case exception will be thrown) as +it can possibly lead to incorrect restoration result. + +See [Event recording sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/SerializationEventRecordingSample.kt) \ No newline at end of file diff --git a/docs/pages/states/states.md b/docs/pages/states/states.md index 8e4ec78..36790c3 100644 --- a/docs/pages/states/states.md +++ b/docs/pages/states/states.md @@ -208,7 +208,7 @@ sealed class States : DefaultState() { } ``` -See [Finished state sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/FinishedStateSample.kt) +See [Finished state sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/FinishedStateSample.kt) Finishing of states and state machines is treated little differently. State machine that was finished stops processing incoming events. @@ -250,16 +250,16 @@ Notifications about finishing are available in two forms: If `FinalState` that triggered `FinishedEvent` is also a `DataState` then its `data` field will be copied into `FinishedEvent`. - See [transition on FinishedEvent sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventSample.kt) + See [transition on FinishedEvent sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventSample.kt) ## Consider using Kotlin `sealed` classes With sealed classes for states and events your state machine structure may look simpler. Try to compare this two samples they both are doing the same thing but using of sealed classes makes code self explaining: -[Minimal sealed classes sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MinimalSealedClassesSample.kt) +[Minimal sealed classes sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MinimalSealedClassesSample.kt) vs -[Minimal syntax sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MinimalSyntaxSample.kt) +[Minimal syntax sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MinimalSyntaxSample.kt) Also sealed classes eliminate need of using `lateinit` states variables or reordering of states in state machine setup block to have a valid state references for transitions. diff --git a/docs/pages/transitions/transitions.md b/docs/pages/transitions/transitions.md index b57fbf1..5a9741e 100644 --- a/docs/pages/transitions/transitions.md +++ b/docs/pages/transitions/transitions.md @@ -124,7 +124,7 @@ state1 { } ``` -See [guarded transition sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/GuardedTransitionSample.kt) +See [guarded transition sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/GuardedTransitionSample.kt) ```mermaid --- @@ -279,7 +279,7 @@ state { } ``` -See [undo transition sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/UndoTransitionSample.kt) +See [undo transition sample](https://github.com/KStateMachine/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/UndoTransitionSample.kt) ## Cross-level transitions diff --git a/kstatemachine-serialization/api/kstatemachine-serialization.api b/kstatemachine-serialization/api/kstatemachine-serialization.api new file mode 100644 index 0000000..0437fbc --- /dev/null +++ b/kstatemachine-serialization/api/kstatemachine-serialization.api @@ -0,0 +1,4 @@ +public final class ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializerKt { + public static final fun getKStateMachineSerializersModule ()Lkotlinx/serialization/modules/SerializersModule; +} + diff --git a/kstatemachine-serialization/build.gradle.kts b/kstatemachine-serialization/build.gradle.kts new file mode 100644 index 0000000..52335dd --- /dev/null +++ b/kstatemachine-serialization/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + kotlin("multiplatform") + `java-library` + ru.nsk.`maven-publish` + id("org.jetbrains.dokka") + kotlin("plugin.serialization") version Versions.kotlin +} + +group = rootProject.group +version = rootProject.version + +kotlin { + jvmToolchain(Versions.jdkVersion) + sourceSets.all { + languageSettings.apply { + languageVersion = Versions.languageVersion + apiVersion = Versions.apiVersion + } + } + + jvm {} + iosArm64() + iosX64() + iosSimulatorArm64() + js { + browser() + nodejs() + } + @Suppress("OPT_IN_USAGE") // this is alpha feature + wasmJs() + + applyDefaultHierarchyTemplate() + sourceSets { + commonMain { + dependencies { + api(project(":kstatemachine")) + + api("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serialization}") + } + } + + // contains blocking APIs which are not supported on JS + val blockingMain by creating { dependsOn(commonMain.get()) } + val jsCommonMain by creating { dependsOn(commonMain.get()) } + + jvmMain.get().dependsOn(blockingMain) + iosMain.get().dependsOn(blockingMain) + + val wasmJsMain by getting + wasmJsMain.dependsOn(jsCommonMain) + jsMain.get().dependsOn(jsCommonMain) + } +} \ No newline at end of file diff --git a/kstatemachine-serialization/src/commonMain/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializer.kt b/kstatemachine-serialization/src/commonMain/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializer.kt new file mode 100644 index 0000000..cf2b632 --- /dev/null +++ b/kstatemachine-serialization/src/commonMain/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializer.kt @@ -0,0 +1,286 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package ru.nsk.kstatemachine.serialization.persistence + +import kotlinx.serialization.* +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.polymorphic +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent.EventType +import ru.nsk.kstatemachine.persistence.Record +import ru.nsk.kstatemachine.persistence.RecordedEvents +import ru.nsk.kstatemachine.statemachine.ProcessingResult +import ru.nsk.kstatemachine.transition.EventAndArgument + +val KStateMachineSerializersModule = SerializersModule { + contextual(RecordedEventsSerializer) + polymorphic(Event::class) { + subclass(SerializableGeneratedEvent::class, SerializableGeneratedEventSerializer) + } + polymorphic(EventType::class) { + subclass(EventType.Start::class, SerializableGeneratedEventEventTypeStartSerializer) + subclass(EventType.Stop::class, SerializableGeneratedEventEventTypeStopSerializer) + subclass(EventType.Destroy::class, SerializableGeneratedEventEventTypeDestroySerializer) + } +} + +private object RecordedEventsSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("ru.nsk.kstatemachine.persistence.RecordedEvents") { + element("structureHashCode") + element("records", ListSerializer(RecordSerializer).descriptor) + } + + override fun serialize(encoder: Encoder, value: RecordedEvents) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.structureHashCode) + encodeSerializableElement(descriptor, 1, ListSerializer(RecordSerializer), value.records) + } + } + + override fun deserialize(decoder: Decoder): RecordedEvents { + return decoder.decodeStructure(descriptor) { + if (decodeSequentially()) { + RecordedEvents( + structureHashCode = decodeIntElement(descriptor, 0), + decodeSerializableElement(descriptor, 1, ListSerializer(RecordSerializer)), + ) + } else { + var structureHashCode = makeNullPointerFailure("required structureHashCode property is absent") + var records = makeNullPointerFailure>("required records property is absent") + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> structureHashCode = Result.success(decodeIntElement(descriptor, 0)) + 1 -> records = Result.success( + decodeSerializableElement(descriptor, 1, ListSerializer(RecordSerializer)) + ) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + RecordedEvents(structureHashCode.getOrThrow(), records.getOrThrow()) + } + } + } +} + +private object RecordSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("ru.nsk.kstatemachine.persistence.Record") { + element("eventAndArgument", EventAndArgumentSerializer.descriptor) + element("processingResult") + } + + override fun serialize(encoder: Encoder, value: Record) { + encoder.encodeStructure(RecordedEventsSerializer.descriptor) { + encodeSerializableElement(descriptor, 0, EventAndArgumentSerializer, value.eventAndArgument) + encodeSerializableElement(descriptor, 1, serializer(), value.processingResult) + } + } + + override fun deserialize(decoder: Decoder): Record { + return decoder.decodeStructure(descriptor) { + if (decodeSequentially()) { + Record( + decodeSerializableElement(descriptor, 0, EventAndArgumentSerializer), + decodeSerializableElement(descriptor, 1, serializer()), + ) + } else { + var eventAndArgument = + makeNullPointerFailure>("required eventAndArgument property is absent") + var processingResult = + makeNullPointerFailure("required processingResult property is absent") + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> eventAndArgument = Result.success( + decodeSerializableElement(descriptor, 0, EventAndArgumentSerializer) + ) + 1 -> processingResult = Result.success( + decodeSerializableElement(descriptor, 1, serializer()) + ) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + Record(eventAndArgument.getOrThrow(), processingResult.getOrThrow()) + } + } + } +} + +private object EventAndArgumentSerializer : KSerializer> { + override val descriptor = buildClassSerialDescriptor("ru.nsk.kstatemachine.transition.EventAndArgument") { + element("event", PolymorphicSerializer(Event::class).descriptor) + element("argument", PolymorphicSerializer(Any::class).descriptor, isOptional = true) + } + + override fun serialize(encoder: Encoder, value: EventAndArgument<*>) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement(descriptor, 0, serializer(), value.event) + encodeNullableSerializableElement(descriptor, 1, PolymorphicSerializer(Any::class), value.argument) + } + } + + override fun deserialize(decoder: Decoder): EventAndArgument<*> { + return decoder.decodeStructure(descriptor) { + if (decodeSequentially()) { + EventAndArgument( + decodeSerializableElement(descriptor, 0, serializer()), + decodeNullableSerializableElement(descriptor, 1, PolymorphicSerializer(Any::class)), + ) + } else { + var event = makeNullPointerFailure("required event property is absent") + var argument: Any? = null + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> event = Result.success(decodeSerializableElement(descriptor, 0, serializer())) + 1 -> argument = + decodeNullableSerializableElement(descriptor, 1, PolymorphicSerializer(Any::class)) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + EventAndArgument(event.getOrThrow(), argument) + } + } + } +} + +private object SerializableGeneratedEventSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("ru.nsk.kstatemachine.event.SerializableGeneratedEvent") { + element("eventType", PolymorphicSerializer(EventType::class).descriptor) + } + + override fun serialize(encoder: Encoder, value: SerializableGeneratedEvent) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement(descriptor, 0, PolymorphicSerializer(EventType::class), value.eventType) + } + } + + override fun deserialize(decoder: Decoder): SerializableGeneratedEvent { + return decoder.decodeStructure(descriptor) { + if (decodeSequentially()) { + SerializableGeneratedEvent( + decodeSerializableElement(descriptor, 0, PolymorphicSerializer(EventType::class)) + ) + } else { + var eventType = makeNullPointerFailure("required eventType property is absent") + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> eventType = Result.success( + decodeSerializableElement(descriptor, 0, PolymorphicSerializer(EventType::class)) + ) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + SerializableGeneratedEvent(eventType.getOrThrow()) + } + } + } +} + +/** + * See an issue https://github.com/Kotlin/kotlinx.serialization/issues/2830 + */ +private object SerializableGeneratedEventEventTypeStartSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor( + "ru.nsk.kstatemachine.event.SerializableGeneratedEvent.EventType.Start", + ) { + element("ignore_this_field", Boolean.serializer().nullable.descriptor, isOptional = true) + } + + override fun serialize(encoder: Encoder, value: EventType.Start) { + encoder.encodeStructure(descriptor) { + encodeNullableSerializableElement(descriptor, 0, Boolean.serializer().nullable, null) + } + } + + override fun deserialize(decoder: Decoder): EventType.Start { + return decoder.decodeStructure(descriptor) { + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> decodeNullableSerializableElement(descriptor, 0, Boolean.serializer().nullable) // ignore + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + EventType.Start + } + } +} + +/** + * See an issue https://github.com/Kotlin/kotlinx.serialization/issues/2830 + */ +private object SerializableGeneratedEventEventTypeStopSerializer : KSerializer { + override val descriptor = + buildClassSerialDescriptor("ru.nsk.kstatemachine.event.SerializableGeneratedEvent.EventType.Stop") { + element("ignore_this_field", Boolean.serializer().nullable.descriptor, isOptional = true) + } + + override fun serialize(encoder: Encoder, value: EventType.Stop) { + encoder.encodeStructure(descriptor) { + encodeNullableSerializableElement(descriptor, 0, Boolean.serializer().nullable, null) + } + } + + override fun deserialize(decoder: Decoder): EventType.Stop { + return decoder.decodeStructure(descriptor) { + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> decodeNullableSerializableElement(descriptor, 0, Boolean.serializer().nullable) // ignore + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + EventType.Stop + } + } +} + +private object SerializableGeneratedEventEventTypeDestroySerializer : KSerializer { + override val descriptor = + buildClassSerialDescriptor("ru.nsk.kstatemachine.event.SerializableGeneratedEvent.EventType.Destroy") { + element("stop") + } + + override fun serialize(encoder: Encoder, value: EventType.Destroy) { + encoder.encodeStructure(descriptor) { + encodeBooleanElement(descriptor, 0, value.stop) + } + } + + override fun deserialize(decoder: Decoder): EventType.Destroy { + return decoder.decodeStructure(descriptor) { + if (decodeSequentially()) { + EventType.Destroy(decodeBooleanElement(descriptor, 0)) + } else { + var stop = makeNullPointerFailure("required stop property is absent") + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> stop = Result.success(decodeBooleanElement(descriptor, 0)) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + EventType.Destroy(stop.getOrThrow()) + } + + } + } +} + +private fun makeNullPointerFailure(message: String): Result = Result.failure(NullPointerException(message)) \ No newline at end of file diff --git a/kstatemachine/api/kstatemachine.api b/kstatemachine/api/kstatemachine.api index 1494060..314641e 100644 --- a/kstatemachine/api/kstatemachine.api +++ b/kstatemachine/api/kstatemachine.api @@ -50,6 +50,33 @@ public final class ru/nsk/kstatemachine/event/FinishedEvent : ru/nsk/kstatemachi public abstract interface class ru/nsk/kstatemachine/event/GeneratedEvent : ru/nsk/kstatemachine/event/Event { } +public final class ru/nsk/kstatemachine/event/SerializableGeneratedEvent : ru/nsk/kstatemachine/event/GeneratedEvent { + public fun (Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType;)V + public final fun component1 ()Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType; + public final fun copy (Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType;)Lru/nsk/kstatemachine/event/SerializableGeneratedEvent; + public static synthetic fun copy$default (Lru/nsk/kstatemachine/event/SerializableGeneratedEvent;Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType;ILjava/lang/Object;)Lru/nsk/kstatemachine/event/SerializableGeneratedEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getEventType ()Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType { +} + +public final class ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType$Destroy : ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType { + public fun (Z)V + public final fun getStop ()Z +} + +public final class ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType$Start : ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType { + public static final field INSTANCE Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType$Start; +} + +public final class ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType$Stop : ru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType { + public static final field INSTANCE Lru/nsk/kstatemachine/event/SerializableGeneratedEvent$EventType$Stop; +} + public abstract interface class ru/nsk/kstatemachine/event/StartEvent : ru/nsk/kstatemachine/event/GeneratedEvent { public abstract fun getStartState ()Lru/nsk/kstatemachine/state/IState; } @@ -100,6 +127,7 @@ public final class ru/nsk/kstatemachine/persistence/Record { public final fun getEventAndArgument ()Lru/nsk/kstatemachine/transition/EventAndArgument; public final fun getProcessingResult ()Lru/nsk/kstatemachine/statemachine/ProcessingResult; public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class ru/nsk/kstatemachine/persistence/RecordedEvents { @@ -107,6 +135,7 @@ public final class ru/nsk/kstatemachine/persistence/RecordedEvents { public final fun getRecords ()Ljava/util/List; public final fun getStructureHashCode ()I public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class ru/nsk/kstatemachine/persistence/RestorationResult { @@ -156,7 +185,7 @@ public final class ru/nsk/kstatemachine/persistence/WarningType : java/lang/Enum public class ru/nsk/kstatemachine/state/BaseStateImpl : ru/nsk/kstatemachine/state/InternalState { public fun (Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;)V public fun addListener (Lru/nsk/kstatemachine/state/IState$Listener;)Lru/nsk/kstatemachine/state/IState$Listener; - public fun addState (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/IState; + public fun addState (Lru/nsk/kstatemachine/state/IState;)Lru/nsk/kstatemachine/state/IState; public fun addTransition (Lru/nsk/kstatemachine/transition/Transition;)Lru/nsk/kstatemachine/transition/Transition; public fun asState ()Lru/nsk/kstatemachine/state/BaseStateImpl; public synthetic fun asState ()Lru/nsk/kstatemachine/state/IState; @@ -295,7 +324,7 @@ public abstract interface class ru/nsk/kstatemachine/state/IState : ru/nsk/kstat public abstract fun accept (Lru/nsk/kstatemachine/visitors/CoVisitor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun accept (Lru/nsk/kstatemachine/visitors/Visitor;)V public abstract fun addListener (Lru/nsk/kstatemachine/state/IState$Listener;)Lru/nsk/kstatemachine/state/IState$Listener; - public abstract fun addState (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/IState; + public abstract fun addState (Lru/nsk/kstatemachine/state/IState;)Lru/nsk/kstatemachine/state/IState; public abstract fun getChildMode ()Lru/nsk/kstatemachine/state/ChildMode; public abstract fun getInitialState ()Lru/nsk/kstatemachine/state/IState; public abstract fun getListeners ()Ljava/util/Collection; @@ -318,7 +347,6 @@ public abstract interface class ru/nsk/kstatemachine/state/IState : ru/nsk/kstat public final class ru/nsk/kstatemachine/state/IState$DefaultImpls { public static fun accept (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/visitors/CoVisitor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun accept (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/visitors/Visitor;)V - public static synthetic fun addState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/IState; public static fun onCleanup (Lru/nsk/kstatemachine/state/IState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onStopped (Lru/nsk/kstatemachine/state/IState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -338,34 +366,36 @@ public final class ru/nsk/kstatemachine/state/IState$Listener$DefaultImpls { public final class ru/nsk/kstatemachine/state/IStateKt { public static final fun activeStates (Lru/nsk/kstatemachine/state/IState;Z)Ljava/util/Set; public static synthetic fun activeStates$default (Lru/nsk/kstatemachine/state/IState;ZILjava/lang/Object;)Ljava/util/Set; - public static final fun addFinalState (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IFinalState;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/IFinalState; - public static synthetic fun addFinalState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IFinalState;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/IFinalState; - public static final fun addInitialState (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/IState; - public static synthetic fun addInitialState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/IState; + public static final fun addFinalState (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IFinalState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun addFinalState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IFinalState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun addInitialState (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun addInitialState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun addState (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun addState$default (Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun choiceState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lru/nsk/kstatemachine/state/pseudo/DefaultChoiceState; public static synthetic fun choiceState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/pseudo/DefaultChoiceState; - public static final fun finalState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/DefaultFinalState; - public static synthetic fun finalState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/DefaultFinalState; + public static final fun finalState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun finalState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun findState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Z)Lru/nsk/kstatemachine/state/IState; public static final fun findState (Lru/nsk/kstatemachine/state/IState;Lkotlin/reflect/KClass;Z)Lru/nsk/kstatemachine/state/IState; public static synthetic fun findState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;ZILjava/lang/Object;)Lru/nsk/kstatemachine/state/IState; public static synthetic fun findState$default (Lru/nsk/kstatemachine/state/IState;Lkotlin/reflect/KClass;ZILjava/lang/Object;)Lru/nsk/kstatemachine/state/IState; public static final fun historyState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/HistoryType;)Lru/nsk/kstatemachine/state/pseudo/DefaultHistoryState; public static synthetic fun historyState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/IState;Lru/nsk/kstatemachine/state/HistoryType;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/pseudo/DefaultHistoryState; - public static final fun initialChoiceState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lru/nsk/kstatemachine/state/pseudo/DefaultChoiceState; - public static synthetic fun initialChoiceState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/pseudo/DefaultChoiceState; - public static final fun initialFinalState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/DefaultFinalState; - public static synthetic fun initialFinalState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/DefaultFinalState; - public static final fun initialState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/DefaultState; - public static synthetic fun initialState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/DefaultState; - public static final fun invoke (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)V + public static final fun initialChoiceState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun initialChoiceState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun initialFinalState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun initialFinalState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun initialState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun initialState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun invoke (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun log (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun machineOrNull (Lru/nsk/kstatemachine/state/IState;)Lru/nsk/kstatemachine/statemachine/StateMachine; public static final fun requireInitialState (Lru/nsk/kstatemachine/state/IState;)Lru/nsk/kstatemachine/state/IState; public static final fun requireState (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Z)Lru/nsk/kstatemachine/state/IState; public static synthetic fun requireState$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;ZILjava/lang/Object;)Lru/nsk/kstatemachine/state/IState; - public static final fun state (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/DefaultState; - public static synthetic fun state$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lru/nsk/kstatemachine/state/DefaultState; + public static final fun state (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun state$default (Lru/nsk/kstatemachine/state/IState;Ljava/lang/String;Lru/nsk/kstatemachine/state/ChildMode;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract class ru/nsk/kstatemachine/state/InternalState : ru/nsk/kstatemachine/state/IState, ru/nsk/kstatemachine/state/InternalNode { @@ -443,8 +473,8 @@ public class ru/nsk/kstatemachine/state/pseudo/BasePseudoState : ru/nsk/kstatema public fun (Ljava/lang/String;)V public fun addListener (Lru/nsk/kstatemachine/state/IState$Listener;)Ljava/lang/Void; public synthetic fun addListener (Lru/nsk/kstatemachine/state/IState$Listener;)Lru/nsk/kstatemachine/state/IState$Listener; - public fun addState (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)Ljava/lang/Void; - public synthetic fun addState (Lru/nsk/kstatemachine/state/IState;Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/state/IState; + public fun addState (Lru/nsk/kstatemachine/state/IState;)Ljava/lang/Void; + public synthetic fun addState (Lru/nsk/kstatemachine/state/IState;)Lru/nsk/kstatemachine/state/IState; public fun addTransition (Lru/nsk/kstatemachine/transition/Transition;)Ljava/lang/Void; public synthetic fun addTransition (Lru/nsk/kstatemachine/transition/Transition;)Lru/nsk/kstatemachine/transition/Transition; } diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/Event.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/Event.kt index c0e3169..ff7756f 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/Event.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/Event.kt @@ -11,7 +11,6 @@ import ru.nsk.kstatemachine.state.DataState import ru.nsk.kstatemachine.state.FinalDataState import ru.nsk.kstatemachine.state.IState import ru.nsk.kstatemachine.statemachine.StateMachine -import ru.nsk.kstatemachine.statemachine.processEventBlocking import ru.nsk.kstatemachine.statemachine.undo /** @@ -79,4 +78,16 @@ internal class DestroyEvent(val stop: Boolean) : GeneratedEvent * @param event original event * @param argument original argument */ -class WrappedEvent(val event: Event, val argument: Any?) : GeneratedEvent \ No newline at end of file +class WrappedEvent(val event: Event, val argument: Any?) : GeneratedEvent + +/** + * Special kind of event, which is not processed by a stateMachine itself but used to + * represent different kinds of [GeneratedEvent] in serialized form for event recording feature. + */ +data class SerializableGeneratedEvent(val eventType: EventType) : GeneratedEvent { + sealed interface EventType { + object Start: EventType + object Stop: EventType + class Destroy(val stop: Boolean) : EventType + } +} \ No newline at end of file diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/EventRecorder.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/EventRecorder.kt index ad90951..523e2f9 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/EventRecorder.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/EventRecorder.kt @@ -8,7 +8,9 @@ package ru.nsk.kstatemachine.persistence import ru.nsk.kstatemachine.VisibleForTesting +import ru.nsk.kstatemachine.event.* import ru.nsk.kstatemachine.event.DestroyEvent +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent.* import ru.nsk.kstatemachine.event.StopEvent import ru.nsk.kstatemachine.statemachine.* import ru.nsk.kstatemachine.transition.EventAndArgument @@ -47,6 +49,8 @@ class RecordedEvents @VisibleForTesting constructor( result = 31 * result + records.hashCode() return result } + + override fun toString() = "RecordedEvents(structureHashCode=$structureHashCode, records=$records)" } class Record @VisibleForTesting constructor( @@ -70,6 +74,8 @@ class Record @VisibleForTesting constructor( result = 31 * result + processingResult.hashCode() return result } + + override fun toString() = "Record(eventAndArgument=$eventAndArgument, processingResult=$processingResult)" } internal class EventRecorderImpl( @@ -79,21 +85,41 @@ internal class EventRecorderImpl( private val records = mutableListOf() /** - * Should be called with not wrapped event. + * Should be called with not wrapped event in [eventAndArgument]. * Should not be called on [ProcessingResult.PENDING] events. */ fun onProcessEvent(eventAndArgument: EventAndArgument<*>, processingResult: ProcessingResult) { val lastEvent = records.lastOrNull()?.eventAndArgument?.event - check(lastEvent !is DestroyEvent) { + val lastEventType = (lastEvent as? SerializableGeneratedEvent)?.eventType + + check(lastEventType !is EventType.Destroy) { "Internal error, ${::onProcessEvent.name} called after " + "${DestroyEvent::class.simpleName} processing, which is considered as last possible event" } if (arguments.skipIgnoredEvents && processingResult == ProcessingResult.IGNORED) return - if (arguments.clearRecordsOnMachineRestart && lastEvent is StopEvent) records.clear() - records += Record(eventAndArgument, processingResult) + if (arguments.clearRecordsOnMachineRestart && lastEventType == EventType.Stop) records.clear() + eventAndArgument.transformGeneratedEvent()?.let { + records += Record(it, processingResult) + } } override fun getRecordedEvents(): RecordedEvents { - return RecordedEvents(machine.structureHashCode, records) + return RecordedEvents(machine.structureHashCode, records.toList() /* defensive copy */) + } +} + +private fun EventAndArgument<*>.transformGeneratedEvent(): EventAndArgument<*>? { + return if (event is GeneratedEvent) { + val event = when (event) { + is StartEvent -> SerializableGeneratedEvent(EventType.Start) + is StopEvent -> SerializableGeneratedEvent(EventType.Stop) + is DestroyEvent -> SerializableGeneratedEvent(EventType.Destroy(event.stop)) + is FinishedEvent -> return null // ignore as it will be regenerated by the library itself + is WrappedEvent -> error("Never get here") + is SerializableGeneratedEvent -> error("Never get here, SerializableGeneratedEvent should not be processed") + } + EventAndArgument(event, argument) + } else { + this } } \ No newline at end of file diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEvents.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEvents.kt index b65ef93..15496e6 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEvents.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEvents.kt @@ -7,6 +7,7 @@ package ru.nsk.kstatemachine.persistence +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent import ru.nsk.kstatemachine.event.StartEvent import ru.nsk.kstatemachine.statemachine.* import ru.nsk.kstatemachine.visitors.structureHashCode @@ -83,29 +84,39 @@ suspend fun StateMachine.restoreByRecordedEvents( recordedEvents.records.forEachIndexed iteration@{ index, record -> val warnings = mutableListOf() val (event, argument) = record.eventAndArgument - if (event is StartEvent) { - if (isRunning) { - if (argument == null) { - results += RestoredEventResult(record, Result.success(ProcessingResult.PROCESSED), warnings) - return@iteration // continue - } else { - if (index == 0) - error( - "The ${StateMachine::class.simpleName} is already started, but " + - "the ${RecordedEvents::class.simpleName} contains an argument for " + - "${StateMachine::start.name} method. " + - "To restore such machine, " + - "do not start it before calling ${::restoreByRecordedEvents.name}" - ) - else { - destroy() - error("The machine should not be running here. Internal error. Never get here") + if (event is SerializableGeneratedEvent) { + when (val eventType = event.eventType) { + SerializableGeneratedEvent.EventType.Start -> { + if (isRunning) { + if (argument == null) { + results += RestoredEventResult( + record, + Result.success(ProcessingResult.PROCESSED), + warnings + ) + return@iteration // continue + } else { + if (index == 0) + error( + "The ${StateMachine::class.simpleName} is already started, but " + + "the ${RecordedEvents::class.simpleName} contains an argument for " + + "${StateMachine::start.name} method. " + + "To restore such machine, " + + "do not start it before calling ${::restoreByRecordedEvents.name}" + ) + else { + destroy() + error("The machine should not be running here. Internal error. Never get here") + } + } + } else { + start(argument) } } - } else { - start(argument) - results += RestoredEventResult(record, Result.success(ProcessingResult.PROCESSED), warnings) + SerializableGeneratedEvent.EventType.Stop -> stop() + is SerializableGeneratedEvent.EventType.Destroy -> destroy(eventType.stop) } + results += RestoredEventResult(record, Result.success(ProcessingResult.PROCESSED), warnings) } else { val processingResult = runCatching { processEvent(event, argument) } val actualResult = processingResult.getOrNull() diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/BaseStateImpl.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/BaseStateImpl.kt index f7f2b0d..6c2f71f 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/BaseStateImpl.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/BaseStateImpl.kt @@ -103,7 +103,7 @@ open class BaseStateImpl( data.listeners.remove(listener) } - override fun addState(state: S, init: StateBlock?): S { + override fun addState(state: S): S { if (machineOrNull()?.isRunning == true) error("Can not add state after state machine started") if (childMode == PARALLEL) { require(state !is IFinalState) { "Can not add IFinalState in parallel child mode" } @@ -117,8 +117,6 @@ open class BaseStateImpl( state as InternalState require(data.states.add(state)) { "$state already added" } state.setParent(this) - - if (init != null) state.init() return state } diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt index b92d9b7..1c32a4c 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt @@ -56,7 +56,7 @@ interface IState : TransitionStateApi, VisitorAcceptor { fun addListener(listener: L): L fun removeListener(listener: Listener) - fun addState(state: S, init: StateBlock? = null): S + fun addState(state: S): S /** * Initial child state is required if child mode is [ChildMode.EXCLUSIVE] and a state has children @@ -91,6 +91,12 @@ interface IState : TransitionStateApi, VisitorAcceptor { } } +suspend fun IState.addState(state: S, init: StateBlock? = null): S { + addState(state) + if (init != null) state.init() + return state +} + enum class ChildMode { EXCLUSIVE, PARALLEL } enum class HistoryType { /** Records only immediate child states */ @@ -155,7 +161,7 @@ interface HistoryState : PseudoState { val storedState: IState } -typealias StateBlock = S.() -> Unit +typealias StateBlock = suspend S.() -> Unit suspend fun IState.log(lazyMessage: () -> String) { machineOrNull()?.logger?.log(lazyMessage) @@ -230,7 +236,7 @@ fun IState.findState(`class`: KClass, recursive: Boolean = true) inline fun IState.requireState(recursive: Boolean = true) = requireNotNull(findState(recursive)) { "State ${S::class.simpleName} not found" } -operator fun S.invoke(block: StateBlock) = block() +suspend operator fun S.invoke(block: StateBlock) = block() fun IState.machineOrNull(): StateMachine? = if (this is StateMachine) this else parent?.machineOrNull() @@ -238,13 +244,13 @@ fun IState.machineOrNull(): StateMachine? = if (this is StateMachine) this else * @param name is optional and is useful for getting state instance after state machine setup * with [IState.findState] and for debugging. */ -fun IState.state( +suspend fun IState.state( name: String? = null, childMode: ChildMode = ChildMode.EXCLUSIVE, init: StateBlock? = null ) = addState(DefaultState(name, childMode), init) -inline fun IState.dataState( +suspend inline fun IState.dataState( name: String? = null, defaultData: D? = null, childMode: ChildMode = ChildMode.EXCLUSIVE, @@ -255,7 +261,7 @@ inline fun IState.dataState( /** * A shortcut for [state] and [IState.setInitialState] calls */ -fun IState.initialState( +suspend fun IState.initialState( name: String? = null, childMode: ChildMode = ChildMode.EXCLUSIVE, init: StateBlock? = null @@ -264,7 +270,7 @@ fun IState.initialState( /** * @param defaultData is necessary for initial [DataState] */ -inline fun IState.initialDataState( +suspend inline fun IState.initialDataState( name: String? = null, defaultData: D, childMode: ChildMode = ChildMode.EXCLUSIVE, @@ -275,7 +281,7 @@ inline fun IState.initialDataState( /** * A shortcut for [IState.addState] and [IState.setInitialState] calls */ -fun IState.addInitialState(state: S, init: StateBlock? = null): S { +suspend fun IState.addInitialState(state: S, init: StateBlock? = null): S { addState(state, init) setInitialState(state) return state @@ -285,23 +291,23 @@ fun IState.addInitialState(state: S, init: StateBlock? = null): * Helper method for adding final states. This is exactly the same as simply call [IState.addState] but makes * code more self expressive. */ -fun IState.addFinalState(state: S, init: StateBlock? = null) = +suspend fun IState.addFinalState(state: S, init: StateBlock? = null) = addState(state, init) -fun IState.finalState(name: String? = null, init: StateBlock? = null) = +suspend fun IState.finalState(name: String? = null, init: StateBlock? = null) = addFinalState(DefaultFinalState(name), init) -fun IState.initialFinalState(name: String? = null, init: StateBlock? = null) = +suspend fun IState.initialFinalState(name: String? = null, init: StateBlock? = null) = addInitialState(DefaultFinalState(name), init) -inline fun IState.finalDataState( +suspend inline fun IState.finalDataState( name: String? = null, defaultData: D? = null, dataExtractor: DataExtractor = defaultDataExtractor(), noinline init: StateBlock>? = null ) = addFinalState(defaultFinalDataState(name, defaultData, dataExtractor), init) -inline fun IState.initialFinalDataState( +suspend inline fun IState.initialFinalDataState( name: String? = null, defaultData: D? = null, dataExtractor: DataExtractor = defaultDataExtractor(), @@ -313,7 +319,7 @@ fun IState.choiceState( choiceAction: suspend EventAndArgument<*>.() -> State ) = addState(DefaultChoiceState(name, choiceAction = choiceAction)) -fun IState.initialChoiceState( +suspend fun IState.initialChoiceState( name: String? = null, choiceAction: suspend EventAndArgument<*>.() -> State ) = addInitialState(DefaultChoiceState(name, choiceAction = choiceAction)) @@ -323,7 +329,7 @@ inline fun IState.choiceDataState( noinline choiceAction: suspend EventAndArgument<*>.() -> DataState ) = addState(DefaultChoiceDataState(name, D::class, choiceAction = choiceAction)) -inline fun IState.initialChoiceDataState( +suspend inline fun IState.initialChoiceDataState( name: String? = null, noinline choiceAction: suspend EventAndArgument<*>.() -> DataState ) = addInitialState(DefaultChoiceDataState(name, D::class, choiceAction = choiceAction)) diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/BasePseudoState.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/BasePseudoState.kt index 9b8c7db..ae297ec 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/BasePseudoState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/BasePseudoState.kt @@ -19,7 +19,7 @@ open class BasePseudoState(name: String?) : BaseStateImpl(name, ChildMode.EXCLUS override fun addListener(listener: L) = throw UnsupportedOperationException("PseudoState $this can not have listeners") - override fun addState(state: S, init: StateBlock?) = + override fun addState(state: S) = throw UnsupportedOperationException("PseudoState $this can not have child states") diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/CreationArguments.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/CreationArguments.kt index 4c5bdb7..648a366 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/CreationArguments.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/CreationArguments.kt @@ -90,4 +90,6 @@ private data class EventRecordingArgumentsBuilderImpl( ) : EventRecordingArgumentsBuilder fun buildEventRecordingArguments(builder: EventRecordingArgumentsBuilder.() -> Unit): EventRecordingArguments = - EventRecordingArgumentsBuilderImpl().apply(builder).copy() \ No newline at end of file + EventRecordingArgumentsBuilderImpl().apply(builder).copy() + + diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt index c99bc49..9383d54 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt @@ -216,6 +216,7 @@ fun StateMachine.stopBlocking() = coroutineAbstraction.runBlocking { stop() } /** * Destroys machine structure clearing all listeners, states etc. + * This a terminal operation, means that machine cannot be used anymore. */ suspend fun StateMachine.destroy(stop: Boolean = true) = coroutineAbstraction.withContext { if (isDestroyed) return@withContext diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionBuilder.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionBuilder.kt index 8fc5ccf..2517ed6 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionBuilder.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionBuilder.kt @@ -24,7 +24,8 @@ abstract class TransitionBuilder(protected val name: String?, protect var type = TransitionType.LOCAL var metaInfo: MetaInfo? = null - abstract fun build(): Transition + @PublishedApi + internal abstract fun build(): Transition } abstract class BaseGuardedTransitionBuilder(name: String?, sourceState: IState) : @@ -36,6 +37,7 @@ abstract class GuardedTransitionBuilder(name: String?, so BaseGuardedTransitionBuilder(name, sourceState) { var targetState: S? = null + @PublishedApi override fun build(): Transition { val direction: TransitionDirectionProducer = { when (it) { @@ -59,6 +61,7 @@ abstract class GuardedTransitionOnBuilder(name: String?, BaseGuardedTransitionBuilder(name, sourceState) { lateinit var targetState: suspend EventAndArgument.() -> S + @PublishedApi override fun build(): Transition { val direction: TransitionDirectionProducer = { policy -> when (policy) { @@ -82,6 +85,7 @@ class ConditionalTransitionBuilder(name: String?, sourceState: IState TransitionBuilder(name, sourceState) { lateinit var direction: suspend EventAndArgument.() -> TransitionDirection + @PublishedApi override fun build(): Transition { val direction: TransitionDirectionProducer = { policy -> when (policy) { @@ -114,6 +118,7 @@ class DataGuardedTransitionBuilder, D : Any>(name: String?, sou /** User should initialize this filed */ lateinit var targetState: DataState + @PublishedApi override fun build(): Transition { require(this::targetState.isInitialized) { "targetState should be set in this transition builder" } val direction: TransitionDirectionProducer = { policy -> diff --git a/samples/build.gradle.kts b/samples/build.gradle.kts index 9cebac3..8c7a8af 100644 --- a/samples/build.gradle.kts +++ b/samples/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("multiplatform") application + kotlin("plugin.serialization") version Versions.kotlin } group = rootProject.group @@ -14,6 +15,9 @@ kotlin { commonMain { dependencies { implementation(project(":kstatemachine-coroutines")) + implementation(project(":kstatemachine-serialization")) + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serialization}") } } } diff --git a/samples/src/commonMain/kotlin/ru/nsk/samples/SerializationEventRecordingSample.kt b/samples/src/commonMain/kotlin/ru/nsk/samples/SerializationEventRecordingSample.kt new file mode 100644 index 0000000..878287f --- /dev/null +++ b/samples/src/commonMain/kotlin/ru/nsk/samples/SerializationEventRecordingSample.kt @@ -0,0 +1,101 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +@file:Suppress("SuspendFunctionOnCoroutineScope") + +package ru.nsk.samples + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.* +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.persistence.RecordedEvents +import ru.nsk.kstatemachine.persistence.restoreByRecordedEvents +import ru.nsk.kstatemachine.serialization.persistence.KStateMachineSerializersModule +import ru.nsk.kstatemachine.state.* +import ru.nsk.kstatemachine.statemachine.StateMachine +import ru.nsk.kstatemachine.statemachine.buildCreationArguments +import ru.nsk.kstatemachine.statemachine.buildEventRecordingArguments +import ru.nsk.kstatemachine.statemachine.createStateMachine +import ru.nsk.samples.SerializationEventRecordingSample.Event1 +import ru.nsk.samples.SerializationEventRecordingSample.Event2 + +private object SerializationEventRecordingSample { + @Serializable + class Event1(val data: Int) : Event + + @Serializable + class Event2(val data: String) : Event +} + +private suspend fun CoroutineScope.createMachine(): StateMachine { + lateinit var state2: State + lateinit var state3: State + return createStateMachine( + this, + "Event recording", + creationArguments = buildCreationArguments { eventRecordingArguments = buildEventRecordingArguments { } } + ) { + logger = StateMachine.Logger { println(it()) } + initialState("State1") { + transitionOn { targetState = { state2 } } + } + state2 = state("State2") { + transitionOn { targetState = { state3 } } + } + state3 = state("State3") + } +} + +private suspend fun CoroutineScope.persistStep(jsonFormat: Json): String { + val originalMachine = createMachine() + + originalMachine.processEvent(Event1(42)) + originalMachine.processEvent(Event2("text")) + + val recordedEvents = originalMachine.eventRecorder.getRecordedEvents() + + return jsonFormat.encodeToString(recordedEvents) +} + +private suspend fun CoroutineScope.restoreStep(jsonFormat: Json, recordedEventsJson: String): StateMachine { + val recordedEvents = jsonFormat.decodeFromString(recordedEventsJson) + + val restoredMachine = createMachine() + restoredMachine.restoreByRecordedEvents(recordedEvents) + return restoredMachine +} + +/** + * The sample shows how original state machine's state is recorded into [RecordedEvents] object + * and another machine instance restored from it. And how [RecordedEvents] can be serialized using + * `kotlinx.serialization` library. + */ +fun main(): Unit = runBlocking { + val jsonFormat = Json { + // use special, library provided SerializersModule for RecordedEvents and its internals + // from kstatemachine-serialization artifact + serializersModule = KStateMachineSerializersModule + SerializersModule { + polymorphic(Event::class) { + subclass(Event1::class) + subclass(Event2::class) + } + } + } + + val recordedEventsJson = persistStep(jsonFormat) + println(recordedEventsJson) + + // assume we need to restore the machine some time later + val restoredMachine = restoreStep(jsonFormat, recordedEventsJson) + + // restoredMachine is in the same state as original one + check(restoredMachine.activeStates().single().name == "State3") +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d27462..317fe4d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "KStateMachine" include( "kstatemachine", "kstatemachine-coroutines", + "kstatemachine-serialization", "samples", - "tests" + "tests", ) \ No newline at end of file diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index b9c9f78..e6e3d30 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("multiplatform") + kotlin("plugin.serialization") version Versions.kotlin } group = rootProject.group @@ -17,6 +18,7 @@ kotlin { commonTest { dependencies { implementation(project(":kstatemachine-coroutines")) + implementation(project(":kstatemachine-serialization")) implementation("io.kotest:kotest-assertions-core:${Versions.kotest}") implementation("io.kotest:kotest-framework-datatest:${Versions.kotest}") diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/TestUtils.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/TestUtils.kt index 2408f46..7adcf47 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/TestUtils.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/TestUtils.kt @@ -79,10 +79,12 @@ enum class CoroutineStarterType { COROUTINES_LIB_DEFAULT_LIMITED_DISPATCHER, } +@OptIn(ExperimentalCoroutinesApi::class) +private val singleThreadContext = newSingleThreadContext("test single thread context") // context leaks /** * Wraps [createStdLibStateMachine] so it can be easily switched to [createStdLibStateMachine] */ -fun createTestStateMachine( +suspend fun createTestStateMachine( coroutineStarterType: CoroutineStarterType, name: String? = null, childMode: ChildMode = ChildMode.EXCLUSIVE, @@ -97,7 +99,7 @@ fun createTestStateMachine( creationArguments, init = init ) - CoroutineStarterType.COROUTINES_LIB_EMPTY_CONTEXT -> createStateMachineBlocking( + CoroutineStarterType.COROUTINES_LIB_EMPTY_CONTEXT -> createStateMachine( CoroutineScope(EmptyCoroutineContext), // does not perform internal context switching name, childMode, @@ -105,7 +107,7 @@ fun createTestStateMachine( creationArguments, init = init ) - CoroutineStarterType.COROUTINES_LIB_UNCONFINED_DISPATCHER -> createStateMachineBlocking( + CoroutineStarterType.COROUTINES_LIB_UNCONFINED_DISPATCHER -> createStateMachine( CoroutineScope(Dispatchers.Unconfined), name, childMode, @@ -113,15 +115,15 @@ fun createTestStateMachine( creationArguments, init = init ) - CoroutineStarterType.COROUTINES_LIB_SINGLE_THREAD_DISPATCHER -> createStateMachineBlocking( - CoroutineScope(newSingleThreadContext("test single thread context")), // fixme context leaks + CoroutineStarterType.COROUTINES_LIB_SINGLE_THREAD_DISPATCHER -> createStateMachine( + CoroutineScope(singleThreadContext), name, childMode, start, creationArguments, init = init ) - CoroutineStarterType.COROUTINES_LIB_DEFAULT_LIMITED_DISPATCHER -> createStateMachineBlocking( + CoroutineStarterType.COROUTINES_LIB_DEFAULT_LIMITED_DISPATCHER -> createStateMachine( CoroutineScope(Dispatchers.Default.limitedParallelism(1)), // does not guarantee same thread for each task name, childMode, diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/coroutines/CoroutinesTest.kt similarity index 98% rename from tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt rename to tests/src/commonTest/kotlin/ru/nsk/kstatemachine/coroutines/CoroutinesTest.kt index a67b465..6768de4 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/coroutines/CoroutinesTest.kt @@ -5,7 +5,7 @@ * All rights reserved. */ -package ru.nsk.kstatemachine +package ru.nsk.kstatemachine.coroutines import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec @@ -15,8 +15,11 @@ import io.mockk.verifySequence import kotlinx.coroutines.* import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take +import ru.nsk.kstatemachine.SecondEvent +import ru.nsk.kstatemachine.SwitchEvent import ru.nsk.kstatemachine.statemachine.StateMachineNotification.* import ru.nsk.kstatemachine.event.StartEvent +import ru.nsk.kstatemachine.mockkCallbacks import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.statemachine.* import ru.nsk.kstatemachine.transition.onTriggered diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/event/FinishedEventTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/event/FinishedEventTest.kt index 9e95203..ca4c516 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/event/FinishedEventTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/event/FinishedEventTest.kt @@ -12,8 +12,11 @@ import io.kotest.matchers.shouldBe import io.mockk.verifySequence import ru.nsk.kstatemachine.* import ru.nsk.kstatemachine.state.* +import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.statemachine.processEventBlocking +import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.onTriggered +import ru.nsk.kstatemachine.visitors.export.exportToPlantUml class FinishedEventTest : StringSpec({ CoroutineStarterType.entries.forEach { coroutineStarterType -> @@ -34,7 +37,7 @@ class FinishedEventTest : StringSpec({ transitionOn { targetState = { state2 } } } - machine.processEventBlocking(SwitchEvent) + machine.processEvent(SwitchEvent) verifySequence { callbacks.onStateFinished(machine) } machine.isFinished shouldBe true @@ -59,16 +62,47 @@ class FinishedEventTest : StringSpec({ } } - machine.processEventBlocking(SwitchEvent) + machine.processEvent(SwitchEvent) verifySequence { callbacks.onStateFinished(state1) callbacks.onStateEntry(state2) } + state1.isActive shouldBe false state1.isFinished shouldBe false machine.isFinished shouldBe false } + "FinishedEvent relies on queuePendingEventHandler" { + val callbacks = mockkCallbacks() + lateinit var state1: State + lateinit var state2: State + val machine = createTestStateMachine(coroutineStarterType) { + pendingEventHandler = StateMachine.PendingEventHandler { /*ignore FinishedEvent*/ } + state1 = initialState("state1") { + val final = finalState("final") + initialState("state11") { + transition(targetState = final) + } + + onFinished { callbacks.onStateFinished(this) } + transitionOn { targetState = { state2 } } + } + state2 = state("state2") { + callbacks.listen(this) + } + } + + machine.processEvent(SwitchEvent) + + verifySequence { + callbacks.onStateFinished(state1) + } + state1.isActive shouldBe true + state1.isFinished shouldBe true + machine.isFinished shouldBe false + } + "FinishedEvent with data" { val callbacks = mockkCallbacks() @@ -97,7 +131,7 @@ class FinishedEventTest : StringSpec({ } } } - machine.processEventBlocking(IntEvent(intData)) + machine.processEvent(IntEvent(intData)) verifySequence { callbacks.onTransitionTriggered(ofType()) } diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/EventRecorderTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/EventRecorderTest.kt index b9f8109..278f9d0 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/EventRecorderTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/EventRecorderTest.kt @@ -10,15 +10,18 @@ package ru.nsk.kstatemachine.persistence import io.kotest.assertions.throwables.shouldThrowWithMessage import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.mpp.start import ru.nsk.kstatemachine.* -import ru.nsk.kstatemachine.event.StartEvent +import ru.nsk.kstatemachine.event.FinishedEvent +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent import ru.nsk.kstatemachine.event.UndoEvent -import ru.nsk.kstatemachine.state.initialState -import ru.nsk.kstatemachine.state.transition +import ru.nsk.kstatemachine.event.WrappedEvent +import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.statemachine.* import ru.nsk.kstatemachine.statemachine.ProcessingResult.IGNORED import ru.nsk.kstatemachine.statemachine.ProcessingResult.PROCESSED @@ -37,6 +40,34 @@ class EventRecorderTest : StringSpec({ ) { machine.eventRecorder } } + "negative process SerializableGeneratedEvent" { + val machine = createTestStateMachine( + coroutineStarterType, + creationArguments = buildCreationArguments { + eventRecordingArguments = buildEventRecordingArguments { skipIgnoredEvents = false } + } + ) { + initialState() + } + shouldThrowWithMessage("Never get here, SerializableGeneratedEvent should not be processed") { + machine.processEvent(SerializableGeneratedEvent(SerializableGeneratedEvent.EventType.Start)) + } + } + + "negative process WrappedEvent" { + val machine = createTestStateMachine( + coroutineStarterType, + creationArguments = buildCreationArguments { + eventRecordingArguments = buildEventRecordingArguments { skipIgnoredEvents = false } + } + ) { + initialState() + } + shouldThrowWithMessage("Never get here") { + machine.processEvent(WrappedEvent(SwitchEvent, argument = null)) + } + } + "check recorded events with arguments" { val machine = createTestStateMachine( coroutineStarterType, @@ -52,13 +83,38 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() recordedEvents.structureHashCode shouldBe machine.structureHashCode - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContainInOrder( Record(EventAndArgument(FirstEvent, null), PROCESSED), Record(EventAndArgument(SecondEvent, 2), PROCESSED), ) } + "check recorded events with start argument" { + val machine = createTestStateMachine( + coroutineStarterType, + start = false, + creationArguments = buildCreationArguments { eventRecordingArguments = buildEventRecordingArguments {} } + ) { + val state1 = state("state1") + val state2 = state("state2") + initialChoiceState { + if (argument != 2) state1 else state2 + } + } + machine.start(argument = 2) + + val recordedEvents = machine.eventRecorder.getRecordedEvents() + recordedEvents.structureHashCode shouldBe machine.structureHashCode + + recordedEvents.records.shouldContainExactly( + Record( + EventAndArgument(SerializableGeneratedEvent(SerializableGeneratedEvent.EventType.Start), 2), + PROCESSED + ) + ) + } + "check recorded events and undo" { val machine = createTestStateMachine( coroutineStarterType, @@ -75,7 +131,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContainInOrder( Record(EventAndArgument(SwitchEvent, null), PROCESSED), Record(EventAndArgument(UndoEvent, null), PROCESSED), @@ -95,7 +151,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContain( Record(EventAndArgument(SwitchEvent, null), PROCESSED), ) @@ -115,13 +171,33 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContain( Record(EventAndArgument(SwitchEvent, null), PROCESSED), ) recordedEvents.records shouldHaveSize 3 // DestroyEvent } + "check recorded events with FinishedEvent" { + lateinit var state2: State + val machine = createTestStateMachine( + coroutineStarterType, + creationArguments = buildCreationArguments { eventRecordingArguments = buildEventRecordingArguments {} } + ) { + state2 = state("state2") + initialState("state1") { + initialFinalState() + transition(targetState = state2) + } + } + + val recordedEvents = machine.eventRecorder.getRecordedEvents() + + recordedEvents.firstEventShouldBeStart() + recordedEvents.records shouldHaveSize 1 + machine.activeStates().shouldContainExactly(state2) + } + "check recorded events on restart without ${EventRecordingArguments::clearRecordsOnMachineRestart.name} flag" { val machine = createTestStateMachine( coroutineStarterType, @@ -141,7 +217,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContainInOrder( Record(EventAndArgument(FirstEvent, null), PROCESSED), Record(EventAndArgument(SecondEvent, null), PROCESSED), @@ -164,7 +240,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContain( Record(EventAndArgument(SecondEvent, null), PROCESSED), ) @@ -182,7 +258,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records shouldHaveSize 1 } @@ -201,7 +277,7 @@ class EventRecorderTest : StringSpec({ val recordedEvents = machine.eventRecorder.getRecordedEvents() - recordedEvents.records.first().eventAndArgument.event.shouldBeInstanceOf() + recordedEvents.firstEventShouldBeStart() recordedEvents.records.shouldContain( Record(EventAndArgument(SwitchEvent, null), IGNORED), ) @@ -209,3 +285,9 @@ class EventRecorderTest : StringSpec({ } } }) + +private fun RecordedEvents.firstEventShouldBeStart() { + val firstEvent = records.first().eventAndArgument.event + firstEvent.shouldBeInstanceOf() + firstEvent.eventType shouldBe SerializableGeneratedEvent.EventType.Start +} \ No newline at end of file diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEventsTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEventsTest.kt index b673df4..54f9eae 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEventsTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEventsTest.kt @@ -17,7 +17,6 @@ import io.mockk.verifySequence import ru.nsk.kstatemachine.* import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.statemachine.* -import ru.nsk.kstatemachine.statemachine.StateMachine.* class RestoreByRecordedEventsTest : StringSpec({ CoroutineStarterType.entries.forEach { coroutineStarterType -> diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializerTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializerTest.kt new file mode 100644 index 0000000..c16765f --- /dev/null +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/serialization/persistence/RecordedEventsSerializerTest.kt @@ -0,0 +1,179 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.serialization.persistence + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import ru.nsk.kstatemachine.event.DataEvent +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.event.SerializableGeneratedEvent.EventType +import ru.nsk.kstatemachine.persistence.RecordedEvents +import ru.nsk.kstatemachine.persistence.restoreByRecordedEvents +import ru.nsk.kstatemachine.state.* +import ru.nsk.kstatemachine.statemachine.StateMachine +import ru.nsk.kstatemachine.statemachine.buildCreationArguments +import ru.nsk.kstatemachine.statemachine.buildEventRecordingArguments +import ru.nsk.kstatemachine.statemachine.createStateMachine + +@Serializable +private class Event1(val data: Int) : Event + +@Serializable +private class Event2(val data: String) : Event + +@Serializable +private class IntData(val value: Int) + +@Serializable +private class IntDataEvent(override val data: IntData) : DataEvent + +/** + * Primitives cannot be serialized in polymorphic context with default serializers. + */ +private object StringPolymorphicSerializer : KSerializer { + + override val descriptor = buildClassSerialDescriptor("com.sample.kotlin.String") { + element("value") + } + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeStructure(descriptor) { + encodeStringElement(descriptor, 0, value) + } + } + + override fun deserialize(decoder: Decoder): String { + return decoder.decodeStructure(descriptor) { + var value = Result.failure(NullPointerException("value is absent")) + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> value = Result.success(decodeStringElement(descriptor, 0)) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + value.getOrThrow() + } + } +} + +class RecordedEventsSerializerTest : StringSpec({ + "Serialize and restore state machine with RecordedEvents" { + suspend fun CoroutineScope.createMachine(): StateMachine { + lateinit var state2: State + lateinit var state3: State + return createStateMachine( + this, + "Event recording", + creationArguments = buildCreationArguments { + eventRecordingArguments = buildEventRecordingArguments { } + } + ) { + logger = StateMachine.Logger { println(it()) } + initialState("State1") { + transitionOn { targetState = { state2 } } + } + state2 = state("State2") { + transitionOn { targetState = { state3 } } + } + state3 = state("State3") + } + } + + val jsonFormat = Json { + serializersModule = KStateMachineSerializersModule + SerializersModule { + polymorphic(Event::class) { + subclass(Event1::class) + subclass(Event2::class) + } + polymorphic(Any::class) { + subclass(String::class, StringPolymorphicSerializer) // for arg + } + } + } + + val originalMachine = createMachine() + originalMachine.processEvent(Event1(42)) + originalMachine.processEvent(Event2("text"), "arg") + val recordedEvents = originalMachine.eventRecorder.getRecordedEvents() + val recordedEventsJson = jsonFormat.encodeToString(recordedEvents) + println(recordedEventsJson) + + // restore the machine + val restoredRecordedEvents = jsonFormat.decodeFromString(recordedEventsJson) + + val restoredMachine = createMachine() + restoredMachine.restoreByRecordedEvents(restoredRecordedEvents) + + restoredMachine.activeStates() shouldHaveSize 1 + restoredMachine.activeStates().single().name shouldBe "State3" + } + + "Serialize and restore state machine with DataTransition" { + suspend fun CoroutineScope.createMachine(): StateMachine { + lateinit var state2: DataState + return createStateMachine( + this, + "Event recording", + creationArguments = buildCreationArguments { + eventRecordingArguments = buildEventRecordingArguments { } + } + ) { + logger = StateMachine.Logger { println(it()) } + initialState("State1") { + dataTransitionOn { targetState = { state2 } } + } + state2 = dataState("State2") + } + } + + val jsonFormat = Json { + serializersModule = KStateMachineSerializersModule + SerializersModule { + polymorphic(Event::class) { + subclass(IntDataEvent::class) + } + } + } + + val originalMachine = createMachine() + originalMachine.processEvent(IntDataEvent(IntData(42))) + val recordedEvents = originalMachine.eventRecorder.getRecordedEvents() + val recordedEventsJson = jsonFormat.encodeToString(recordedEvents) + println(recordedEventsJson) + + // restore the machine + val restoredRecordedEvents = jsonFormat.decodeFromString(recordedEventsJson) + + val restoredMachine = createMachine() + restoredMachine.restoreByRecordedEvents(restoredRecordedEvents) + + restoredMachine.activeStates() shouldHaveSize 1 + val state = restoredMachine.activeStates().single() + state.name shouldBe "State2" + state.shouldBeInstanceOf>() + val data = state.data + data.shouldBeInstanceOf() + data.value shouldBe 42 + } +}) \ No newline at end of file diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ObjectStatesTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ObjectStatesTest.kt index 5638e86..794a358 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ObjectStatesTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ObjectStatesTest.kt @@ -58,7 +58,7 @@ class ObjectStatesTest : StringSpec({ } }) -private fun useInMachine(coroutineStarterType: CoroutineStarterType, autoDestroyOnStatesReuse: Boolean): StateMachine { +private suspend fun useInMachine(coroutineStarterType: CoroutineStarterType, autoDestroyOnStatesReuse: Boolean): StateMachine { val machine = createTestStateMachine( coroutineStarterType, creationArguments = buildCreationArguments { this.autoDestroyOnStatesReuse = autoDestroyOnStatesReuse }, diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/StateCleanupTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/StateCleanupTest.kt index 40783bf..f1f5e39 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/StateCleanupTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/StateCleanupTest.kt @@ -46,6 +46,7 @@ class StateCleanupTest : StringSpec({ } }) -private fun useInMachine(coroutineStarterType: CoroutineStarterType, state: IState) = createTestStateMachine(coroutineStarterType) { - addInitialState(state) -} \ No newline at end of file +private suspend fun useInMachine(coroutineStarterType: CoroutineStarterType, state: IState) = + createTestStateMachine(coroutineStarterType) { + addInitialState(state) + } \ No newline at end of file diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/statemachine/CompositionStateMachinesTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/statemachine/CompositionStateMachinesTest.kt index a0fded3..48ec541 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/statemachine/CompositionStateMachinesTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/statemachine/CompositionStateMachinesTest.kt @@ -135,7 +135,7 @@ class CompositionStateMachinesTest : StringSpec({ } }) -private fun composition(coroutineStarterType: CoroutineStarterType, startInnerMachineOnSetup: Boolean) { +private suspend fun composition(coroutineStarterType: CoroutineStarterType, startInnerMachineOnSetup: Boolean) { val callbacks = mockkCallbacks() val outerState1 = DefaultState("Outer state1") diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/ConditionalTransitionTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/ConditionalTransitionTest.kt index 5e3e0b7..73508e4 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/ConditionalTransitionTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/ConditionalTransitionTest.kt @@ -16,6 +16,7 @@ import ru.nsk.kstatemachine.event.Event import ru.nsk.kstatemachine.event.StartEvent import ru.nsk.kstatemachine.state.DefaultState import ru.nsk.kstatemachine.state.addInitialState +import ru.nsk.kstatemachine.state.addState import ru.nsk.kstatemachine.state.transitionConditionally import ru.nsk.kstatemachine.statemachine.onTransitionTriggered import ru.nsk.kstatemachine.statemachine.processEventBlocking diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TransitionOverrideTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TransitionOverrideTest.kt index 2db864e..eea08b8 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TransitionOverrideTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TransitionOverrideTest.kt @@ -76,7 +76,9 @@ class TransitionOverrideTest : StringSpec({ } }) -private inline fun overrideParentTransitionWithEventType(coroutineStarterType: CoroutineStarterType) { +private suspend inline fun overrideParentTransitionWithEventType( + coroutineStarterType: CoroutineStarterType +) { val callbacks = mockkCallbacks() lateinit var state2: State @@ -107,7 +109,7 @@ private inline fun overrideParentTransitionWithEventType(cor } } -private fun overrideWithDirection( +private suspend fun overrideWithDirection( coroutineStarterType: CoroutineStarterType, callbacks: Callbacks, childDirection: TransitionDirection diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitorTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitorTest.kt index f70b6d8..86cb288 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitorTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitorTest.kt @@ -281,7 +281,7 @@ ChoiceState --> State12 @enduml """ -private fun makeNestedMachine(coroutineStarterType: CoroutineStarterType): StateMachine { +private suspend fun makeNestedMachine(coroutineStarterType: CoroutineStarterType): StateMachine { return createTestStateMachine(coroutineStarterType, name = "Nested states") { val state1 = initialState("State1") val state3 = finalState("State3") @@ -304,7 +304,7 @@ private fun makeNestedMachine(coroutineStarterType: CoroutineStarterType): State } } -private fun makeChoiceMachine(coroutineStarterType: CoroutineStarterType): StateMachine { +private suspend fun makeChoiceMachine(coroutineStarterType: CoroutineStarterType): StateMachine { return createTestStateMachine( coroutineStarterType, name = "Pseudo states",