Skip to content

Commit

Permalink
Impement target-less transitions for DataStates
Browse files Browse the repository at this point in the history
  • Loading branch information
nsk90 committed Nov 19, 2023
1 parent 7d1bc77 commit 627c6dc
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 42 deletions.
11 changes: 8 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ greenState {
```

> [!NOTE]
> Such transitions are also called internal.
> Such transitions are also called internal or self-targeted.
### Transition type

Expand Down Expand Up @@ -641,8 +641,13 @@ createStateMachine(scope) {
`DataState`'s `data` field is set and might be accessed only while the state is active. At the moment when `DataState`
is activated it requires data value from a `DataEvent`. You can use `lastData` field to access last data value even
after state exit, it falls back
to `defaultData` if provided or throws.
after state exit, it falls back to `defaultData` if provided or throws.

### Target-less data transitions

You can define target-less transitions for `DataState`. Please, note that if you want such transition to change state's
`data` field, it should be `EXTERNAL` type. If target-less transition is `LOCAL` it does not change states data.
This is related to the way how `DataState` is implemented, `data` field is changed only on state entry moment.
### Corner cases of `DataState` activation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,6 @@ open class DefaultTransition<E : Event>(
override val type: TransitionType,
sourceState: IState,
) : InternalTransition<E> {
private val _listeners = mutableSetOf<Transition.Listener>()
override val listeners: Collection<Transition.Listener> get() = _listeners

override val sourceState = sourceState as InternalState

/**
* Function that is called during event processing,
* not during state machine configuration. So it is possible to check some outer (business logic) values in it.
* If [Transition] does not have target state then [StateMachine] keeps current state
* when such [Transition] is triggered.
* This function should not have side effects.
*/
private var targetStateDirectionProducer: TransitionDirectionProducer<E> = { stay() }

override var argument: Any? = null

constructor(
name: String?,
eventMatcher: EventMatcher<E>,
Expand All @@ -42,6 +26,22 @@ open class DefaultTransition<E : Event>(
this.targetStateDirectionProducer = targetStateDirectionProducer
}

private val _listeners = mutableSetOf<Transition.Listener>()
override val listeners: Collection<Transition.Listener> get() = _listeners

override val sourceState = sourceState as InternalState

/**
* Function that is called during event processing,
* not during state machine configuration. So it is possible to check some outer (business logic) values in it.
* If [Transition] does not have target state then [StateMachine] keeps current state
* when such [Transition] is triggered.
* This function should not have side effects.
*/
private var targetStateDirectionProducer: TransitionDirectionProducer<E> = { stay() }

override var argument: Any? = null

override fun <L : Transition.Listener> addListener(listener: L): L {
require(_listeners.add(listener)) { "$listener is already added" }
return listener
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ interface State : IState
/**
* State which holds data while it is active
*/
interface DataState<D : Any> : IState {
interface DataState<D : Any> : IState, DataTransitionStateApi<D> {
val defaultData: D?

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface Transition<E : Event> : VisitorAcceptor {
}

/**
* Most of the cases external and local transition are functionally equivalent except in cases where transition
* Most of the cases [EXTERNAL] and [LOCAL] transition are functionally equivalent except in cases where transition
* is happening between super and sub states. Local transition doesn't cause exit and entry to source state if
* target state is a sub-state of a source state.
* Other way around, local transition doesn't cause exit and entry to target state if target is a superstate of a source state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,29 @@ class UnitGuardedTransitionOnBuilder<E : Event>(name: String?, sourceState: ISta
* Type safe argument transition builder
*/
class DataGuardedTransitionBuilder<E : DataEvent<D>, D : Any>(name: String?, sourceState: IState) :
GuardedTransitionBuilder<E, DataState<D>>(name, sourceState)
BaseGuardedTransitionBuilder<E>(name, sourceState) {
/** User should initialize this filed */
lateinit var targetState: DataState<D>

override fun build(): Transition<E> {
require(this::targetState.isInitialized) { "targetState should be set in this transition builder" }
val direction: TransitionDirectionProducer<E> = {
when (it) {
is DefaultPolicy<E> ->
if (it.eventAndArgument.guard())
it.targetState(targetState)
else
noTransition()

is CollectTargetStatesPolicy<E> -> it.targetState(targetState)
}
}

val transition = DefaultTransition(name, eventMatcher, type, sourceState, direction)
listeners.forEach { transition.addListener(it) }
return transition
}
}

/**
* Type safe argument transitionOn builder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface TransitionStateApi {
fun asState(): IState
}

/**
* Same as [TransitionStateApi] interface, for specialized [DataState] api.
*/
interface DataTransitionStateApi<D : Any> : TransitionStateApi

/**
* Find transition by name. This might be used to start listening to transition after state machine setup.
*/
Expand Down Expand Up @@ -101,19 +106,27 @@ inline fun <reified E : Event> TransitionStateApi.transitionConditionally(

/**
* Shortcut function for type safe argument transition.
* Data transition can not be target-less as it does not make sense.
* Data transition can be target-less (self-targeted), it is useful to update [DataState] data
* Note that transition must be [TransitionType.EXTERNAL] to update data.
*/
inline fun <reified E : DataEvent<D>, D : Any> TransitionStateApi.dataTransition(
name: String? = null,
targetState: DataState<D>,
type: TransitionType = LOCAL,
): Transition<E> {
require(targetState != asState()) {
"data transition should no be self targeted, use simple transition instead"
}
return addTransition(DefaultTransition(name, matcherForEvent(asState()), type, asState(), targetState))
}

/**
* Shortcut function for type safe target-less (self targeted) transition.
*/
inline fun <reified E : DataEvent<D>, D : Any> DataTransitionStateApi<D>.dataTransition(
name: String? = null,
type: TransitionType = LOCAL,
): Transition<E> {
return addTransition(DefaultTransition(name, matcherForEvent(asState()), type, asState(), null))
}

/**
* Creates type safe argument transition to [DataState].
*/
Expand All @@ -125,12 +138,6 @@ inline fun <reified E : DataEvent<D>, D : Any> TransitionStateApi.dataTransition
eventMatcher = matcherForEvent(asState())
block()
}
requireNotNull(builder.targetState) {
"data transition should no be target-less, specify targetState or use simple transition instead"
}
require(builder.targetState != asState()) {
"data transition should no be self targeted, use simple transition instead"
}
return addTransition(builder.build())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ class TypesafeTransitionTest : StringSpec({
state2.data shouldBe id
}

"target-less data transition negative" {
"target-less data transition inside nonDataState negative" {
shouldThrow<IllegalArgumentException> {
createTestStateMachine(coroutineStarterType) {
initialState("state1") {
Expand All @@ -211,7 +211,34 @@ class TypesafeTransitionTest : StringSpec({
}
}

"target-less transition in data state" {
"create self targeted data transition in DataState" {
createTestStateMachine(coroutineStarterType) {
initialDataState<Int>("state1", 42) {
dataTransition<IdEvent, Int>(targetState = this)
}
}
}

"create self targeted data transition in DataState via builder" {
createTestStateMachine(coroutineStarterType) {
initialDataState<Int>("state1", 42) {
dataTransition<IdEvent, Int> {
targetState = this@initialDataState
}
}
}
}

"create target-less data transition in DataState" {
createTestStateMachine(coroutineStarterType) {
initialDataState<Int>("state1", 42) {
// this method is only available for DataState
dataTransition<IdEvent, Int>()
}
}
}

"simple target-less transition in data state" {
val callbacks = mockkCallbacks()

val machine = createTestStateMachine(coroutineStarterType) {
Expand All @@ -232,19 +259,33 @@ class TypesafeTransitionTest : StringSpec({
verify { callbacks.onTransitionTriggered(SwitchEvent) }
}

"self targeted transition in data state" {
shouldThrow<IllegalArgumentException> {
createTestStateMachine(coroutineStarterType) {
initialState("state1")
"self targeted LOCAL transition in data state, does not update data value" {
lateinit var dataState: DataState<Int>
val machine = createTestStateMachine(coroutineStarterType) {
dataState = initialDataState("state1", defaultData = 1) {
dataTransition<IdEvent, Int>(targetState = this)
}
}

dataState("state2") {
dataTransition<IdEvent, Int>(targetState = this)
}
dataState.data shouldBe 1
machine.processEvent(IdEvent(2))
dataState.data shouldBe 1
}

"self targeted EXTERNAL transition in data state updates data value" {
lateinit var dataState: DataState<Int>
val machine = createTestStateMachine(coroutineStarterType) {
dataState = initialDataState("state1", defaultData = 1) {
dataTransition<IdEvent, Int>(targetState = this, type = TransitionType.EXTERNAL)
}
}

dataState.data shouldBe 1
machine.processEvent(IdEvent(2))
dataState.data shouldBe 2
}

"self targeted transitionOn() does not update data, cannot throw on construction" {
"self targeted LOCAL transitionOn() does not update data" {
lateinit var dataState: DataState<Int>

val machine = createTestStateMachine(coroutineStarterType) {
Expand Down

0 comments on commit 627c6dc

Please sign in to comment.