From 2ab450d0389089acd27618ce2df82c924fee768b Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Tue, 26 Nov 2024 22:22:26 +0300 Subject: [PATCH 1/6] Add CompositeMetaInfo --- .../kstatemachine/metainfo/ExportMetaInfo.kt | 43 +++++++++++++ .../ru/nsk/kstatemachine/metainfo/MetaInfo.kt | 64 ++++++++----------- .../nsk/kstatemachine/metainfo/UmlMetaInfo.kt | 57 +++++++++++++++++ .../ru/nsk/kstatemachine/state/IState.kt | 2 +- .../visitors/export/ExportPlantUmlVisitor.kt | 5 +- .../export/ExportPlantUmlVisitorTest.kt | 35 ++++++++++ 6 files changed, 167 insertions(+), 39 deletions(-) create mode 100644 kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt create mode 100644 kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfo.kt diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt new file mode 100644 index 0000000..d7e46c2 --- /dev/null +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt @@ -0,0 +1,43 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.metainfo + +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.state.IState +import ru.nsk.kstatemachine.transition.TransitionDirection + +sealed interface ResolutionHint +class StateResolutionHint(targetState: IState) : ResolutionHint +class EventResolutionHint(event: Event) : ResolutionHint +class DirectionResolutionHint(direction: TransitionDirection) : ResolutionHint + +/** + * Standard [MetaInfo], to control unsafe export feature. + */ +interface ExportMetaInfo : MetaInfo { + /** + * Default: emptySet() + */ + val resolutionHints: Set +} + +/** + * [ExportMetaInfo] Implementation is separated from its interface as a user may combine multiple [MetaInfo] + * interfaces into one object. Data class should not be exposed to public APIs due to binary compatibility, users should + * use [buildExportMetaInfo] instead. + */ +interface ExportMetaInfoBuilder : ExportMetaInfo { + override var resolutionHints: Set +} + +private data class ExportMetaInfoBuilderImpl( + override var resolutionHints: Set = emptySet(), +) : ExportMetaInfoBuilder + +fun buildExportMetaInfo(builder: ExportMetaInfoBuilder.() -> Unit): ExportMetaInfo = + ExportMetaInfoBuilderImpl().apply(builder).copy() \ No newline at end of file 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 ce6c14a..096ae07 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt @@ -13,52 +13,44 @@ import ru.nsk.kstatemachine.transition.Transition /** * Additional static (designed to be immutable) info for library primitives like [IState] [Transition] etc. * Users may extend this interface to add their own [MetaInfo] implementations. - * Users may combine multiple [MetaInfo] interfaces into one object. + * Users may combine multiple [MetaInfo] derived interfaces into single object or use [CompositeMetaInfo] instead. */ interface MetaInfo /** - * Standard [MetaInfo], to control export PlantUML and Mermaid feature visualization. + * Allows to specify multiple [MetaInfo] objects. + * It might be simpler than constructing single object implementing multiple [MetaInfo] derived interfaces. + * Nesting [CompositeMetaInfo] into each other is not supported. */ -interface UmlMetaInfo : MetaInfo { +interface CompositeMetaInfo : MetaInfo { /** - * Will be mapped to "long name" for [IState], and a "label" for [Transition] - * Default: null + * Default: emptySet() */ - val umlLabel: String? - - /** - * Add description lines for [IState] - * Does not have effect for [Transition] - * Default: emptyList() - */ - val umlStateDescriptions: List + val metaInfoSet: Set +} - /** - * For [IState] translated to "note right of". - * For [Transition] translated to "note on link" (supports only one note). - * Mermaid does not support this, so it will not take any effect. - * Default: emptyList() - */ - val umlNotes: List +internal inline fun MetaInfo.findMetaInfo(): M? { + return when (this) { + is M -> this + is CompositeMetaInfo -> metaInfoSet.singleOrNull { it is M } as? M + else -> null + } } -/** - * [UmlMetaInfo] Implementation is separated from its interface as a user may combine multiple [MetaInfo] - * interfaces into one object. Data class should not be exposed to public APIs due to binary compatibility, users should - * use [buildUmlMetaInfo] instead. - */ -interface UmlMetaInfoBuilder : UmlMetaInfo { - override var umlLabel: String? - override var umlStateDescriptions: List - override var umlNotes: List +interface CompositeMetaInfoBuilder : CompositeMetaInfo { + override var metaInfoSet: Set } -private data class UmlMetaInfoBuilderImpl( - override var umlLabel: String? = null, - override var umlStateDescriptions: List = emptyList(), - override var umlNotes: List = emptyList(), -) : UmlMetaInfoBuilder +private data class CompositeMetaInfoBuilderImpl( + override var metaInfoSet: Set = emptySet() +) : CompositeMetaInfoBuilder + +fun buildCompositeMetaInfo(builder: CompositeMetaInfoBuilder.() -> Unit): CompositeMetaInfo = + CompositeMetaInfoBuilderImpl().apply(builder).copy() + +fun buildCompositeMetaInfo(metaInfo1: MetaInfo, metaInfo2: MetaInfo, vararg infos: MetaInfo): CompositeMetaInfo = + CompositeMetaInfoBuilderImpl(infos.toMutableSet().apply { + add(metaInfo1) + add(metaInfo2) + }) -fun buildUmlMetaInfo(builder: UmlMetaInfoBuilder.() -> Unit): UmlMetaInfo = - UmlMetaInfoBuilderImpl().apply(builder).copy() \ No newline at end of file diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfo.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfo.kt new file mode 100644 index 0000000..136b486 --- /dev/null +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfo.kt @@ -0,0 +1,57 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.metainfo + +import ru.nsk.kstatemachine.state.IState +import ru.nsk.kstatemachine.transition.Transition + +/** + * Standard [MetaInfo], to control export PlantUML and Mermaid feature visualization. + */ +interface UmlMetaInfo : MetaInfo { + /** + * Will be mapped to "long name" for [IState], and a "label" for [Transition] + * Default: null + */ + val umlLabel: String? + + /** + * Add description lines for [IState] + * Does not have effect for [Transition] + * Default: emptyList() + */ + val umlStateDescriptions: List + + /** + * For [IState] translated to "note right of". + * For [Transition] translated to "note on link" (supports only one note). + * Mermaid does not support this, so it will not take any effect. + * Default: emptyList() + */ + val umlNotes: List +} + +/** + * [UmlMetaInfo] Implementation is separated from its interface as a user may combine multiple [MetaInfo] + * interfaces into one object. Data class should not be exposed to public APIs due to binary compatibility, users should + * use [buildUmlMetaInfo] instead. + */ +interface UmlMetaInfoBuilder : UmlMetaInfo { + override var umlLabel: String? + override var umlStateDescriptions: List + override var umlNotes: List +} + +private data class UmlMetaInfoBuilderImpl( + override var umlLabel: String? = null, + override var umlStateDescriptions: List = emptyList(), + override var umlNotes: List = emptyList(), +) : UmlMetaInfoBuilder + +fun buildUmlMetaInfo(builder: UmlMetaInfoBuilder.() -> Unit): UmlMetaInfo = + UmlMetaInfoBuilderImpl().apply(builder).copy() \ No newline at end of file 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 1c32a4c..c891df1 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/IState.kt @@ -288,7 +288,7 @@ suspend fun IState.addInitialState(state: S, init: StateBlock? = } /** - * Helper method for adding final states. This is exactly the same as simply call [IState.addState] but makes + * Helper dsl method for adding final states. This is exactly the same as simply call [IState.addState] but makes * code more self expressive. */ suspend fun IState.addFinalState(state: S, init: StateBlock? = null) = 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 64841fa..a4973fb 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 @@ -11,6 +11,7 @@ import ru.nsk.kstatemachine.event.Event import ru.nsk.kstatemachine.isNeighbor import ru.nsk.kstatemachine.metainfo.UmlMetaInfo import ru.nsk.kstatemachine.metainfo.MetaInfo +import ru.nsk.kstatemachine.metainfo.findMetaInfo import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.state.pseudo.UndoState import ru.nsk.kstatemachine.statemachine.StateMachine @@ -197,8 +198,8 @@ internal class ExportPlantUmlVisitor( } private companion object { - val MetaInfo.umlNotes get() = (this as? UmlMetaInfo)?.umlNotes.orEmpty() - val MetaInfo.umlLabel get() = (this as? UmlMetaInfo)?.umlLabel + val MetaInfo.umlNotes get() = findMetaInfo()?.umlNotes.orEmpty() + val MetaInfo.umlLabel get() = findMetaInfo()?.umlLabel fun IState.graphName(): String { val name = (name ?: "State${hashCode()}").replace(Regex("[ -]"), "_") 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 535e06e..ab80fa9 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 @@ -15,6 +15,8 @@ import io.kotest.data.table import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldNotBeInstanceOf import ru.nsk.kstatemachine.* +import ru.nsk.kstatemachine.metainfo.MetaInfo +import ru.nsk.kstatemachine.metainfo.buildCompositeMetaInfo import ru.nsk.kstatemachine.metainfo.buildUmlMetaInfo import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.statemachine.StateMachine @@ -280,6 +282,16 @@ ChoiceState --> State12 @enduml """ +private const val PLANTUML_COMPOSITE_META_INFO = """@startuml +hide empty description +state "Nested states sm" as Meta_info_StateMachine { + state State1 + + [*] --> State1 +} +@enduml +""" + private suspend fun makeNestedMachine(coroutineStarterType: CoroutineStarterType): StateMachine { return createTestStateMachine(coroutineStarterType, name = "Nested states") { val state1 = initialState("State1") @@ -516,5 +528,28 @@ class ExportPlantUmlVisitorTest : StringSpec({ unsafeCallConditionalLambdas = true ) shouldBe PLANTUML_META_INFO } + + "CompositeMetaInfo vararg export test" { + val machine = createStateMachine(this, name = "Meta info") { + // label for state machine + metaInfo = buildCompositeMetaInfo( + buildUmlMetaInfo { umlLabel = "Nested states sm" }, + object : MetaInfo {} // just a stub + ) + initialState("State1") + } + machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO + } + + "CompositeMetaInfo export test" { + val machine = createStateMachine(this, name = "Meta info") { + // label for state machine + metaInfo = buildCompositeMetaInfo { + metaInfoSet = setOf(buildUmlMetaInfo { umlLabel = "Nested states sm" }) + } + initialState("State1") + } + machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO + } } }) \ No newline at end of file From f6ecfcbc9d70b3adb0b7eb40eb140c0c6932d5e9 Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Fri, 6 Dec 2024 20:04:18 +0800 Subject: [PATCH 2/6] Implement ResolutionHints --- .../kstatemachine/metainfo/ExportMetaInfo.kt | 58 +++++- .../ru/nsk/kstatemachine/metainfo/MetaInfo.kt | 2 +- .../nsk/kstatemachine/state/InternalState.kt | 2 +- .../statemachine/StateMachine.kt | 1 - .../visitors/export/ExportPlantUmlVisitor.kt | 112 ++++++++++-- .../visitors/export/ExportToMermaid.kt | 4 +- .../metainfo/ExportMetaInfoTest.kt | 106 +++++++++++ .../kstatemachine/metainfo/UmlMetaInfoTest.kt | 166 ++++++++++++++++++ .../export/ExportPlantUmlVisitorTest.kt | 160 ++++------------- 9 files changed, 463 insertions(+), 148 deletions(-) create mode 100644 tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt create mode 100644 tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt 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 d7e46c2..f543426 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt @@ -9,12 +9,62 @@ package ru.nsk.kstatemachine.metainfo import ru.nsk.kstatemachine.event.Event import ru.nsk.kstatemachine.state.IState -import ru.nsk.kstatemachine.transition.TransitionDirection +import ru.nsk.kstatemachine.state.InternalState +import ru.nsk.kstatemachine.state.RedirectPseudoState +import ru.nsk.kstatemachine.transition.EventAndArgument +/** + * Hint to be used with [ExportMetaInfo] + */ sealed interface ResolutionHint -class StateResolutionHint(targetState: IState) : ResolutionHint -class EventResolutionHint(event: Event) : ResolutionHint -class DirectionResolutionHint(direction: TransitionDirection) : 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. + * User is responsible to provide correct hints. + */ +class StateResolutionHint( + val description: String, + /** Allows to specify parallel target states */ + val targetStates: Set, +) : ResolutionHint { + constructor( + description: String, + targetState: IState, + ) : this(description, setOf(targetState)) + + init { + require(targetStates.isNotEmpty()) { + "targetStates must be non-empty, use single state or multiple states for parallel transitions" + } + } + + @Suppress("UNCHECKED_CAST") + internal val internalTargetStates: Set get() = targetStates as Set +} + +/** + * 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. + * User is responsible to provide correct hints. + */ +class EventAndArgumentResolutionHint( + val description: String, + val event: Event, + val argument: Any? = null +) : ResolutionHint { + val eventAndArgument = EventAndArgument(event, argument) +} + +/** + * Allows to ignore an effect of export's feature [unsafeCallConditionalLambdas] flag for certain conditional transition + * or conditional state [RedirectPseudoState]. Conditional lambda will not be called if this meta info is applied. + */ +object IgnoreUnsafeCallConditionalLambdasMetaInfo : MetaInfo /** * Standard [MetaInfo], to control unsafe export feature. 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 096ae07..d537466 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt @@ -29,7 +29,7 @@ interface CompositeMetaInfo : MetaInfo { val metaInfoSet: Set } -internal inline fun MetaInfo.findMetaInfo(): M? { +internal inline fun MetaInfo?.findMetaInfo(): M? { return when (this) { is M -> this is CompositeMetaInfo -> metaInfoSet.singleOrNull { it is M } as? M diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/InternalState.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/InternalState.kt index 62ccf17..07671b7 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/InternalState.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/state/InternalState.kt @@ -27,7 +27,7 @@ internal fun InternalNode.requireParentNode(): InternalNode = /** * Defines state API for internal library usage. All states must implement this class. * Unfortunately cannot use interface for this purpose. - * This is safe to cast any [IState] to [InternalState] by design. + * This is safe to cast any [IState] to [InternalState] by design for internal library implementation. */ abstract class InternalState : IState, InternalNode { override val parent: IState? get() = internalParent 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 9383d54..2759f31 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt @@ -19,7 +19,6 @@ import ru.nsk.kstatemachine.state.IState import ru.nsk.kstatemachine.state.State import ru.nsk.kstatemachine.statemachine.StateMachine.PendingEventHandler import ru.nsk.kstatemachine.transition.EventAndArgument -import ru.nsk.kstatemachine.transition.Transition import ru.nsk.kstatemachine.transition.TransitionParams import ru.nsk.kstatemachine.visitors.CoVisitor import ru.nsk.kstatemachine.visitors.Visitor 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 a4973fb..dc1f12c 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 @@ -9,8 +9,13 @@ package ru.nsk.kstatemachine.visitors.export import ru.nsk.kstatemachine.event.Event import ru.nsk.kstatemachine.isNeighbor +import ru.nsk.kstatemachine.metainfo.EventAndArgumentResolutionHint +import ru.nsk.kstatemachine.metainfo.ExportMetaInfo +import ru.nsk.kstatemachine.metainfo.IgnoreUnsafeCallConditionalLambdasMetaInfo import ru.nsk.kstatemachine.metainfo.UmlMetaInfo import ru.nsk.kstatemachine.metainfo.MetaInfo +import ru.nsk.kstatemachine.metainfo.ResolutionHint +import ru.nsk.kstatemachine.metainfo.StateResolutionHint import ru.nsk.kstatemachine.metainfo.findMetaInfo import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.state.pseudo.UndoState @@ -18,6 +23,7 @@ import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.InternalTransition import ru.nsk.kstatemachine.transition.Transition +import ru.nsk.kstatemachine.transition.TransitionDirection import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy.CollectTargetStatesPolicy import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy.UnsafeCollectTargetStatesPolicy @@ -40,6 +46,21 @@ internal object ExportPlantUmlEvent : Event internal enum class CompatibilityFormat { PLANT_UML, MERMAID } +private data class TargetStateInfo( + val description: String?, + val targetStates: Set, +) { + init { + require(targetStates.isNotEmpty()) { "targetStates must be non-empty." } + } + + /** + * PlantUML cant draw multiple target transitions. + * So I have to simplify it to just use first state. + */ + val targetState get() = targetStates.first() +} + /** * Export state machine to Plant UML language format. * @see Plant UML state diagram @@ -85,12 +106,13 @@ internal class ExportPlantUmlVisitor( is HistoryState, is UndoState -> return is RedirectPseudoState -> { line("state ${state.labelGraphName()} $CHOICE") - @Suppress("UNCHECKED_CAST") - val targetStates = state.resolveTargetState(makeDirectionProducerPolicy()) - .targetStates as Set + val targetStateInfoList = executeDirectionProducerPolicy(state.metaInfo) { policy -> + state.resolveTargetState(policy) + } state.printStateNotes() - targetStates.forEach { targetState -> - crossLevelTransitions += "${state.graphName()} --> ${targetState.targetGraphName()}" + targetStateInfoList.forEach { targetStateInfo -> + //todo use description + crossLevelTransitions += "${state.graphName()} --> ${targetStateInfo.targetState.targetGraphName()}" } } else -> { @@ -121,14 +143,15 @@ internal class ExportPlantUmlVisitor( transition as InternalTransition val sourceState = transition.sourceState.graphName() + val targetStateInfoList = executeDirectionProducerPolicy(transition.metaInfo) { policy -> + transition.produceTargetStateDirection(policy) + } + targetStateInfoList.forEach { targetStateInfo -> // actually plantUml may not understand multiple transitions + //todo use description + val transitionString = + "$sourceState --> ${targetStateInfo.targetState.targetGraphName()}${transitionLabel(transition)}" - @Suppress("UNCHECKED_CAST") - val targetStates = transition.produceTargetStateDirection(makeDirectionProducerPolicy()).targetStates - as Set - targetStates.forEach { targetState -> // actually plantUml may not understand multiple transitions - val transitionString = "$sourceState --> ${targetState.targetGraphName()}${transitionLabel(transition)}" - - if (transition.sourceState.isNeighbor(targetState)) + if (transition.sourceState.isNeighbor(targetStateInfo.targetState)) line(transitionString) else crossLevelTransitions += transitionString @@ -143,13 +166,62 @@ internal class ExportPlantUmlVisitor( } } - private fun makeDirectionProducerPolicy(): TransitionDirectionProducerPolicy { - return if (unsafeCallConditionalLambdas) { - @Suppress("UNCHECKED_CAST") // this is unsafe by design - UnsafeCollectTargetStatesPolicy(EventAndArgument(ExportPlantUmlEvent as E, null)) + /** + * We should call conditional lambdas if [hasUnsafeCallConditionalLambdas] is true and use user provided + * [ResolutionHint]s to extend output. + */ + private suspend fun executeDirectionProducerPolicy( + metaInfo: MetaInfo?, + block: suspend (TransitionDirectionProducerPolicy) -> TransitionDirection + ): List { + val stateInfoList = mutableListOf() + if (metaInfo.hasUnsafeCallConditionalLambdas) { + val exportMetaInfo = metaInfo.findMetaInfo() + if (exportMetaInfo != null) { + val hintMap = exportMetaInfo.resolutionHints.groupBy { + when (it) { + is EventAndArgumentResolutionHint -> EventAndArgumentResolutionHint::class + is StateResolutionHint -> StateResolutionHint::class + } + } + if (hintMap.isNotEmpty()) { + hintMap[EventAndArgumentResolutionHint::class]?.mapTo(stateInfoList) { + it as EventAndArgumentResolutionHint + @Suppress("UNCHECKED_CAST") + val eventAndArgument = it.eventAndArgument as EventAndArgument + TargetStateInfo( + it.description, + block(makeDirectionProducerPolicy(metaInfo, eventAndArgument)).internalTargetStates + ) + } + hintMap[StateResolutionHint::class]?.mapTo(stateInfoList) { + it as StateResolutionHint + TargetStateInfo(it.description, it.internalTargetStates) + } + } else { + stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) + } + } else { + stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) + } } else { - CollectTargetStatesPolicy() + stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) } + return stateInfoList + } + + 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) { @@ -225,13 +297,17 @@ internal class ExportPlantUmlVisitor( } } +@Suppress("UNCHECKED_CAST") +private val TransitionDirection.internalTargetStates get() = targetStates as Set + /** * Export [StateMachine] to PlantUML state diagram * @see PlantUML * * [showEventLabels] prints event types for transitions * [unsafeCallConditionalLambdas] will call conditional lambdas which can touch application data, - * this may give more complete output, but may be not safe. + * this may give more complete output, but may be not safe ([ClassCastException] may be thrown). + * * See [ExportMetaInfo] for more info. */ suspend fun StateMachine.exportToPlantUml( showEventLabels: Boolean = false, diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportToMermaid.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportToMermaid.kt index 2e112d2..84d1b7c 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportToMermaid.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/export/ExportToMermaid.kt @@ -7,6 +7,7 @@ package ru.nsk.kstatemachine.visitors.export +import ru.nsk.kstatemachine.metainfo.ExportMetaInfo import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.visitors.export.CompatibilityFormat.MERMAID @@ -16,7 +17,8 @@ import ru.nsk.kstatemachine.visitors.export.CompatibilityFormat.MERMAID * * [showEventLabels] prints event types for transitions * [unsafeCallConditionalLambdas] will call conditional lambdas which can touch application data, - * this may give more complete output, but may be not safe. + * this may give more complete output, but may be not safe ([ClassCastException] may be thrown). + * See [ExportMetaInfo] for more info. */ suspend fun StateMachine.exportToMermaid( showEventLabels: Boolean = false, diff --git a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt new file mode 100644 index 0000000..e8e6da2 --- /dev/null +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt @@ -0,0 +1,106 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.metainfo + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import ru.nsk.kstatemachine.CoroutineStarterType +import ru.nsk.kstatemachine.SwitchEvent +import ru.nsk.kstatemachine.createTestStateMachine +import ru.nsk.kstatemachine.state.choiceState +import ru.nsk.kstatemachine.state.initialState +import ru.nsk.kstatemachine.state.state +import ru.nsk.kstatemachine.state.transitionConditionally +import ru.nsk.kstatemachine.state.transitionOn +import ru.nsk.kstatemachine.transition.EventAndArgument +import ru.nsk.kstatemachine.transition.noTransition +import ru.nsk.kstatemachine.transition.stay +import ru.nsk.kstatemachine.transition.targetParallelStates +import ru.nsk.kstatemachine.transition.targetState +import ru.nsk.kstatemachine.visitors.export.exportToPlantUml + +private const val TEST = """ + TODO +""" + +class ExportMetaInfoTest : StringSpec({ + CoroutineStarterType.entries.forEach { coroutineStarterType -> + "ExportMetaInfo test" { + val machine = createTestStateMachine(coroutineStarterType) { + val state1 = initialState("State1") + val state2 = state("State2") + transitionOn { + targetState = { + if (false) state1 else state2 + } + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("if (false)",state1), + StateResolutionHint("else", state2), + ) + } + } + transitionConditionally { + direction = { + when(0) { + 1 -> targetState(state1) + 2 -> targetState(state2) + 3 -> targetParallelStates(state1, state2) + 4 -> stay() + else -> noTransition() + } + } + // provide state results + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("when 1", state1), + StateResolutionHint("when 2", state2), + StateResolutionHint("when 2", setOf(state1, state2)), // for parallel + ) + } + // provide direction results + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( +// DirectionResolutionHint(targetState(state1)), +// DirectionResolutionHint(targetState(state2)), +// DirectionResolutionHint(targetParallelStates(state1, state2)), + DirectionResolutionHint("when 4", stay()), + // does not make sense to use DirectionResolutionHint("else", noTransition()), + ) + } + // help with calculation + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + EventAndArgumentResolutionHint("SwitchEvent", SwitchEvent), + EventAndArgumentResolutionHint("SwitchEvent", SwitchEvent), + ) + } + + // mix help approaches + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("", state1), + DirectionResolutionHint("", stay()), + EventAndArgumentResolutionHint("", SwitchEvent), + ) + } + } + choiceState("choice") { + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("if (true)", state1), + StateResolutionHint("else", state2), + ) + } + if (true) state1 else state2 + } + } + machine.exportToPlantUml() shouldBe 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 new file mode 100644 index 0000000..80bc5a6 --- /dev/null +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt @@ -0,0 +1,166 @@ +/* + * Author: Mikhail Fedotov + * Github: https://github.com/KStateMachine + * Copyright (c) 2024. + * All rights reserved. + */ + +package ru.nsk.kstatemachine.metainfo + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import ru.nsk.kstatemachine.CoroutineStarterType +import ru.nsk.kstatemachine.SwitchEvent +import ru.nsk.kstatemachine.createTestStateMachine +import ru.nsk.kstatemachine.state.finalState +import ru.nsk.kstatemachine.state.initialChoiceState +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 +hide empty description +state "Nested states sm" as Meta_info_StateMachine { + state State_1 { + state State12 + state "Choice label" as ChoiceState <> + note right of ChoiceState : Note 1 + note right of ChoiceState : Note 2 + + [*] --> ChoiceState + } + state "Long State 3" as State3 + State3 : Description 1 + State3 : Description 2 + note right of State3 : Note 1 + note right of State3 : Note 2 + state "Long State 2" as State2 { + state "Final sub state" as FinalState + state Initial_subState + + [*] --> Initial_subState + Initial_subState --> FinalState : SwitchEvent + FinalState --> [*] + } + + [*] --> State_1 + State_1 --> State2 : go to State2, SwitchEvent + State_1 --> State_1 : self targeted, SwitchEvent + note on link + Note 1 + end note + note on link + Note 2 + end note + State2 --> State3 : That's all, SwitchEvent + State2 --> State_1 : back to State 1, SwitchEvent + State3 --> [*] +} +ChoiceState --> State12 +@enduml +""" + +private const val PLANTUML_COMPOSITE_META_INFO = """@startuml +hide empty description +state "Nested states sm" as Meta_info_StateMachine { + state State1 + + [*] --> State1 +} +@enduml +""" + +class UmlMetaInfoTest : StringSpec({ + CoroutineStarterType.entries.forEach { coroutineStarterType -> + "metaInfo export test" { + val machine = createTestStateMachine(coroutineStarterType, name = "Meta info") { + // label for state machine + metaInfo = buildUmlMetaInfo { umlLabel = "Nested states sm" } + + val state1 = initialState("State-1") + val state3 = finalState("State3") { + // label for state + metaInfo = buildUmlMetaInfo { + umlLabel = "Long State 3" + umlStateDescriptions = listOf("Description 1", "Description 2") + umlNotes = listOf("Note 1", "Note 2") + } + } + + val state2 = state("State2") { + // label for state + metaInfo = buildUmlMetaInfo { umlLabel = "Long State 2" } + transition { + // label for transition + metaInfo = buildUmlMetaInfo { umlLabel = "That's all" } + targetState = state3 + } + transition("back") { + // label for transition + metaInfo = buildUmlMetaInfo { umlLabel = "back to State 1" } + targetState = state1 + } + val finalSubState = finalState("FinalState") { + // label for state + metaInfo = buildUmlMetaInfo { umlLabel = "Final sub state" } + } + initialState("Initial subState") { + transition { targetState = finalSubState } + } + } + + state1 { + transition { + metaInfo = buildUmlMetaInfo { umlLabel = "go to ${state2.name}" } + targetState = state2 + } + transition("self targeted") { + targetState = this@state1 + metaInfo = buildUmlMetaInfo { umlNotes = listOf("Note 1", "Note 2") } + } + transition() + + val state12 = state("State12") + val choiceState = initialChoiceState("ChoiceState") { state12 } + choiceState.metaInfo = buildUmlMetaInfo { + umlLabel = "Choice label" + // no plantUml nor Mermaid can draw this + umlStateDescriptions = listOf("Description 1", "Description 2") + umlNotes = listOf("Note 1", "Note 2") + } + } + } + + machine.exportToPlantUml( + showEventLabels = true, + unsafeCallConditionalLambdas = true + ) shouldBe PLANTUML_META_INFO + } + + "CompositeMetaInfo vararg export test" { + val machine = createTestStateMachine(coroutineStarterType, name = "Meta info") { + // label for state machine + metaInfo = buildCompositeMetaInfo( + buildUmlMetaInfo { umlLabel = "Nested states sm" }, + object : MetaInfo {} // just a stub + ) + initialState("State1") + } + machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO + } + + "CompositeMetaInfo export test" { + val machine = createTestStateMachine(coroutineStarterType, name = "Meta info") { + // label for state machine + metaInfo = buildCompositeMetaInfo { + metaInfoSet = setOf(buildUmlMetaInfo { umlLabel = "Nested states sm" }) + } + initialState("State1") + } + machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO + } + } +}) \ No newline at end of file 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 ab80fa9..5595c80 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 @@ -15,6 +15,7 @@ import io.kotest.data.table import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldNotBeInstanceOf import ru.nsk.kstatemachine.* +import ru.nsk.kstatemachine.metainfo.IgnoreUnsafeCallConditionalLambdasMetaInfo import ru.nsk.kstatemachine.metainfo.MetaInfo import ru.nsk.kstatemachine.metainfo.buildCompositeMetaInfo import ru.nsk.kstatemachine.metainfo.buildUmlMetaInfo @@ -240,54 +241,28 @@ state1 --> state222 @enduml """ -private const val PLANTUML_META_INFO = """@startuml +private const val PLANTUML_MULTIPLE_TARGET_STATES_IGNORED_RESULT = """@startuml hide empty description -state "Nested states sm" as Meta_info_StateMachine { - state State_1 { - state State12 - state "Choice label" as ChoiceState <> - note right of ChoiceState : Note 1 - note right of ChoiceState : Note 2 - - [*] --> ChoiceState - } - state "Long State 3" as State3 - State3 : Description 1 - State3 : Description 2 - note right of State3 : Note 1 - note right of State3 : Note 2 - state "Long State 2" as State2 { - state "Final sub state" as FinalState - state Initial_subState +state state_machine_StateMachine { + state state1 + state state2 { + state state21 { + state state211 + state state212 + + [*] --> state211 + } + -- + state state22 { + state state221 + state state222 + + [*] --> state221 + } - [*] --> Initial_subState - Initial_subState --> FinalState : SwitchEvent - FinalState --> [*] } - [*] --> State_1 - State_1 --> State2 : go to State2, SwitchEvent - State_1 --> State_1 : self targeted, SwitchEvent - note on link - Note 1 - end note - note on link - Note 2 - end note - State2 --> State3 : That's all, SwitchEvent - State2 --> State_1 : back to State 1, SwitchEvent - State3 --> [*] -} -ChoiceState --> State12 -@enduml -""" - -private const val PLANTUML_COMPOSITE_META_INFO = """@startuml -hide empty description -state "Nested states sm" as Meta_info_StateMachine { - state State1 - - [*] --> State1 + [*] --> state1 } @enduml """ @@ -464,92 +439,33 @@ class ExportPlantUmlVisitorTest : StringSpec({ machine.exportToPlantUml(unsafeCallConditionalLambdas = true) shouldBe PLANTUML_MULTIPLE_TARGET_STATES_RESULT } - "metaInfo export test" { - val machine = createStateMachine(this, name = "Meta info") { - // label for state machine - metaInfo = buildUmlMetaInfo { umlLabel = "Nested states sm" } - - val state1 = initialState("State-1") - val state3 = finalState("State3") { - // label for state - metaInfo = buildUmlMetaInfo { - umlLabel = "Long State 3" - umlStateDescriptions = listOf("Description 1", "Description 2") - umlNotes = listOf("Note 1", "Note 2") - } - } + "plantUml export multiple target states, disable by metadata" { + lateinit var state212: State + lateinit var state222: State - val state2 = state("State2") { - // label for state - metaInfo = buildUmlMetaInfo { umlLabel = "Long State 2" } - transition { - // label for transition - metaInfo = buildUmlMetaInfo { umlLabel = "That's all" } - targetState = state3 - } - transition("back") { - // label for transition - metaInfo = buildUmlMetaInfo { umlLabel = "back to State 1" } - targetState = state1 - } - val finalSubState = finalState("FinalState") { - // label for state - metaInfo = buildUmlMetaInfo { umlLabel = "Final sub state" } - } - initialState("Initial subState") { - transition { targetState = finalSubState } + val machine = createTestStateMachine(coroutineStarterType, "state machine") { + initialState("state1") { + transitionConditionally { + metaInfo = IgnoreUnsafeCallConditionalLambdasMetaInfo + direction = { + event.shouldNotBeInstanceOf() // ExportPlantUmlEvent is provided as a fake + targetParallelStates(state212, state222) + } } } - - state1 { - transition { - metaInfo = buildUmlMetaInfo { umlLabel = "go to ${state2.name}" } - targetState = state2 - } - transition("self targeted") { - targetState = this@state1 - metaInfo = buildUmlMetaInfo { umlNotes = listOf("Note 1", "Note 2") } + state("state2", childMode = ChildMode.PARALLEL) { + state("state21") { + initialState("state211") + state212 = state("state212") } - transition() - - val state12 = state("State12") - val choiceState = initialChoiceState("ChoiceState") { state12 } - choiceState.metaInfo = buildUmlMetaInfo { - umlLabel = "Choice label" - // no plantUml nor Mermaid can draw this - umlStateDescriptions = listOf("Description 1", "Description 2") - umlNotes = listOf("Note 1", "Note 2") + state("state22") { + initialState("state221") + state222 = state("state222") } } } - machine.exportToPlantUml( - showEventLabels = true, - unsafeCallConditionalLambdas = true - ) shouldBe PLANTUML_META_INFO - } - - "CompositeMetaInfo vararg export test" { - val machine = createStateMachine(this, name = "Meta info") { - // label for state machine - metaInfo = buildCompositeMetaInfo( - buildUmlMetaInfo { umlLabel = "Nested states sm" }, - object : MetaInfo {} // just a stub - ) - initialState("State1") - } - machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO - } - - "CompositeMetaInfo export test" { - val machine = createStateMachine(this, name = "Meta info") { - // label for state machine - metaInfo = buildCompositeMetaInfo { - metaInfoSet = setOf(buildUmlMetaInfo { umlLabel = "Nested states sm" }) - } - initialState("State1") - } - machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO + machine.exportToPlantUml(unsafeCallConditionalLambdas = true) shouldBe PLANTUML_MULTIPLE_TARGET_STATES_IGNORED_RESULT } } }) \ No newline at end of file From 7d1178e2331e25506f377f9128897da122e83a3c Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Fri, 6 Dec 2024 20:04:18 +0800 Subject: [PATCH 3/6] Implement ResolutionHints --- .../visitors/export/ExportPlantUmlVisitor.kt | 63 ++++--- .../metainfo/ExportMetaInfoTest.kt | 178 +++++++++++------- .../export/ExportPlantUmlVisitorTest.kt | 9 +- 3 files changed, 147 insertions(+), 103 deletions(-) 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 dc1f12c..b50f596 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 @@ -22,6 +22,7 @@ import ru.nsk.kstatemachine.state.pseudo.UndoState import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.InternalTransition +import ru.nsk.kstatemachine.transition.NoTransition import ru.nsk.kstatemachine.transition.Transition import ru.nsk.kstatemachine.transition.TransitionDirection import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy @@ -48,18 +49,9 @@ internal enum class CompatibilityFormat { PLANT_UML, MERMAID } private data class TargetStateInfo( val description: String?, + /** Might be empty - means [NoTransition] */ val targetStates: Set, -) { - init { - require(targetStates.isNotEmpty()) { "targetStates must be non-empty." } - } - - /** - * PlantUML cant draw multiple target transitions. - * So I have to simplify it to just use first state. - */ - val targetState get() = targetStates.first() -} +) /** * Export state machine to Plant UML language format. @@ -110,9 +102,13 @@ internal class ExportPlantUmlVisitor( state.resolveTargetState(policy) } state.printStateNotes() - targetStateInfoList.forEach { targetStateInfo -> - //todo use description - crossLevelTransitions += "${state.graphName()} --> ${targetStateInfo.targetState.targetGraphName()}" + for (targetStateInfo in targetStateInfoList) { + // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. + for (targetState in targetStateInfo.targetStates) { + crossLevelTransitions += "${state.graphName()} --> " + + targetState.targetGraphName() + + transitionLabel(targetStateInfo.description) + } } } else -> { @@ -146,15 +142,18 @@ internal class ExportPlantUmlVisitor( val targetStateInfoList = executeDirectionProducerPolicy(transition.metaInfo) { policy -> transition.produceTargetStateDirection(policy) } - targetStateInfoList.forEach { targetStateInfo -> // actually plantUml may not understand multiple transitions - //todo use description - val transitionString = - "$sourceState --> ${targetStateInfo.targetState.targetGraphName()}${transitionLabel(transition)}" - - if (transition.sourceState.isNeighbor(targetStateInfo.targetState)) - line(transitionString) - else - crossLevelTransitions += transitionString + for (targetStateInfo in targetStateInfoList) { + // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. + for (targetState in targetStateInfo.targetStates) { + val transitionString = "$sourceState --> " + + targetState.targetGraphName() + + transitionLabel(transition, targetStateInfo.description) + + if (transition.sourceState.isNeighbor(targetState)) + line(transitionString) + else + crossLevelTransitions += transitionString + } } if (format != MERMAID) { // Mermaid does not support this @@ -199,10 +198,16 @@ internal class ExportPlantUmlVisitor( TargetStateInfo(it.description, it.internalTargetStates) } } else { - stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) + stateInfoList += TargetStateInfo( + null, + block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates + ) } } else { - stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) + stateInfoList += TargetStateInfo( + null, + block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates + ) } } else { stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) @@ -242,6 +247,7 @@ internal class ExportPlantUmlVisitor( if (initialState != null) line("$STAR --> ${initialState.graphName()}") + //FIXME why I skipped own transitions??? and why i getting children transitions here? // visit transitions states.flatMap { it.transitions }.forEach { visit(it) } @@ -252,14 +258,17 @@ internal class ExportPlantUmlVisitor( private fun line(text: String) = builder.appendLine(SINGLE_INDENT.repeat(indent) + text) - private fun transitionLabel(transition: Transition<*>): String { + private fun transitionLabel(transition: Transition<*>, description: String?): String { val text = listOfNotNull( transition.metaInfo?.umlLabel ?: transition.name, transition.eventMatcher.eventClass.simpleName.takeIf { showEventLabels }, + description, ).joinToString() - return " : $text".takeIf { text.isNotBlank() } ?: "" + return transitionLabel(text) } + private fun transitionLabel(text: String?) = " : $text".takeIf { text?.isNotBlank() == true } ?: "" + private fun IState.printStateDescriptions() { val descriptions = (metaInfo as? UmlMetaInfo)?.umlStateDescriptions.orEmpty() descriptions.forEach { line("${graphName()} : $it") } 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 e8e6da2..6f30dc9 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt @@ -10,97 +10,129 @@ package ru.nsk.kstatemachine.metainfo import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import ru.nsk.kstatemachine.CoroutineStarterType -import ru.nsk.kstatemachine.SwitchEvent import ru.nsk.kstatemachine.createTestStateMachine +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.metainfo.ExportMetaInfoTestData.ValueEvent1 +import ru.nsk.kstatemachine.metainfo.ExportMetaInfoTestData.ValueEvent2 import ru.nsk.kstatemachine.state.choiceState import ru.nsk.kstatemachine.state.initialState import ru.nsk.kstatemachine.state.state import ru.nsk.kstatemachine.state.transitionConditionally import ru.nsk.kstatemachine.state.transitionOn -import ru.nsk.kstatemachine.transition.EventAndArgument +import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.transition.noTransition import ru.nsk.kstatemachine.transition.stay import ru.nsk.kstatemachine.transition.targetParallelStates import ru.nsk.kstatemachine.transition.targetState import ru.nsk.kstatemachine.visitors.export.exportToPlantUml -private const val TEST = """ - TODO +private const val EXPORT_META_INFO_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 +State1 --> State11 : if (event.value == 42) +State1 --> State12 : else +@enduml """ -class ExportMetaInfoTest : StringSpec({ - CoroutineStarterType.entries.forEach { coroutineStarterType -> - "ExportMetaInfo test" { - val machine = createTestStateMachine(coroutineStarterType) { - val state1 = initialState("State1") - val state2 = state("State2") - transitionOn { - targetState = { - if (false) state1 else state2 - } - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( - StateResolutionHint("if (false)",state1), - StateResolutionHint("else", state2), - ) - } - } - transitionConditionally { - direction = { - when(0) { - 1 -> targetState(state1) - 2 -> targetState(state2) - 3 -> targetParallelStates(state1, state2) - 4 -> stay() - else -> noTransition() - } - } - // provide state results - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( - StateResolutionHint("when 1", state1), - StateResolutionHint("when 2", state2), - StateResolutionHint("when 2", setOf(state1, state2)), // for parallel - ) - } - // provide direction results - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( -// DirectionResolutionHint(targetState(state1)), -// DirectionResolutionHint(targetState(state2)), -// DirectionResolutionHint(targetParallelStates(state1, state2)), - DirectionResolutionHint("when 4", stay()), - // does not make sense to use DirectionResolutionHint("else", noTransition()), - ) - } - // help with calculation - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( - EventAndArgumentResolutionHint("SwitchEvent", SwitchEvent), - EventAndArgumentResolutionHint("SwitchEvent", SwitchEvent), - ) - } +private const val EXPORT_META_INFO_WITH_LABELS_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 +State1 --> State11 : ValueEvent1, if (event.value == 42) +State1 --> State12 : ValueEvent1, else +@enduml +""" + +private object ExportMetaInfoTestData { + class ValueEvent1(val value: Int) : Event + class ValueEvent2(val value: Int) : Event +} - // mix help approaches - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( - StateResolutionHint("", state1), - DirectionResolutionHint("", stay()), - EventAndArgumentResolutionHint("", SwitchEvent), - ) - } +private suspend fun createTestMachine(coroutineStarterType: CoroutineStarterType): StateMachine { + return createTestStateMachine(coroutineStarterType, "ExportMetaInfoStateMachine") { + val state1 = initialState("State1") { + val state11 = initialState("State11") + val state12 = state("State12") + transitionOn { + targetState = { if (event.value == 42) state11 else state12 } + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + EventAndArgumentResolutionHint("if (event.value == 42)", ValueEvent1(42)), + EventAndArgumentResolutionHint("else", ValueEvent1(0)), + ) } - choiceState("choice") { - metaInfo = buildExportMetaInfo { - resolutionHints = setOf( - StateResolutionHint("if (true)", state1), - StateResolutionHint("else", state2), - ) - } - if (true) state1 else state2 + } + } + val state2 = state("State2") + + transitionConditionally { + direction = { + when (event.value) { + 1 -> targetState(state1) + 2 -> targetState(state2) + 3 -> targetParallelStates(state1, state2) + 4 -> stay() + else -> noTransition() } } - machine.exportToPlantUml() shouldBe TEST + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("when 1", state1), + EventAndArgumentResolutionHint("when 2", ValueEvent2(2)), + StateResolutionHint("when 3", setOf(state1, state2)), + StateResolutionHint("when 4", this@createTestStateMachine), + EventAndArgumentResolutionHint("else", ValueEvent2(5)), + ) + } + } + val choiceState = choiceState("choiceState") { if (true) state1 else state2 } + choiceState.metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + StateResolutionHint("if (true)", state1), + StateResolutionHint(" ", state2), + ) + } + } +} + +class ExportMetaInfoTest : StringSpec({ + CoroutineStarterType.entries.forEach { coroutineStarterType -> + "ExportMetaInfo test" { + val machine = createTestMachine(coroutineStarterType) + machine.exportToPlantUml(unsafeCallConditionalLambdas = true) shouldBe EXPORT_META_INFO_TEST + } + + "ExportMetaInfo test with labels" { + val machine = createTestMachine(coroutineStarterType) + machine.exportToPlantUml( + showEventLabels = true, + unsafeCallConditionalLambdas = true + ) shouldBe EXPORT_META_INFO_WITH_LABELS_TEST } } }) \ No newline at end of file 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 5595c80..3e55eab 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 @@ -236,8 +236,8 @@ state state_machine_StateMachine { [*] --> state1 } -state1 --> state212 -state1 --> state222 +state1 --> state212 : SwitchEvent +state1 --> state222 : SwitchEvent @enduml """ @@ -436,7 +436,10 @@ class ExportPlantUmlVisitorTest : StringSpec({ } } - machine.exportToPlantUml(unsafeCallConditionalLambdas = true) shouldBe PLANTUML_MULTIPLE_TARGET_STATES_RESULT + machine.exportToPlantUml( + showEventLabels = true, + unsafeCallConditionalLambdas = true + ) shouldBe PLANTUML_MULTIPLE_TARGET_STATES_RESULT } "plantUml export multiple target states, disable by metadata" { From b14a331d53d93c45c7b6af831cbba0ee21a6db69 Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Thu, 12 Dec 2024 01:07:10 +0800 Subject: [PATCH 4/6] Fix top level state machine transitions export They were completely ignored before this commit. --- .../kstatemachine/statemachine/StateMachineImpl.kt | 9 +++++++-- .../visitors/export/ExportPlantUmlVisitor.kt | 14 +++++++++++--- .../kstatemachine/metainfo/ExportMetaInfoTest.kt | 10 ++++++++++ .../visitors/export/ExportPlantUmlVisitorTest.kt | 4 ++++ 4 files changed, 32 insertions(+), 5 deletions(-) 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 f9cb181..29e96a7 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachineImpl.kt @@ -10,6 +10,7 @@ package ru.nsk.kstatemachine.statemachine import ru.nsk.kstatemachine.coroutines.CoroutineAbstraction import ru.nsk.kstatemachine.event.* import ru.nsk.kstatemachine.isSubStateOf +import ru.nsk.kstatemachine.metainfo.IgnoreUnsafeCallConditionalLambdasMetaInfo import ru.nsk.kstatemachine.persistence.EventRecorder import ru.nsk.kstatemachine.persistence.EventRecorderImpl import ru.nsk.kstatemachine.state.* @@ -22,6 +23,9 @@ import ru.nsk.kstatemachine.visitors.CleanupVisitor import ru.nsk.kstatemachine.visitors.checkNonBlankNames import kotlin.reflect.KClass +internal const val START_TRANSITION_NAME = "start transition" +internal const val UNDO_TRANSITION_NAME = "undo transition" + internal class StateMachineImpl( name: String?, childMode: ChildMode, @@ -81,7 +85,7 @@ internal class StateMachineImpl( } init { - transitionConditionally("start transition") { + transitionConditionally(START_TRANSITION_NAME) { direction = { when (event) { is StartEventImpl -> { @@ -94,10 +98,11 @@ internal class StateMachineImpl( is StartDataEventImpl<*> -> targetState(event.startState) } } + metaInfo = IgnoreUnsafeCallConditionalLambdasMetaInfo } if (creationArguments.isUndoEnabled) { val undoState = addState(UndoState()) - transition("undo transition", undoState) + transition(UNDO_TRANSITION_NAME, undoState) } } 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 b50f596..4b01969 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 @@ -19,7 +19,9 @@ import ru.nsk.kstatemachine.metainfo.StateResolutionHint import ru.nsk.kstatemachine.metainfo.findMetaInfo import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.state.pseudo.UndoState +import ru.nsk.kstatemachine.statemachine.START_TRANSITION_NAME import ru.nsk.kstatemachine.statemachine.StateMachine +import ru.nsk.kstatemachine.statemachine.UNDO_TRANSITION_NAME import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.InternalTransition import ru.nsk.kstatemachine.transition.NoTransition @@ -53,6 +55,8 @@ private data class TargetStateInfo( val targetStates: Set, ) +private val internalTransitions = listOf(START_TRANSITION_NAME, UNDO_TRANSITION_NAME) + /** * Export state machine to Plant UML language format. * @see Plant UML state diagram @@ -83,6 +87,9 @@ internal class ExportPlantUmlVisitor( line("state ${machine.labelGraphName()} {") ++indent processStateBody(machine) + machine.transitions.forEach { + if (it.name !in internalTransitions) visit(it) + } --indent line("}") @@ -247,9 +254,10 @@ internal class ExportPlantUmlVisitor( if (initialState != null) line("$STAR --> ${initialState.graphName()}") - //FIXME why I skipped own transitions??? and why i getting children transitions here? - // visit transitions - states.flatMap { it.transitions }.forEach { visit(it) } + // visit transitions, skipping internal StateMachines + states.flatMap { + if (it !is StateMachine) it.transitions else emptySet() + }.forEach { visit(it) } // add finish transitions states.filterIsInstance() 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 6f30dc9..5a18936 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfoTest.kt @@ -44,6 +44,11 @@ choiceState --> State1 : if (true) choiceState --> State2 State1 --> State11 : if (event.value == 42) State1 --> State12 : else +ExportMetaInfoStateMachine_StateMachine --> State2 : when 2 +ExportMetaInfoStateMachine_StateMachine --> State1 : when 1 +ExportMetaInfoStateMachine_StateMachine --> State1 : when 3 +ExportMetaInfoStateMachine_StateMachine --> State2 : when 3 +ExportMetaInfoStateMachine_StateMachine --> ExportMetaInfoStateMachine_StateMachine : when 4 @enduml """ @@ -65,6 +70,11 @@ choiceState --> State1 : if (true) choiceState --> State2 State1 --> State11 : ValueEvent1, if (event.value == 42) State1 --> State12 : ValueEvent1, else +ExportMetaInfoStateMachine_StateMachine --> State2 : ValueEvent2, when 2 +ExportMetaInfoStateMachine_StateMachine --> State1 : ValueEvent2, when 1 +ExportMetaInfoStateMachine_StateMachine --> State1 : ValueEvent2, when 3 +ExportMetaInfoStateMachine_StateMachine --> State2 : ValueEvent2, when 3 +ExportMetaInfoStateMachine_StateMachine --> ExportMetaInfoStateMachine_StateMachine : ValueEvent2, when 4 @enduml """ 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 3e55eab..d10c028 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 @@ -7,6 +7,7 @@ package ru.nsk.kstatemachine.visitors.export +import io.kotest.core.spec.style.FreeSpec import io.kotest.core.spec.style.StringSpec import io.kotest.data.forAll import io.kotest.data.headers @@ -210,6 +211,7 @@ state outer_machine_StateMachine { [*] --> outer_state1 } +outer_machine_StateMachine -> outer_machine_StateMachine @enduml """ @@ -402,10 +404,12 @@ class ExportPlantUmlVisitorTest : StringSpec({ val inner = createTestStateMachine(coroutineStarterType, name = "inner machine") { initialState("inner state1") state("inner state2") + transition() // should be ignored } val outer = createTestStateMachine(coroutineStarterType, name = "outer machine") { initialState("outer state1") addState(inner) + transition() } outer.exportToPlantUml() shouldBe PLANTUML_COMPOSED_MACHINES_RESULT From 906c0eb3d128e2e576dc144a31c3f3eab95410a4 Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Thu, 12 Dec 2024 18:22:30 +0800 Subject: [PATCH 5/6] Fix ExportPlantUmlVisitor support of Stay TraniitionDirection --- .../kstatemachine/metainfo/ExportMetaInfo.kt | 3 - .../transition/TransitionDirection.kt | 3 + .../visitors/export/ExportPlantUmlVisitor.kt | 68 ++++++++++++------- .../kstatemachine/metainfo/UmlMetaInfoTest.kt | 1 + .../export/ExportPlantUmlVisitorTest.kt | 9 ++- 5 files changed, 51 insertions(+), 33 deletions(-) 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 f543426..89fd7cc 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt @@ -40,9 +40,6 @@ class StateResolutionHint( "targetStates must be non-empty, use single state or multiple states for parallel transitions" } } - - @Suppress("UNCHECKED_CAST") - internal val internalTargetStates: Set get() = targetStates as Set } /** diff --git a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionDirection.kt b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionDirection.kt index ab71b49..214ad6e 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionDirection.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/transition/TransitionDirection.kt @@ -14,6 +14,9 @@ import ru.nsk.kstatemachine.state.pseudo.UndoState import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy.DefaultPolicy +/** + * Caller should check subclass to recognise/distinguish [NoTransition] and [Stay] cases. + */ sealed interface TransitionDirection { /** * Already resolved target state of conditional transition or [PseudoState] computation 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 4b01969..ff462eb 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 @@ -25,6 +25,8 @@ import ru.nsk.kstatemachine.statemachine.UNDO_TRANSITION_NAME import ru.nsk.kstatemachine.transition.EventAndArgument import ru.nsk.kstatemachine.transition.InternalTransition import ru.nsk.kstatemachine.transition.NoTransition +import ru.nsk.kstatemachine.transition.Stay +import ru.nsk.kstatemachine.transition.TargetState import ru.nsk.kstatemachine.transition.Transition import ru.nsk.kstatemachine.transition.TransitionDirection import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy @@ -51,8 +53,7 @@ internal enum class CompatibilityFormat { PLANT_UML, MERMAID } private data class TargetStateInfo( val description: String?, - /** Might be empty - means [NoTransition] */ - val targetStates: Set, + val transitionDirection: TransitionDirection, ) private val internalTransitions = listOf(START_TRANSITION_NAME, UNDO_TRANSITION_NAME) @@ -109,12 +110,22 @@ internal class ExportPlantUmlVisitor( state.resolveTargetState(policy) } state.printStateNotes() + val stateGraphName = state.graphName() for (targetStateInfo in targetStateInfoList) { - // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. - for (targetState in targetStateInfo.targetStates) { - crossLevelTransitions += "${state.graphName()} --> " + - targetState.targetGraphName() + + val transitionDirection = targetStateInfo.transitionDirection + when (transitionDirection) { + NoTransition -> continue + // not possible actually, as it is an infinite loop + Stay -> crossLevelTransitions += "$stateGraphName --> $stateGraphName" + transitionLabel(targetStateInfo.description) + is TargetState -> { + // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. + @Suppress("UNCHECKED_CAST") + for (targetState in targetStateInfo.transitionDirection.targetStates as Set) { + crossLevelTransitions += "$stateGraphName --> ${targetState.targetGraphName()}" + + transitionLabel(targetStateInfo.description) + } + } } } } @@ -145,21 +156,31 @@ internal class ExportPlantUmlVisitor( override suspend fun visit(transition: Transition) { transition as InternalTransition - val sourceState = transition.sourceState.graphName() + val sourceStateGraphName = transition.sourceState.graphName() val targetStateInfoList = executeDirectionProducerPolicy(transition.metaInfo) { policy -> transition.produceTargetStateDirection(policy) } for (targetStateInfo in targetStateInfoList) { - // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. - for (targetState in targetStateInfo.targetStates) { - val transitionString = "$sourceState --> " + - targetState.targetGraphName() + - transitionLabel(transition, targetStateInfo.description) - - if (transition.sourceState.isNeighbor(targetState)) - line(transitionString) - else - crossLevelTransitions += transitionString + val transitionDirection = targetStateInfo.transitionDirection + when (transitionDirection) { + NoTransition -> continue + Stay -> line( + "$sourceStateGraphName --> $sourceStateGraphName" + + transitionLabel(transition, targetStateInfo.description) + ) + is TargetState -> { + // PlantUML cant draw multi-target transitions. So I have to simply loop through all the targets. + @Suppress("UNCHECKED_CAST") + for (targetState in transitionDirection.targetStates as Set) { + val transitionString = "$sourceStateGraphName --> ${targetState.targetGraphName()}" + + transitionLabel(transition, targetStateInfo.description) + + if (transition.sourceState.isNeighbor(targetState)) + line(transitionString) + else + crossLevelTransitions += transitionString + } + } } } @@ -197,27 +218,27 @@ internal class ExportPlantUmlVisitor( val eventAndArgument = it.eventAndArgument as EventAndArgument TargetStateInfo( it.description, - block(makeDirectionProducerPolicy(metaInfo, eventAndArgument)).internalTargetStates + block(makeDirectionProducerPolicy(metaInfo, eventAndArgument)) ) } hintMap[StateResolutionHint::class]?.mapTo(stateInfoList) { it as StateResolutionHint - TargetStateInfo(it.description, it.internalTargetStates) + TargetStateInfo(it.description, TargetState(it.targetStates)) } } else { stateInfoList += TargetStateInfo( null, - block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates + block(makeDirectionProducerPolicy(metaInfo)) ) } } else { stateInfoList += TargetStateInfo( null, - block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates + block(makeDirectionProducerPolicy(metaInfo)) ) } } else { - stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo)).internalTargetStates) + stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy(metaInfo))) } return stateInfoList } @@ -314,9 +335,6 @@ internal class ExportPlantUmlVisitor( } } -@Suppress("UNCHECKED_CAST") -private val TransitionDirection.internalTargetStates get() = targetStates as Set - /** * Export [StateMachine] to PlantUML state diagram * @see PlantUML 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 80bc5a6..da9a8c2 100644 --- a/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt +++ b/tests/src/commonTest/kotlin/ru/nsk/kstatemachine/metainfo/UmlMetaInfoTest.kt @@ -55,6 +55,7 @@ state "Nested states sm" as Meta_info_StateMachine { note on link Note 2 end note + State_1 --> State_1 : SwitchEvent State2 --> State3 : That's all, SwitchEvent State2 --> State_1 : back to State 1, SwitchEvent State3 --> [*] 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 d10c028..4bbf5b6 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 @@ -7,7 +7,6 @@ package ru.nsk.kstatemachine.visitors.export -import io.kotest.core.spec.style.FreeSpec import io.kotest.core.spec.style.StringSpec import io.kotest.data.forAll import io.kotest.data.headers @@ -17,13 +16,10 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldNotBeInstanceOf import ru.nsk.kstatemachine.* import ru.nsk.kstatemachine.metainfo.IgnoreUnsafeCallConditionalLambdasMetaInfo -import ru.nsk.kstatemachine.metainfo.MetaInfo -import ru.nsk.kstatemachine.metainfo.buildCompositeMetaInfo import ru.nsk.kstatemachine.metainfo.buildUmlMetaInfo import ru.nsk.kstatemachine.state.* import ru.nsk.kstatemachine.statemachine.StateMachine import ru.nsk.kstatemachine.statemachine.buildCreationArguments -import ru.nsk.kstatemachine.statemachine.createStateMachine import ru.nsk.kstatemachine.transition.targetParallelStates private const val PLANTUML_NESTED_STATES_RESULT = """@startuml @@ -43,6 +39,7 @@ state Nested_states_StateMachine { [*] --> State1 State1 --> State2 : to State2 State1 --> State1 + State1 --> State1 State2 --> State3 State2 --> State1 : back State3 --> [*] @@ -67,6 +64,7 @@ state Nested_states_StateMachine { [*] --> State1 State1 --> State2 : to State2, SwitchEvent State1 --> State1 : SwitchEvent + State1 --> State1 : SwitchEvent State2 --> State3 : SwitchEvent State2 --> State1 : back, SwitchEvent State3 --> [*] @@ -90,6 +88,7 @@ state Nested_states_StateMachine { [*] --> State1 State1 --> State2 : to State2 State1 --> State1 + State1 --> State1 State2 --> State3 State2 --> State1 : back State3 --> [*] @@ -210,8 +209,8 @@ state outer_machine_StateMachine { state inner_machine_StateMachine [*] --> outer_state1 + outer_machine_StateMachine --> outer_machine_StateMachine } -outer_machine_StateMachine -> outer_machine_StateMachine @enduml """ From 27fe9061bde1857e8a0abda492edd95bdd15144f Mon Sep 17 00:00:00 2001 From: Mikhail Fedotov Date: Fri, 13 Dec 2024 00:45:57 +0800 Subject: [PATCH 6/6] [docs] Update MetaInfo documentation --- docs/pages/export.md | 50 +++++++++++++++++- docs/pages/meta_information.md | 21 ++++++-- kstatemachine/api/kstatemachine.api | 52 ++++++++++++++++++- .../kstatemachine/metainfo/ExportMetaInfo.kt | 1 - .../ru/nsk/kstatemachine/metainfo/MetaInfo.kt | 6 ++- .../visitors/export/ExportPlantUmlVisitor.kt | 2 +- 6 files changed, 123 insertions(+), 9 deletions(-) diff --git a/docs/pages/export.md b/docs/pages/export.md index 85aec0d..8b3753e 100644 --- a/docs/pages/export.md +++ b/docs/pages/export.md @@ -4,13 +4,15 @@ title: Export --- # Export + {: .no_toc } ## Page contents + {: .no_toc .text-delta } - TOC -{:toc} + {:toc} The library supports export into PlantUML and Mermaid diagram drawing systems. They both use PlantUML text format. Mermaid supports fewer features then PlantUML itself. @@ -27,6 +29,50 @@ but may cause runtime errors depending on what the lambda actually do. As it may valid when export is running, also `event` argument will be faked by unsafe cast, so touching it will cause `ClassCastException` That is why `unsafeCallConditionalLambdas` flag should be considered as debug/development tool only. +The library provides additional `MetaInfo` objects that might be used along with `unsafeCallConditionalLambdas` flag +to provide complete output (with a help of a user). + +* `IgnoreUnsafeCallConditionalLambdasMetaInfo` allows to ignore `unsafeCallConditionalLambdas` flag for some state or + transition +* `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. + There is `StateResolutionHint` which is useful to specify target states (so lambda execution is not needed) + and `EventAndArgumentResolutionHint` allowing to specify `Event` and argument, which will be used to execute a + conditional lambda. This allows to bypass limitations of default behaviour with fake event. + + Here are some usage samples: + ```kotlin + class ValueEvent(val value: Int) : Event + + transitionConditionally { + direction = { + when (event.value) { + 1 -> targetState(state1) + 2 -> targetState(state2) + 3 -> targetParallelStates(state1, state2) + 4 -> stay() + else -> noTransition() + } + } + metaInfo = buildExportMetaInfo { + resolutionHints = setOf( + // the library does not need to call "direction" lambda, this hint provides the result (state1) directly + StateResolutionHint("when 1", state1), + // calls "direction" lambda during export with specified Event and optional argument (lambda will return state2) + EventAndArgumentResolutionHint("when 2", ValueEvent(2)), + // you can specify set of states that represents parallel target states + StateResolutionHint("when 3", setOf(state1, state2)), + // describes stay() behaviour without calling "direction" lambda + StateResolutionHint("when 4", this@createStateMachine), + // resolves to stay() by calling "direction" lambda + EventAndArgumentResolutionHint("when 4", ValueEvent(4)), + // useless, does not affect export output as it resolves to noTransition() + EventAndArgumentResolutionHint("else", ValueEvent(5)), + ) + } + } + ``` ## PlantUML @@ -64,7 +110,7 @@ See [Mermaid nested states export sample](https://github.com/KStateMachine/kstat ## Controlling export output -To beautify and enrich export output, you can use `UmlMetaInfo` for both `IState` and `Transition`. It can be built +To beautify and enrich export output, you can use `UmlMetaInfo` for both `IState` and `Transition`. It can be built with `buildUmlMetaInfo()` function: ```kotlin diff --git a/docs/pages/meta_information.md b/docs/pages/meta_information.md index 9006926..e0ca045 100644 --- a/docs/pages/meta_information.md +++ b/docs/pages/meta_information.md @@ -14,12 +14,27 @@ title: Meta information ## `metaInfo` property -The library provides `metaInfo` property for `IState` and `Transition` types. +The library declares `metaInfo` property for `IState` and `Transition` types. `MetaInfo` is a marker interface allowing to attach some static information to library primitives. This mechanism is extendable and users may add their own `MetaInfo` sub interfaces/classes if necessary. -Currently, the only standard implementation is `UmlMetaInfo` which is useful for export feature. + +One of standard implementations is `UmlMetaInfo` which is useful for export feature. See [controlling export output](https://kstatemachine.github.io/kstatemachine/pages/export.html#controlling-export-output). You can build it using `buildUmlMetaInfo()` function. {: .note } -`MetaInfo` considered to be immutable data by design \ No newline at end of file +`MetaInfo` considered to be immutable data by design + +## MetaInfo composition + +If you need to specify more than one `MetaInfo` subtypes for `IState` or `Transition` you have two options: +1) Use `CompositeMetaInfo` which is constructed by `buildCompositeMetaInfo` builder function. +This type allows to specify a set of `MetaInfo` objects. + + Limitations: + * `CompositeMetaInfo` cannot nest into each other. Only one layer is supported. + * Don't try to specify `MetaInfo` of same type multiple times. This is wrong. + Certain `MetaInfo` subtype should be applied only once. Exception will be thrown otherwise. +2) Manually implement all required `MetaInfo` interfaces in a single object. + +Both options are supported, choose any one you like more. \ No newline at end of file diff --git a/kstatemachine/api/kstatemachine.api b/kstatemachine/api/kstatemachine.api index 314641e..13d45be 100644 --- a/kstatemachine/api/kstatemachine.api +++ b/kstatemachine/api/kstatemachine.api @@ -91,11 +91,57 @@ public final class ru/nsk/kstatemachine/event/WrappedEvent : ru/nsk/kstatemachin public final fun getEvent ()Lru/nsk/kstatemachine/event/Event; } +public abstract interface class ru/nsk/kstatemachine/metainfo/CompositeMetaInfo : ru/nsk/kstatemachine/metainfo/MetaInfo { + public abstract fun getMetaInfoSet ()Ljava/util/Set; +} + +public abstract interface class ru/nsk/kstatemachine/metainfo/CompositeMetaInfoBuilder : ru/nsk/kstatemachine/metainfo/CompositeMetaInfo { + public abstract fun getMetaInfoSet ()Ljava/util/Set; + public abstract fun setMetaInfoSet (Ljava/util/Set;)V +} + +public final class ru/nsk/kstatemachine/metainfo/EventAndArgumentResolutionHint : ru/nsk/kstatemachine/metainfo/ResolutionHint { + public fun (Ljava/lang/String;Lru/nsk/kstatemachine/event/Event;Ljava/lang/Object;)V + public synthetic fun (Ljava/lang/String;Lru/nsk/kstatemachine/event/Event;Ljava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getArgument ()Ljava/lang/Object; + public final fun getDescription ()Ljava/lang/String; + public final fun getEvent ()Lru/nsk/kstatemachine/event/Event; + public final fun getEventAndArgument ()Lru/nsk/kstatemachine/transition/EventAndArgument; +} + +public abstract interface class ru/nsk/kstatemachine/metainfo/ExportMetaInfo : ru/nsk/kstatemachine/metainfo/MetaInfo { + public abstract fun getResolutionHints ()Ljava/util/Set; +} + +public abstract interface class ru/nsk/kstatemachine/metainfo/ExportMetaInfoBuilder : ru/nsk/kstatemachine/metainfo/ExportMetaInfo { + public abstract fun getResolutionHints ()Ljava/util/Set; + public abstract fun setResolutionHints (Ljava/util/Set;)V +} + +public final class ru/nsk/kstatemachine/metainfo/ExportMetaInfoKt { + public static final fun buildExportMetaInfo (Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/metainfo/ExportMetaInfo; +} + +public final class ru/nsk/kstatemachine/metainfo/IgnoreUnsafeCallConditionalLambdasMetaInfo : ru/nsk/kstatemachine/metainfo/MetaInfo { + public static final field INSTANCE Lru/nsk/kstatemachine/metainfo/IgnoreUnsafeCallConditionalLambdasMetaInfo; +} + public abstract interface class ru/nsk/kstatemachine/metainfo/MetaInfo { } public final class ru/nsk/kstatemachine/metainfo/MetaInfoKt { - public static final fun buildUmlMetaInfo (Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/metainfo/UmlMetaInfo; + public static final fun buildCompositeMetaInfo (Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/metainfo/CompositeMetaInfo; + public static final fun buildCompositeMetaInfo (Lru/nsk/kstatemachine/metainfo/MetaInfo;Lru/nsk/kstatemachine/metainfo/MetaInfo;[Lru/nsk/kstatemachine/metainfo/MetaInfo;)Lru/nsk/kstatemachine/metainfo/CompositeMetaInfo; +} + +public abstract interface class ru/nsk/kstatemachine/metainfo/ResolutionHint { +} + +public final class ru/nsk/kstatemachine/metainfo/StateResolutionHint : ru/nsk/kstatemachine/metainfo/ResolutionHint { + public fun (Ljava/lang/String;Ljava/util/Set;)V + public fun (Ljava/lang/String;Lru/nsk/kstatemachine/state/IState;)V + public final fun getDescription ()Ljava/lang/String; + public final fun getTargetStates ()Ljava/util/Set; } public abstract interface class ru/nsk/kstatemachine/metainfo/UmlMetaInfo : ru/nsk/kstatemachine/metainfo/MetaInfo { @@ -113,6 +159,10 @@ public abstract interface class ru/nsk/kstatemachine/metainfo/UmlMetaInfoBuilder public abstract fun setUmlStateDescriptions (Ljava/util/List;)V } +public final class ru/nsk/kstatemachine/metainfo/UmlMetaInfoKt { + public static final fun buildUmlMetaInfo (Lkotlin/jvm/functions/Function1;)Lru/nsk/kstatemachine/metainfo/UmlMetaInfo; +} + public final class ru/nsk/kstatemachine/persistence/EmptyValidator : ru/nsk/kstatemachine/persistence/RestorationResultValidator { public static final field INSTANCE Lru/nsk/kstatemachine/persistence/EmptyValidator; public fun validate (Lru/nsk/kstatemachine/persistence/RestorationResult;Lru/nsk/kstatemachine/persistence/RecordedEvents;Lru/nsk/kstatemachine/statemachine/StateMachine;)V 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 89fd7cc..c775c30 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/ExportMetaInfo.kt @@ -9,7 +9,6 @@ package ru.nsk.kstatemachine.metainfo import ru.nsk.kstatemachine.event.Event import ru.nsk.kstatemachine.state.IState -import ru.nsk.kstatemachine.state.InternalState import ru.nsk.kstatemachine.state.RedirectPseudoState import ru.nsk.kstatemachine.transition.EventAndArgument 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 d537466..438f7c0 100644 --- a/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt +++ b/kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/metainfo/MetaInfo.kt @@ -21,6 +21,7 @@ interface MetaInfo * Allows to specify multiple [MetaInfo] objects. * It might be simpler than constructing single object implementing multiple [MetaInfo] derived interfaces. * Nesting [CompositeMetaInfo] into each other is not supported. + * Only one instance of certain [MetaInfo] subtype should be specified. */ interface CompositeMetaInfo : MetaInfo { /** @@ -29,7 +30,10 @@ interface CompositeMetaInfo : MetaInfo { val metaInfoSet: Set } -internal inline fun MetaInfo?.findMetaInfo(): M? { +/** + * Helper method for getting [MetaInfo] of specified type + */ +inline fun MetaInfo?.findMetaInfo(): M? { return when (this) { is M -> this is CompositeMetaInfo -> metaInfoSet.singleOrNull { it is M } as? M 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 ff462eb..8e3e2ae 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 @@ -299,7 +299,7 @@ internal class ExportPlantUmlVisitor( private fun transitionLabel(text: String?) = " : $text".takeIf { text?.isNotBlank() == true } ?: "" private fun IState.printStateDescriptions() { - val descriptions = (metaInfo as? UmlMetaInfo)?.umlStateDescriptions.orEmpty() + val descriptions = metaInfo.findMetaInfo()?.umlStateDescriptions.orEmpty() descriptions.forEach { line("${graphName()} : $it") } }