KStateMachine / kstatemachine

KStateMachine is a Kotlin DSL library for creating state machines and statecharts.
https://kstatemachine.github.io/kstatemachine/
Boost Software License 1.0
340 stars 19 forks source link

Entry/Exit callback not called in Child state #25

Closed maros136 closed 2 years ago

maros136 commented 2 years ago

I find cases when Entry/Exit callback not called in Child state.

I try it in latest version 0.9.0 Below is unit test how to reproduce it

package ru.nsk.kstatemachine

import io.kotest.core.spec.style.StringSpec

class AdvancedCrossLevelTransitionTest : StringSpec({

    "1. child to neighbors 1. child and then back 1. child" {
        val callbacks = mockkCallbacks()

        lateinit var state1: State
        lateinit var state11: State
        lateinit var state12: State
        lateinit var state2: State
        lateinit var state21: State
        lateinit var state22: State

        val machine = createStateMachine {
            state1 = initialState("1") {
                callbacks.listen(this)

                state11 = initialState("11") {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state12 }
                        callbacks.listen(this)
                    }
                }

                state12 = state("12") {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state11 }
                        callbacks.listen(this)
                    }
                }

                transitionOn<SwitchEventL1> {
                    targetState = { state2 }
                    callbacks.listen(this)
                }
            }
            state2 = state("2") {
                callbacks.listen(this)

                state21 = initialState("21") {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state22 }
                        callbacks.listen(this)
                    }
                }

                state22 = state("22") {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state21 }
                        callbacks.listen(this)
                    }
                }

                transitionOn<SwitchEventL1> {
                    targetState = { state1 }
                    callbacks.listen(this)
                }
            }
        }

        //* -> 1 (11)   - ok
        verifySequenceAndClear(callbacks) {
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 1 (12)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 2 (21)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state12)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)
        }

        //2 (21) -> 2 (22)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state21)
            callbacks.onEntryState(state22)
        }

        //2 (22) -> 1 (11)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state22)
            callbacks.onExitState(state2)
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 1 (12)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 1 (11)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state12)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 2 (21)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state11)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)
        }

        //2 (21) -> 1 (11)  - failed (missing child entry callback)
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state21)
            callbacks.onExitState(state2)
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)//Missing entry state!
        }

        //1 (11) -> 1 (12)  - failed (missing child exit callback)
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)//Missing exit state!
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 1 (11)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state12)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 2 (21)  - failed (missing child entry callback)
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state11)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)//Missing entry state!
        }
    }
})

Solution how to fix it (with very ugly fix in State)

package ru.nsk.kstatemachine

import io.kotest.core.spec.style.StringSpec
import timber.log.Timber
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible

open class FixedState(
        name: String? = null,
        childMode: ChildMode = ChildMode.EXCLUSIVE
) : DefaultState(name, childMode) {

    override fun onDoExit(transitionParams: TransitionParams<*>) {
        super.onDoExit(transitionParams)
        //Need clear CurrentState after exit
        // - not working - reEnter parent state after leaving it with child state (as initial state)
        try {
            DefaultStateWithDetail.propertyCurrentState?.set(this, null)
        } catch (e : Exception) {
            Timber.e(e, "Cannot set value for currentState property!")
        }
    }

    companion object {
        internal var propertyCurrentState : KMutableProperty1<InternalState, Any?>? = null

        init {
            try {
                propertyCurrentState = BaseStateImpl::class.memberProperties
                        .find { it.name == "currentState" }
                        ?.apply { isAccessible = true } as KMutableProperty1<InternalState, Any?>
            } catch (e : Exception){
                Timber.e(e, "Cannot get currentState property!")
            }
        }
    }
}

class FixedAdvancedCrossLevelTransitionTest : StringSpec({
    "1. child to neighbors 1. child and then back 1. child" {
        val callbacks = mockkCallbacks()

        val state1 = FixedState("1")
        val state11 = FixedState("11")
        val state12 = FixedState("12")
        val state2 = FixedState("2")
        val state21 = FixedState("21")
        val state22 = FixedState("22")

        val machine = createStateMachine {
            addInitialState(state1) {
                callbacks.listen(this)

                addInitialState(state11) {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state12 }
                        callbacks.listen(this)
                    }
                }

                addState(state12) {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state11 }
                        callbacks.listen(this)
                    }
                }

                transitionOn<SwitchEventL1> {
                    targetState = { state2 }
                    callbacks.listen(this)
                }
            }
            addState(state2) {
                callbacks.listen(this)

                addInitialState(state21) {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state22 }
                        callbacks.listen(this)
                    }
                }

                addState(state22) {
                    callbacks.listen(this)

                    transitionOn<SwitchEvent> {
                        targetState = { state21 }
                        callbacks.listen(this)
                    }
                }

                transitionOn<SwitchEventL1> {
                    targetState = { state1 }
                    callbacks.listen(this)
                }
            }
        }

        //* -> 1 (11)   - ok
        verifySequenceAndClear(callbacks) {
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 1 (12)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 2 (21)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state12)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)
        }

        //2 (21) -> 2 (22)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state21)
            callbacks.onEntryState(state22)
        }

        //2 (22) -> 1 (11)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state22)
            callbacks.onExitState(state2)
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 1 (12)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 1 (11)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state12)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 2 (21)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state11)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)
        }

        //2 (21) -> 1 (11)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state21)
            callbacks.onExitState(state2)
            callbacks.onEntryState(state1)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 1 (12)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state11)
            callbacks.onEntryState(state12)
        }

        //1 (12) -> 1 (11)  - ok
        machine.processEvent(SwitchEvent)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEvent)
            callbacks.onExitState(state12)
            callbacks.onEntryState(state11)
        }

        //1 (11) -> 2 (21)  - ok
        machine.processEvent(SwitchEventL1)
        verifySequenceAndClear(callbacks) {
            callbacks.onTriggeredTransition(SwitchEventL1)
            callbacks.onExitState(state11)
            callbacks.onExitState(state1)
            callbacks.onEntryState(state2)
            callbacks.onEntryState(state21)
        }
    }
})
nsk90 commented 2 years ago

Thank you, I will check it out after 16'th February.

nsk90 commented 2 years ago

I confirm a bug, and start working on a fix. Thank you for a good test sample.

nsk90 commented 2 years ago

Fixed by clearing currentState field on exiting state.

8f658cfac39e79e9ad5b3259d69f711cf5ec43f8