From 22ba23448b242c78fb64aed8f2d1ca943705ab10 Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Sat, 14 Dec 2024 00:25:15 +0800 Subject: [PATCH] Add CheckMetaInfoStructureVisitor to verify correct MetaInfo structure on StateMachine startup --- docs/pages/export.md | 2 +- .../kstatemachine/metainfo/ExportMetaInfo.kt | 13 ++--- .../ru/nsk/kstatemachine/metainfo/MetaInfo.kt | 10 +++- .../statemachine/StateMachineImpl.kt | 2 + .../visitors/CheckMetaInfoStructureVisitor.kt | 50 +++++++++++++++++++ .../visitors/export/ExportPlantUmlVisitor.kt | 36 ++++++------- .../metainfo/ExportMetaInfoTest.kt | 28 +++++++++++ .../kstatemachine/metainfo/UmlMetaInfoTest.kt | 29 ++++++++++- 8 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/CheckMetaInfoStructureVisitor.kt diff --git a/docs/pages/export.md b/docs/pages/export.md index 8b3753e..a5b1779 100644 --- a/docs/pages/export.md +++ b/docs/pages/export.md @@ -33,7 +33,7 @@ The library provides additional `MetaInfo` objects that might be used along with to provide complete output (with a help of a user). * `IgnoreUnsafeCallConditionalLambdasMetaInfo` allows to ignore `unsafeCallConditionalLambdas` flag for some state or - transition + transition. Corresponding lambda will not be executed. * `ExportMetaInfoBuilder` (is built by `buildExportMetaInfo` function) allows a user to manually specify hint information (list of hints) for the library to print complete export output. User is responsible to specify correct information. diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt index c775c30..105c5d2 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt @@ -19,14 +19,14 @@ sealed interface ResolutionHint /** * To be used with state/transition constructions where some conditional lambda should return [IState] type. - * If the hint takes an effect (applied correctly), the conditional lambda will not be called even if - * [unsafeCallConditionalLambdas] is true. - * You can specify multiple [StateResolutionHint] instances for the same construction to cover all internal branches. + * This hint does not require [unsafeCallConditionalLambdas] flag to be true. + * You can specify multiple [StateResolutionHint] instances for the same construction to cover all internal lambda + * branches. * User is responsible to provide correct hints. */ class StateResolutionHint( val description: String, - /** Allows to specify parallel target states */ + /** Allows to specify parallel target states. Must be non-empty */ val targetStates: Set, ) : ResolutionHint { constructor( @@ -44,8 +44,9 @@ class StateResolutionHint( /** * To be used with state/transition constructions where some conditional lambda uses [EventAndArgument] instance. * If the hint takes an effect (applied correctly) and [unsafeCallConditionalLambdas] flag is true the conditional lambda - * will be called with specified [EventAndArgument] instead of default fake ([ExportPlantUmlEvent]). - * You can specify multiple [EventAndArgumentResolutionHint] instances for the same construction to cover all internal branches. + * will be called with specified [EventAndArgument] instead of default fake [ExportPlantUmlEvent]. + * You can specify multiple [EventAndArgumentResolutionHint] instances for the same construction to cover all internal + * lambda branches. * User is responsible to provide correct hints. */ class EventAndArgumentResolutionHint( diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt index 438f7c0..4e26b60 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt @@ -9,6 +9,7 @@ package ru.nsk.kstatemachine.metainfo import ru.nsk.kstatemachine.state.IState import ru.nsk.kstatemachine.transition.Transition +import kotlin.collections.single /** * Additional static (designed to be immutable) info for library primitives like [IState] [Transition] etc. @@ -36,7 +37,14 @@ interface CompositeMetaInfo : MetaInfo { inline fun MetaInfo?.findMetaInfo(): M? { return when (this) { is M -> this - is CompositeMetaInfo -> metaInfoSet.singleOrNull { it is M } as? M + is CompositeMetaInfo -> { + val infos = metaInfoSet.filterIsInstance() + when (infos.size) { + 0 -> null + 1 -> infos.single() + else -> throw IllegalArgumentException("MetaInfo set has more than one element of type ${M::class.simpleName}") + } + } else -> null } } diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt index 29e96a7..cf9734c 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt @@ -18,6 +18,7 @@ import ru.nsk.kstatemachine.state.pseudo.UndoState import ru.nsk.kstatemachine.statemachine.ProcessingResult.* import ru.nsk.kstatemachine.transition.* import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy.DefaultPolicy +import ru.nsk.kstatemachine.visitors.CheckMetaInfoStructureVisitor import ru.nsk.kstatemachine.visitors.CheckUniqueNamesVisitor import ru.nsk.kstatemachine.visitors.CleanupVisitor import ru.nsk.kstatemachine.visitors.checkNonBlankNames @@ -158,6 +159,7 @@ internal class StateMachineImpl( private fun checkBeforeRunMachine() { accept(CheckUniqueNamesVisitor()) + accept(CheckMetaInfoStructureVisitor()) if (creationArguments.requireNonBlankNames) checkNonBlankNames() checkNotDestroyed() diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/CheckMetaInfoStructureVisitor.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/CheckMetaInfoStructureVisitor.kt new file mode 100644 index 0000000..449dd38 --- /dev/null +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/CheckMetaInfoStructureVisitor.kt @@ -0,0 +1,50 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.visitors + +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.metainfo.CompositeMetaInfo +import ru.nsk.kstatemachine.metainfo.MetaInfo +import ru.nsk.kstatemachine.state.IState +import ru.nsk.kstatemachine.statemachine.StateMachine +import ru.nsk.kstatemachine.transition.Transition + +/** + * Checks that [MetaInfo] is correctly structured. + * [CompositeMetaInfo] is not nested into each other. + * Certain [MetaInfo] subclasses are applied only once. + */ +internal class CheckMetaInfoStructureVisitor : RecursiveVisitor { + override fun visit(machine: StateMachine) { + machine.metaInfo?.checkStructure() + machine.visitChildren() + } + + override fun visit(state: IState) { + state.metaInfo?.checkStructure() + + if (state !is StateMachine) // do not check nested machines + state.visitChildren() + } + + override fun visit(transition: Transition) { + transition.metaInfo?.checkStructure() + } + + private fun MetaInfo.checkStructure() { + if (this is CompositeMetaInfo) { + val groups = metaInfoSet.groupBy { + check(it !is CompositeMetaInfo) { "CompositeMetaInfo cannot nest each other" } + it::class + } + groups.entries.forEach { + check(it.value.size == 1) { "MetaInfo ${it.key} is repeated more than once" } + } + } + } +} \ No newline at end of file diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitor.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitor.kt index 8e3e2ae..762ff5f 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitor.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportPlantUmlVisitor.kt @@ -218,7 +218,7 @@ internal class ExportPlantUmlVisitor( val eventAndArgument = it.eventAndArgument as EventAndArgument TargetStateInfo( it.description, - block(makeDirectionProducerPolicy(metaInfo, eventAndArgument)) + block(makeUnsafeCollectTargetStatesPolicy(eventAndArgument)) ) } hintMap[StateResolutionHint::class]?.mapTo(stateInfoList) { @@ -226,19 +226,17 @@ internal class ExportPlantUmlVisitor( TargetStateInfo(it.description, TargetState(it.targetStates)) } } else { - stateInfoList += TargetStateInfo( - null, - block(makeDirectionProducerPolicy(metaInfo)) - ) + stateInfoList += TargetStateInfo(null, block(makeUnsafeCollectTargetStatesPolicy())) } } else { - stateInfoList += TargetStateInfo( - null, - block(makeDirectionProducerPolicy(metaInfo)) - ) + stateInfoList += TargetStateInfo(null, block(makeUnsafeCollectTargetStatesPolicy())) } } else { - stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo))) + stateInfoList += TargetStateInfo(null, block(CollectTargetStatesPolicy())) + metaInfo.findMetaInfo() + ?.resolutionHints + ?.filterIsInstance() + ?.mapTo(stateInfoList) { TargetStateInfo(it.description, TargetState(it.targetStates)) } } return stateInfoList } @@ -246,17 +244,6 @@ internal class ExportPlantUmlVisitor( private val MetaInfo?.hasUnsafeCallConditionalLambdas: Boolean get() = unsafeCallConditionalLambdas && findMetaInfo() == null - private fun makeDirectionProducerPolicy( - metaInfo: MetaInfo?, - @Suppress("UNCHECKED_CAST") // this is unsafe by design - eventAndArgument: EventAndArgument = EventAndArgument(ExportPlantUmlEvent as E, null) - ): TransitionDirectionProducerPolicy { - return if (metaInfo.hasUnsafeCallConditionalLambdas) - UnsafeCollectTargetStatesPolicy(eventAndArgument) - else - CollectTargetStatesPolicy() - } - private suspend fun processStateBody(state: IState) { state.printStateDescriptions() state.printStateNotes() @@ -335,6 +322,13 @@ internal class ExportPlantUmlVisitor( } } +private fun makeUnsafeCollectTargetStatesPolicy( + @Suppress("UNCHECKED_CAST") // this is unsafe by design + eventAndArgument: EventAndArgument = EventAndArgument(ExportPlantUmlEvent as E, null) +): TransitionDirectionProducerPolicy { + return UnsafeCollectTargetStatesPolicy(eventAndArgument) +} + /** * Export [StateMachine] to PlantUML state diagram * @see PlantUML diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt index 5a18936..4e20b04 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt @@ -78,6 +78,29 @@ ExportMetaInfoStateMachine_StateMachine --> ExportMetaInfoStateMachine_StateMach @enduml """ +private const val EXPORT_META_INFO_WITHOUT_UNSAFE_CALL_CONDITIONAL_LAMBDAS_TEST = """@startuml +hide empty description +state ExportMetaInfoStateMachine_StateMachine { + state State1 { + state State11 + state State12 + + [*] --> State11 + } + state State2 + state choiceState <> + + [*] --> State1 +} +choiceState --> State1 : if (true) +choiceState --> State2 +ExportMetaInfoStateMachine_StateMachine --> State1 : when 1 +ExportMetaInfoStateMachine_StateMachine --> State1 : when 3 +ExportMetaInfoStateMachine_StateMachine --> State2 : when 3 +ExportMetaInfoStateMachine_StateMachine --> ExportMetaInfoStateMachine_StateMachine : when 4 +@enduml +""" + private object ExportMetaInfoTestData { class ValueEvent1(val value: Int) : Event class ValueEvent2(val value: Int) : Event @@ -144,5 +167,10 @@ class ExportMetaInfoTest : StringSpec({ unsafeCallConditionalLambdas = true ) shouldBe EXPORT_META_INFO_WITH_LABELS_TEST } + + "ExportMetaInfo test without unsafeCallConditionalLambdas flag" { + val machine = createTestMachine(coroutineStarterType) + machine.exportToPlantUml() shouldBe EXPORT_META_INFO_WITHOUT_UNSAFE_CALL_CONDITIONAL_LAMBDAS_TEST + } } }) \ No newline at end of file diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt index da9a8c2..071cc10 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt @@ -7,6 +7,8 @@ package ru.nsk.kstatemachine.metainfo +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.throwables.shouldThrowWithMessage import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import ru.nsk.kstatemachine.CoroutineStarterType @@ -18,7 +20,6 @@ import ru.nsk.kstatemachine.state.initialState import ru.nsk.kstatemachine.state.invoke import ru.nsk.kstatemachine.state.state import ru.nsk.kstatemachine.state.transition -import ru.nsk.kstatemachine.statemachine.createStateMachine import ru.nsk.kstatemachine.visitors.export.exportToPlantUml private const val PLANTUML_META_INFO = """@startuml @@ -163,5 +164,31 @@ class UmlMetaInfoTest : StringSpec({ } machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO } + + "Negative CompositeMetaInfo nesting" { + shouldThrowWithMessage("CompositeMetaInfo cannot nest each other") { + createTestStateMachine(coroutineStarterType) { + metaInfo = buildCompositeMetaInfo( + buildCompositeMetaInfo {}, + buildUmlMetaInfo {} + ) + initialState("State1") + } + } + } + + "Negative MetaInfo cannot be repeated more than once" { + shouldThrowWithMessage("MetaInfo ${buildUmlMetaInfo {}::class} is repeated more than once") { + createTestStateMachine(coroutineStarterType) { + transition { + metaInfo = buildCompositeMetaInfo( + buildUmlMetaInfo { umlLabel = "text1" }, + buildUmlMetaInfo { umlLabel = "text2" } + ) + } + initialState("State1") + } + } + } } }) \ No newline at end of file