Skip to content

Commit

Permalink
Add CheckMetaInfoStructureVisitor to verify correct MetaInfo structur…
Browse files Browse the repository at this point in the history
…e on StateMachine startup
  • Loading branch information
nsk90 committed Dec 13, 2024
1 parent b5ab6e9 commit 22ba234
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 30 deletions.
2 changes: 1 addition & 1 deletion docs/pages/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IState>,
) : ResolutionHint {
constructor(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -36,7 +37,14 @@ interface CompositeMetaInfo : MetaInfo {
inline fun <reified M : MetaInfo> MetaInfo?.findMetaInfo(): M? {
return when (this) {
is M -> this
is CompositeMetaInfo -> metaInfoSet.singleOrNull { it is M } as? M
is CompositeMetaInfo -> {
val infos = metaInfoSet.filterIsInstance<M>()
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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,6 +159,7 @@ internal class StateMachineImpl(

private fun checkBeforeRunMachine() {
accept(CheckUniqueNamesVisitor())
accept(CheckMetaInfoStructureVisitor())
if (creationArguments.requireNonBlankNames)
checkNonBlankNames()
checkNotDestroyed()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <E : Event> visit(transition: Transition<E>) {
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" }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,45 +218,32 @@ internal class ExportPlantUmlVisitor(
val eventAndArgument = it.eventAndArgument as EventAndArgument<E>
TargetStateInfo(
it.description,
block(makeDirectionProducerPolicy<E>(metaInfo, eventAndArgument))
block(makeUnsafeCollectTargetStatesPolicy<E>(eventAndArgument))
)
}
hintMap[StateResolutionHint::class]?.mapTo(stateInfoList) {
it as StateResolutionHint
TargetStateInfo(it.description, TargetState(it.targetStates))
}
} else {
stateInfoList += TargetStateInfo(
null,
block(makeDirectionProducerPolicy<E>(metaInfo))
)
stateInfoList += TargetStateInfo(null, block(makeUnsafeCollectTargetStatesPolicy<E>()))
}
} else {
stateInfoList += TargetStateInfo(
null,
block(makeDirectionProducerPolicy<E>(metaInfo))
)
stateInfoList += TargetStateInfo(null, block(makeUnsafeCollectTargetStatesPolicy<E>()))
}
} else {
stateInfoList += TargetStateInfo(null, block(makeDirectionProducerPolicy<E>(metaInfo)))
stateInfoList += TargetStateInfo(null, block(CollectTargetStatesPolicy()))
metaInfo.findMetaInfo<ExportMetaInfo>()
?.resolutionHints
?.filterIsInstance<StateResolutionHint>()
?.mapTo(stateInfoList) { TargetStateInfo(it.description, TargetState(it.targetStates)) }
}
return stateInfoList
}

private val MetaInfo?.hasUnsafeCallConditionalLambdas: Boolean
get() = unsafeCallConditionalLambdas && findMetaInfo<IgnoreUnsafeCallConditionalLambdasMetaInfo>() == null

private fun <E : Event> makeDirectionProducerPolicy(
metaInfo: MetaInfo?,
@Suppress("UNCHECKED_CAST") // this is unsafe by design
eventAndArgument: EventAndArgument<E> = EventAndArgument(ExportPlantUmlEvent as E, null)
): TransitionDirectionProducerPolicy<E> {
return if (metaInfo.hasUnsafeCallConditionalLambdas)
UnsafeCollectTargetStatesPolicy(eventAndArgument)
else
CollectTargetStatesPolicy()
}

private suspend fun processStateBody(state: IState) {
state.printStateDescriptions()
state.printStateNotes()
Expand Down Expand Up @@ -335,6 +322,13 @@ internal class ExportPlantUmlVisitor(
}
}

private fun <E : Event> makeUnsafeCollectTargetStatesPolicy(
@Suppress("UNCHECKED_CAST") // this is unsafe by design
eventAndArgument: EventAndArgument<E> = EventAndArgument(ExportPlantUmlEvent as E, null)
): TransitionDirectionProducerPolicy<E> {
return UnsafeCollectTargetStatesPolicy(eventAndArgument)
}

/**
* Export [StateMachine] to PlantUML state diagram
* @see <a href="https://plantuml.com/">PlantUML</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<choice>>
[*] --> 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
Expand Down Expand Up @@ -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
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -163,5 +164,31 @@ class UmlMetaInfoTest : StringSpec({
}
machine.exportToPlantUml() shouldBe PLANTUML_COMPOSITE_META_INFO
}

"Negative CompositeMetaInfo nesting" {
shouldThrowWithMessage<IllegalStateException>("CompositeMetaInfo cannot nest each other") {
createTestStateMachine(coroutineStarterType) {
metaInfo = buildCompositeMetaInfo(
buildCompositeMetaInfo {},
buildUmlMetaInfo {}
)
initialState("State1")
}
}
}

"Negative MetaInfo cannot be repeated more than once" {
shouldThrowWithMessage<IllegalStateException>("MetaInfo ${buildUmlMetaInfo {}::class} is repeated more than once") {
createTestStateMachine(coroutineStarterType) {
transition<SwitchEvent> {
metaInfo = buildCompositeMetaInfo(
buildUmlMetaInfo { umlLabel = "text1" },
buildUmlMetaInfo { umlLabel = "text2" }
)
}
initialState("State1")
}
}
}
}
})

0 comments on commit 22ba234

Please sign in to comment.