Skip to content

Commit 627c6dc

Browse files
committed
Impement target-less transitions for DataStates
1 parent 7d1bc77 commit 627c6dc

File tree

7 files changed

+117
-42
lines changed

7 files changed

+117
-42
lines changed

docs/index.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ greenState {
257257
```
258258

259259
> [!NOTE]
260-
> Such transitions are also called internal.
260+
> Such transitions are also called internal or self-targeted.
261261
262262
### Transition type
263263

@@ -641,8 +641,13 @@ createStateMachine(scope) {
641641
642642
`DataState`'s `data` field is set and might be accessed only while the state is active. At the moment when `DataState`
643643
is activated it requires data value from a `DataEvent`. You can use `lastData` field to access last data value even
644-
after state exit, it falls back
645-
to `defaultData` if provided or throws.
644+
after state exit, it falls back to `defaultData` if provided or throws.
645+
646+
### Target-less data transitions
647+
648+
You can define target-less transitions for `DataState`. Please, note that if you want such transition to change state's
649+
`data` field, it should be `EXTERNAL` type. If target-less transition is `LOCAL` it does not change states data.
650+
This is related to the way how `DataState` is implemented, `data` field is changed only on state entry moment.
646651
647652
### Corner cases of `DataState` activation
648653

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/DefaultTransition.kt

+16-16
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,6 @@ open class DefaultTransition<E : Event>(
66
override val type: TransitionType,
77
sourceState: IState,
88
) : InternalTransition<E> {
9-
private val _listeners = mutableSetOf<Transition.Listener>()
10-
override val listeners: Collection<Transition.Listener> get() = _listeners
11-
12-
override val sourceState = sourceState as InternalState
13-
14-
/**
15-
* Function that is called during event processing,
16-
* not during state machine configuration. So it is possible to check some outer (business logic) values in it.
17-
* If [Transition] does not have target state then [StateMachine] keeps current state
18-
* when such [Transition] is triggered.
19-
* This function should not have side effects.
20-
*/
21-
private var targetStateDirectionProducer: TransitionDirectionProducer<E> = { stay() }
22-
23-
override var argument: Any? = null
24-
259
constructor(
2610
name: String?,
2711
eventMatcher: EventMatcher<E>,
@@ -42,6 +26,22 @@ open class DefaultTransition<E : Event>(
4226
this.targetStateDirectionProducer = targetStateDirectionProducer
4327
}
4428

29+
private val _listeners = mutableSetOf<Transition.Listener>()
30+
override val listeners: Collection<Transition.Listener> get() = _listeners
31+
32+
override val sourceState = sourceState as InternalState
33+
34+
/**
35+
* Function that is called during event processing,
36+
* not during state machine configuration. So it is possible to check some outer (business logic) values in it.
37+
* If [Transition] does not have target state then [StateMachine] keeps current state
38+
* when such [Transition] is triggered.
39+
* This function should not have side effects.
40+
*/
41+
private var targetStateDirectionProducer: TransitionDirectionProducer<E> = { stay() }
42+
43+
override var argument: Any? = null
44+
4545
override fun <L : Transition.Listener> addListener(listener: L): L {
4646
require(_listeners.add(listener)) { "$listener is already added" }
4747
return listener

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/IState.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ interface State : IState
7979
/**
8080
* State which holds data while it is active
8181
*/
82-
interface DataState<D : Any> : IState {
82+
interface DataState<D : Any> : IState, DataTransitionStateApi<D> {
8383
val defaultData: D?
8484

8585
/**

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/Transition.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ interface Transition<E : Event> : VisitorAcceptor {
4949
}
5050

5151
/**
52-
* Most of the cases external and local transition are functionally equivalent except in cases where transition
52+
* Most of the cases [EXTERNAL] and [LOCAL] transition are functionally equivalent except in cases where transition
5353
* is happening between super and sub states. Local transition doesn't cause exit and entry to source state if
5454
* target state is a sub-state of a source state.
5555
* Other way around, local transition doesn't cause exit and entry to target state if target is a superstate of a source state.

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/TransitionBuilder.kt

+23-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,29 @@ class UnitGuardedTransitionOnBuilder<E : Event>(name: String?, sourceState: ISta
9494
* Type safe argument transition builder
9595
*/
9696
class DataGuardedTransitionBuilder<E : DataEvent<D>, D : Any>(name: String?, sourceState: IState) :
97-
GuardedTransitionBuilder<E, DataState<D>>(name, sourceState)
97+
BaseGuardedTransitionBuilder<E>(name, sourceState) {
98+
/** User should initialize this filed */
99+
lateinit var targetState: DataState<D>
100+
101+
override fun build(): Transition<E> {
102+
require(this::targetState.isInitialized) { "targetState should be set in this transition builder" }
103+
val direction: TransitionDirectionProducer<E> = {
104+
when (it) {
105+
is DefaultPolicy<E> ->
106+
if (it.eventAndArgument.guard())
107+
it.targetState(targetState)
108+
else
109+
noTransition()
110+
111+
is CollectTargetStatesPolicy<E> -> it.targetState(targetState)
112+
}
113+
}
114+
115+
val transition = DefaultTransition(name, eventMatcher, type, sourceState, direction)
116+
listeners.forEach { transition.addListener(it) }
117+
return transition
118+
}
119+
}
98120

99121
/**
100122
* Type safe argument transitionOn builder

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/TransitionStateApi.kt

+17-10
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ interface TransitionStateApi {
1717
fun asState(): IState
1818
}
1919

20+
/**
21+
* Same as [TransitionStateApi] interface, for specialized [DataState] api.
22+
*/
23+
interface DataTransitionStateApi<D : Any> : TransitionStateApi
24+
2025
/**
2126
* Find transition by name. This might be used to start listening to transition after state machine setup.
2227
*/
@@ -101,19 +106,27 @@ inline fun <reified E : Event> TransitionStateApi.transitionConditionally(
101106

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

120+
/**
121+
* Shortcut function for type safe target-less (self targeted) transition.
122+
*/
123+
inline fun <reified E : DataEvent<D>, D : Any> DataTransitionStateApi<D>.dataTransition(
124+
name: String? = null,
125+
type: TransitionType = LOCAL,
126+
): Transition<E> {
127+
return addTransition(DefaultTransition(name, matcherForEvent(asState()), type, asState(), null))
128+
}
129+
117130
/**
118131
* Creates type safe argument transition to [DataState].
119132
*/
@@ -125,12 +138,6 @@ inline fun <reified E : DataEvent<D>, D : Any> TransitionStateApi.dataTransition
125138
eventMatcher = matcherForEvent(asState())
126139
block()
127140
}
128-
requireNotNull(builder.targetState) {
129-
"data transition should no be target-less, specify targetState or use simple transition instead"
130-
}
131-
require(builder.targetState != asState()) {
132-
"data transition should no be self targeted, use simple transition instead"
133-
}
134141
return addTransition(builder.build())
135142
}
136143

tests/src/commonTest/kotlin/ru/nsk/kstatemachine/TypesafeTransitionTest.kt

+51-10
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ class TypesafeTransitionTest : StringSpec({
201201
state2.data shouldBe id
202202
}
203203

204-
"target-less data transition negative" {
204+
"target-less data transition inside nonDataState negative" {
205205
shouldThrow<IllegalArgumentException> {
206206
createTestStateMachine(coroutineStarterType) {
207207
initialState("state1") {
@@ -211,7 +211,34 @@ class TypesafeTransitionTest : StringSpec({
211211
}
212212
}
213213

214-
"target-less transition in data state" {
214+
"create self targeted data transition in DataState" {
215+
createTestStateMachine(coroutineStarterType) {
216+
initialDataState<Int>("state1", 42) {
217+
dataTransition<IdEvent, Int>(targetState = this)
218+
}
219+
}
220+
}
221+
222+
"create self targeted data transition in DataState via builder" {
223+
createTestStateMachine(coroutineStarterType) {
224+
initialDataState<Int>("state1", 42) {
225+
dataTransition<IdEvent, Int> {
226+
targetState = this@initialDataState
227+
}
228+
}
229+
}
230+
}
231+
232+
"create target-less data transition in DataState" {
233+
createTestStateMachine(coroutineStarterType) {
234+
initialDataState<Int>("state1", 42) {
235+
// this method is only available for DataState
236+
dataTransition<IdEvent, Int>()
237+
}
238+
}
239+
}
240+
241+
"simple target-less transition in data state" {
215242
val callbacks = mockkCallbacks()
216243

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

235-
"self targeted transition in data state" {
236-
shouldThrow<IllegalArgumentException> {
237-
createTestStateMachine(coroutineStarterType) {
238-
initialState("state1")
262+
"self targeted LOCAL transition in data state, does not update data value" {
263+
lateinit var dataState: DataState<Int>
264+
val machine = createTestStateMachine(coroutineStarterType) {
265+
dataState = initialDataState("state1", defaultData = 1) {
266+
dataTransition<IdEvent, Int>(targetState = this)
267+
}
268+
}
239269

240-
dataState("state2") {
241-
dataTransition<IdEvent, Int>(targetState = this)
242-
}
270+
dataState.data shouldBe 1
271+
machine.processEvent(IdEvent(2))
272+
dataState.data shouldBe 1
273+
}
274+
275+
"self targeted EXTERNAL transition in data state updates data value" {
276+
lateinit var dataState: DataState<Int>
277+
val machine = createTestStateMachine(coroutineStarterType) {
278+
dataState = initialDataState("state1", defaultData = 1) {
279+
dataTransition<IdEvent, Int>(targetState = this, type = TransitionType.EXTERNAL)
243280
}
244281
}
282+
283+
dataState.data shouldBe 1
284+
machine.processEvent(IdEvent(2))
285+
dataState.data shouldBe 2
245286
}
246287

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

250291
val machine = createTestStateMachine(coroutineStarterType) {

0 commit comments

Comments
 (0)