diff --git a/README.md b/README.md index 2445d78..0cd246a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![Share on Reddit](https://img.shields.io/badge/reddit-share-red?logo=reddit&style=flat)](https://www.reddit.com/submit?url=https%3A%2F%2Fgithub.com%2Fkstatemachine%2Fkstatemachine&title=I%20like%20KStateMachine%20library) -[Documentation](https://kstatemachine.github.io/kstatemachine) | [Sponsors](#sponsors-) | [Quick start](#quick-start-sample) | [Samples](#samples) | [Install](#install) | [Contribution](#contribution) | [License](#license) | [Discussions](https://github.com/kstatemachine/kstatemachine/discussions) +**[Documentation](https://kstatemachine.github.io/kstatemachine) | [Sponsors](#sponsors-) | [Quick start](#quick-start-sample) | [Samples](#samples) | [Install](#install) | [Contribution](#contribution) | [License](#license) | [Discussions](https://github.com/kstatemachine/kstatemachine/discussions)** KStateMachine is a Kotlin DSL library for creating [state machines](https://en.wikipedia.org/wiki/Finite-state_machine) and [statecharts](https://www.sciencedirect.com/science/article/pii/0167642387900359/pdf). diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/DataExtractor.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/DataExtractor.kt index 890461e..0709cea 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/DataExtractor.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/DataExtractor.kt @@ -2,6 +2,7 @@ package ru.nsk.kstatemachine.event import ru.nsk.kstatemachine.state.DataState import ru.nsk.kstatemachine.transition.TransitionParams +import kotlin.reflect.KClass /** * Allows to extract data for [DataState] from any [Event] @@ -10,11 +11,14 @@ import ru.nsk.kstatemachine.transition.TransitionParams * when implementing custom [DataExtractor]. */ interface DataExtractor { + val dataClass: KClass suspend fun extractFinishedEvent(transitionParams: TransitionParams<*>, event: FinishedEvent): D? suspend fun extract(transitionParams: TransitionParams<*>): D? } inline fun defaultDataExtractor() = object : DataExtractor { + override val dataClass = D::class + override suspend fun extractFinishedEvent(transitionParams: TransitionParams<*>, event: FinishedEvent) = event.data as? D diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/EventMatcher.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/EventMatcher.kt index d5a571a..c9287c8 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/EventMatcher.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/event/EventMatcher.kt @@ -8,12 +8,14 @@ import kotlin.reflect.KClass /** * Adds an ability to select who [Transition] matches [Event] subclass */ -abstract class EventMatcher(val eventClass: KClass) { - abstract suspend fun match(value: Event): Boolean +interface EventMatcher { + val eventClass: KClass + suspend fun match(value: Event): Boolean companion object { /** This matcher is used by default, allowing [Event] subclasses */ - inline fun isInstanceOf() = object : EventMatcher(E::class) { + inline fun isInstanceOf() = object : EventMatcher { + override val eventClass = E::class override suspend fun match(value: Event) = value is E } } @@ -23,10 +25,12 @@ abstract class EventMatcher(val eventClass: KClass) { inline fun TransitionBuilder.isInstanceOf() = EventMatcher.isInstanceOf() @Suppress("UNUSED") // The unused warning is probably a bug -inline fun TransitionBuilder.isEqual() = object : EventMatcher(E::class) { +inline fun TransitionBuilder.isEqual() = object : EventMatcher { + override val eventClass = E::class override suspend fun match(value: Event) = value::class == E::class } -fun finishedEventMatcher(state: IState) = object : EventMatcher(FinishedEvent::class) { +fun finishedEventMatcher(state: IState) = object : EventMatcher { + override val eventClass = FinishedEvent::class override suspend fun match(value: Event) = value is FinishedEvent && value.state === state } \ No newline at end of file diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/DefaultDataState.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/DefaultDataState.kt index 55e3877..e85f9d1 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/DefaultDataState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/DefaultDataState.kt @@ -3,6 +3,7 @@ package ru.nsk.kstatemachine.state import ru.nsk.kstatemachine.event.* import ru.nsk.kstatemachine.state.ChildMode.EXCLUSIVE import ru.nsk.kstatemachine.transition.TransitionParams +import kotlin.reflect.KClass /** inline constructor function */ inline fun defaultDataState( @@ -26,6 +27,7 @@ open class DefaultDataState( get() = checkNotNull(_lastData ?: defaultData) { "Last data is not available yet in $this, and default data not provided" } + override val dataClass: KClass get() = dataExtractor.dataClass override suspend fun onDoEnter(transitionParams: TransitionParams<*>) { fun assign(data: D?) { 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 a3bb9a0..0f00dd9 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt @@ -112,6 +112,8 @@ interface DataState : IState, DataTransitionStateApi { * If state was not entered this property falls back to [defaultData] if it was specified */ val lastData: D + + val dataClass: KClass } /** @@ -305,15 +307,15 @@ fun IState.initialChoiceState( choiceAction: suspend EventAndArgument<*>.() -> State ) = addInitialState(DefaultChoiceState(name, choiceAction = choiceAction)) -fun IState.choiceDataState( +inline fun IState.choiceDataState( name: String? = null, - choiceAction: suspend EventAndArgument<*>.() -> DataState -) = addState(DefaultChoiceDataState(name, choiceAction = choiceAction)) + noinline choiceAction: suspend EventAndArgument<*>.() -> DataState +) = addState(DefaultChoiceDataState(name, D::class, choiceAction = choiceAction)) -fun IState.initialChoiceDataState( +inline fun IState.initialChoiceDataState( name: String? = null, - choiceAction: suspend EventAndArgument<*>.() -> DataState -) = addInitialState(DefaultChoiceDataState(name, choiceAction = choiceAction)) + noinline choiceAction: suspend EventAndArgument<*>.() -> DataState +) = addInitialState(DefaultChoiceDataState(name, D::class, choiceAction = choiceAction)) fun IState.historyState( name: String? = null, diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/DefaultChoiceState.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/DefaultChoiceState.kt index 298ca2c..da5cf04 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/DefaultChoiceState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/pseudo/DefaultChoiceState.kt @@ -5,6 +5,7 @@ import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.TransitionDirection import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy import ru.nsk.kstatemachine.transition.noTransition +import kotlin.reflect.KClass open class DefaultChoiceState( name: String? = null, @@ -18,6 +19,7 @@ open class DefaultChoiceState( open class DefaultChoiceDataState( name: String? = null, + override val dataClass: KClass, private val choiceAction: suspend EventAndArgument<*>.() -> DataState, ) : DataState, BasePseudoState(name), RedirectPseudoState { diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitor.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitor.kt new file mode 100644 index 0000000..613cb4b --- /dev/null +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitor.kt @@ -0,0 +1,52 @@ +package ru.nsk.kstatemachine.visitors + +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.state.DataState +import ru.nsk.kstatemachine.state.DefaultDataState +import ru.nsk.kstatemachine.state.HistoryState +import ru.nsk.kstatemachine.state.IState +import ru.nsk.kstatemachine.statemachine.StateMachine +import ru.nsk.kstatemachine.transition.Transition + +/** + * This visitor collects structural information about [StateMachine] in order to compare two [StateMachine] + * instances for structural equality + */ +internal class GetStructureHashCodeVisitor : RecursiveVisitor { + private val nodes = mutableListOf() + val structureHashCode get() = nodes.hashCode() + + override fun visit(machine: StateMachine) { + nodes += machine.stateInfo() + machine.visitChildren() + } + + override fun visit(state: IState) { + if (state !is StateMachine) { // do not check nested machines + nodes += state.stateInfo() + state.visitChildren() + } else { + nodes += "class:${state::class.simpleName}, name:${state.name}" + } + } + + private fun IState.stateInfo() = "class:${this::class.simpleName}, " + + "name:$name, " + + "statesCount:${states.size}, " + + "transitionsCount:${transitions.size}, " + + "childMode:$childMode" + + if (this is HistoryState) ", historyType:$historyType" else "" + + if (this is DataState<*>) ", dataClass:$dataClass, defaultData:$defaultData" else "" + + override fun visit(transition: Transition) { + nodes += "class:${transition::class.simpleName}, " + + "name:${transition.name}, " + + "type:${transition.type}, " + + "event:${transition.eventMatcher.eventClass}" + } +} + +fun StateMachine.getStructureHashCode() = with(GetStructureHashCodeVisitor()) { + visit(this@getStructureHashCode) + structureHashCode +} \ No newline at end of file diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt index f2353d8..ac7457a 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/CoroutinesTest.kt @@ -292,7 +292,7 @@ class CoroutinesTest : StringSpec({ statesFlow.first().shouldContainExactlyInAnyOrder(state2) } - "f:context switching" { + "context switching" { println(""+ Thread.currentThread() + Thread.currentThread().hashCode() ) val scope = CoroutineScope(EmptyCoroutineContext) val scope1 = CoroutineScope(Dispatchers.Default) diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ChoiceStateTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ChoiceStateTest.kt index 1fb5517..ec24bd5 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ChoiceStateTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/state/ChoiceStateTest.kt @@ -2,6 +2,7 @@ package ru.nsk.kstatemachine.state import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe import io.mockk.verifySequence import ru.nsk.kstatemachine.* import ru.nsk.kstatemachine.event.DataEvent @@ -61,7 +62,7 @@ class ChoiceStateTest : StringSpec({ verifySequence { callbacks.onStateEntry(State2) } } - "initial choice state" { + "initial choiceState" { val callbacks = mockkCallbacks() createTestStateMachine(coroutineStarterType) { @@ -71,6 +72,17 @@ class ChoiceStateTest : StringSpec({ verifySequence { callbacks.onStateEntry(State2) } } + "initial choiceDataState" { + val callbacks = mockkCallbacks() + lateinit var state2: DataState + createTestStateMachine(coroutineStarterType) { + initialChoiceDataState("choice") { state2 } + state2 = dataState(defaultData = 42) { callbacks.listen(this) } + } + verifySequence { callbacks.onStateEntry(state2) } + state2.data shouldBe 42 + } + "initial choice state on entry parent" { lateinit var state1: State lateinit var state2: State diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TypesafeTransitionTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TypesafeTransitionTest.kt index 11e4965..26dfe8e 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TypesafeTransitionTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/transition/TypesafeTransitionTest.kt @@ -17,6 +17,7 @@ import ru.nsk.kstatemachine.statemachine.processEventBlocking import ru.nsk.kstatemachine.transition.TypesafeTransitionTestData.CustomDataEvent import ru.nsk.kstatemachine.transition.TypesafeTransitionTestData.IdEvent import ru.nsk.kstatemachine.transition.TypesafeTransitionTestData.NameEvent +import kotlin.reflect.KClass private object TypesafeTransitionTestData { class CustomDataEvent(val value: Int) : Event @@ -342,6 +343,8 @@ class TypesafeTransitionTest : StringSpec({ dataState = dataState( "data state", dataExtractor = object : DataExtractor { + override val dataClass = Int::class + override suspend fun extractFinishedEvent(transitionParams: TransitionParams<*>, event: FinishedEvent) = event.data as? Int diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitorTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitorTest.kt new file mode 100644 index 0000000..22fd1ab --- /dev/null +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/visitors/GetStructureHashCodeVisitorTest.kt @@ -0,0 +1,235 @@ +package ru.nsk.kstatemachine.visitors + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import ru.nsk.kstatemachine.* +import ru.nsk.kstatemachine.state.* +import ru.nsk.kstatemachine.transition.TransitionType + +class GetStructureHashCodeVisitorTest : StringSpec({ + CoroutineStarterType.entries.forEach { coroutineStarterType -> + "structure hash code should be the same if called twice" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + } + val hashCode = machine.getStructureHashCode() + hashCode shouldNotBe 0 + hashCode shouldBe machine.getStructureHashCode() + } + + "structure hash code for different StateMachine instances should be the same if they have equal structure" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState() + } + + val machine3 = createTestStateMachine(coroutineStarterType) { + initialState("State") + } + val hashCode = machine.getStructureHashCode() + hashCode shouldBe machine2.getStructureHashCode() + hashCode shouldNotBe machine3.getStructureHashCode() + } + + "structure hash code should catch state reorder (this might affect behaviour in some corner cases)" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState("state1") + state("state2") + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + state("state2") + initialState("state1") + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "negative structure hash code should catch state reorder (not works for empty states)" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + state() + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + state() + initialState() + } + + // should not be equal, but cannot implement it + machine.getStructureHashCode() shouldBe machine2.getStructureHashCode() + } + + "structure hash code is affected by state name" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState("state1") + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState("state1_2") + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by state count" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + state() + state() + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState() + state() + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by state type" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialDataState(defaultData = 0) + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by generic DataState type" { + val machine = createTestStateMachine(coroutineStarterType) { + initialDataState(defaultData = 0.0) + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialDataState(defaultData = 0) + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by DataState defaultData" { + val machine = createTestStateMachine(coroutineStarterType) { + initialDataState(defaultData = 0) + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialDataState(defaultData = 1) + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by transition" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + } + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState() + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by transition name" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState { + transition("transition1") + } + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState { + transition("transition1_2") + } + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by transition event" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + } + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + } + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by transition count" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + transition() + } + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + } + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by transition type" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState { + transition() + } + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState { + transition(type = TransitionType.EXTERNAL) + } + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by child mode" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + } + + val machine2 = createTestStateMachine(coroutineStarterType, childMode = ChildMode.PARALLEL) { + state() + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + + "structure hash code is affected by HistoryType" { + val machine = createTestStateMachine(coroutineStarterType) { + initialState() + historyState(historyType = HistoryType.DEEP) + } + + val machine2 = createTestStateMachine(coroutineStarterType) { + initialState() + historyState(historyType = HistoryType.SHALLOW) + } + + machine.getStructureHashCode() shouldNotBe machine2.getStructureHashCode() + } + } +})