Skip to content

Commit a86b57e

Browse files
committed
feat: add service.do() boolean return validation
State machine service now returns meaningful boolean feedback when executing actions. Returns true for successful transitions, false when predicates fail or no valid transition exists. This enables deterministic error handling - developers can now programmatically verify whether state changes succeeded instead of guessing from side effects.
1 parent 58aeea2 commit a86b57e

File tree

7 files changed

+1047
-126
lines changed

7 files changed

+1047
-126
lines changed

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,41 @@ Creates an executable state machine instance.
118118

119119
#### Methods
120120

121-
- `.do(action, payload?)` - Dispatch an action
121+
- `.do(action, payload?)` - Dispatch an action, returns boolean indicating success
122122
- `.subscribe(callback)` - Subscribe to state changes
123+
124+
### Action Dispatch Return Values
125+
126+
The `.do()` method returns a boolean that indicates whether the action successfully triggered a state transition:
127+
128+
**Returns `true` when:**
129+
130+
- A valid transition exists for the current state + action combination
131+
- All transition predicates (if any) evaluate to `true`
132+
- The state transition executes successfully
133+
134+
**Returns `false` when:**
135+
136+
- No transition is defined for the current state + action combination
137+
- All transition predicates fail (return `false`)
138+
139+
This return value enables precise control flow based on whether state changes actually occurred.
140+
141+
```typescript
142+
const machine = stateMachine()
143+
.state('idle')
144+
.state('working')
145+
.initial('idle')
146+
.action('start')
147+
.action('stop')
148+
.transition('idle', 'start', 'working')
149+
// Note: no 'stop' transition from 'idle'
150+
151+
const service = interpret(machine)
152+
153+
const started = service.do('start') // true - transition succeeds
154+
console.log(service.state) // 'working'
155+
156+
const stopped = service.do('stop') // false - no transition defined
157+
console.log(service.state) // still 'working'
158+
```

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@commitlint/cli": "19.8.1",
1212
"@commitlint/config-conventional": "19.8.1",
1313
"@escapace/pnpm-pack": "0.6.0",
14+
"@escapace/sequentialize": "4.0.1",
1415
"@ls-lint/ls-lint": "2.3.1",
1516
"@types/lodash-es": "4.17.12",
1617
"@vitest/coverage-v8": "3.2.4",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,119 @@ describe('./src/index.spec.ts', () => {
481481
assert.equal(spyObservable.mock.calls.length, 7)
482482
})
483483

484+
it('service.do() boolean return values', () => {
485+
// Create a focused state machine to test service.do() return values
486+
// Based on interpret.ts logic:
487+
// - Returns false: No transitions for state+action OR all predicates fail
488+
// - Returns true: Valid transition found and executed
489+
490+
enum TestState {
491+
StateA = 'STATE_A',
492+
StateB = 'STATE_B',
493+
}
494+
495+
enum TestAction {
496+
Always = 'ALWAYS',
497+
Conditional = 'CONDITIONAL',
498+
Never = 'NEVER',
499+
Undefined = 'UNDEFINED', // Won't be defined in machine
500+
}
501+
502+
interface TestContext {
503+
counter: number
504+
}
505+
506+
const testMachine = stateMachine()
507+
.state(TestState.StateA)
508+
.state(TestState.StateB)
509+
.initial(TestState.StateA)
510+
.action<TestAction.Always>(TestAction.Always)
511+
.action<TestAction.Never>(TestAction.Never)
512+
.action<TestAction.Conditional, { shouldPass: boolean }>(TestAction.Conditional)
513+
.context<TestContext>({ counter: 0 })
514+
// Transition that always succeeds (no predicate)
515+
.transition(TestState.StateA, TestAction.Always, TestState.StateB)
516+
// Transition with predicate that always fails
517+
.transition(
518+
TestState.StateA,
519+
[TestAction.Never, () => false], // Predicate always returns false
520+
TestState.StateB,
521+
)
522+
// Transition with conditional predicate
523+
.transition(
524+
TestState.StateA,
525+
[TestAction.Conditional, (_, action) => action.payload.shouldPass],
526+
TestState.StateB,
527+
)
528+
// Note: No transition defined for TestAction.Undefined
529+
530+
const testService = interpret(testMachine)
531+
532+
// Test case 1: Valid transition with no predicate - should return true
533+
assert.equal(testService.state, TestState.StateA)
534+
const alwaysResult = testService.do(TestAction.Always)
535+
assert.equal(alwaysResult, true, 'service.do() should return true for valid transition')
536+
assert.equal(
537+
testService.state,
538+
TestState.StateB,
539+
'State should change after successful transition',
540+
)
541+
542+
// Reset to StateA for remaining tests
543+
const resetMachine = interpret(testMachine)
544+
545+
// Test case 2: Transition with failing predicate - should return false
546+
const neverResult = resetMachine.do(TestAction.Never)
547+
assert.equal(neverResult, false, 'service.do() should return false when predicate fails')
548+
assert.equal(
549+
resetMachine.state,
550+
TestState.StateA,
551+
'State should not change when transition fails',
552+
)
553+
554+
// Test case 3: Transition with passing predicate - should return true
555+
const conditionalTrueResult = resetMachine.do(TestAction.Conditional, { shouldPass: true })
556+
assert.equal(
557+
conditionalTrueResult,
558+
true,
559+
'service.do() should return true when predicate passes',
560+
)
561+
assert.equal(resetMachine.state, TestState.StateB, 'State should change when predicate passes')
562+
563+
// Reset again for final test
564+
const resetMachine2 = interpret(testMachine)
565+
566+
// Test case 4: Transition with failing predicate - should return false
567+
const conditionalFalseResult = resetMachine2.do(TestAction.Conditional, { shouldPass: false })
568+
assert.equal(
569+
conditionalFalseResult,
570+
false,
571+
'service.do() should return false when predicate fails',
572+
)
573+
assert.equal(
574+
resetMachine2.state,
575+
TestState.StateA,
576+
'State should not change when predicate fails',
577+
)
578+
579+
// Test case 5: Action with no defined transitions - should return false
580+
// From StateB, try an action that only has transitions from StateA
581+
testService.do(TestAction.Always) // Move to StateB if not already there
582+
assert.equal(testService.state, TestState.StateB)
583+
584+
const noTransitionResult = testService.do(TestAction.Never)
585+
assert.equal(
586+
noTransitionResult,
587+
false,
588+
'service.do() should return false when no transitions exist for state+action',
589+
)
590+
assert.equal(
591+
testService.state,
592+
TestState.StateB,
593+
'State should not change when no transition exists',
594+
)
595+
})
596+
484597
it('fff', () => {
485598
assert.throws(() =>
486599
// @ts-expect-error

src/interpret.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const interpret = <T extends InteropStateMachine>(
6767

6868
if (transitions === undefined || transitions.length === 0) {
6969
// TODO: Strict mode? Silent mode?
70-
return
70+
return false
7171
}
7272

7373
const _action: Partial<Action> = {
@@ -110,7 +110,7 @@ export const interpret = <T extends InteropStateMachine>(
110110

111111
if (transition === undefined) {
112112
// TODO: Strict mode? Silent mode?
113-
return
113+
return false
114114
}
115115

116116
state = transition.target
@@ -129,7 +129,7 @@ export const interpret = <T extends InteropStateMachine>(
129129
// subscription({ action: _action, context, state } as Change)
130130
// )
131131

132-
return
132+
return true
133133
},
134134
get state() {
135135
return state

0 commit comments

Comments
 (0)