@pyreon/machine 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +50 -0
- package/src/index.ts +41 -0
- package/src/machine.ts +166 -0
- package/src/tests/machine.test.ts +766 -0
- package/src/types.ts +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vit Bokisch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/machine",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Reactive state machines for Pyreon — constrained signals with type-safe transitions",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pyreon/fundamentals.git",
|
|
9
|
+
"directory": "packages/machine"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/machine#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pyreon/fundamentals/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"module": "./lib/index.js",
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": "./lib/index.js",
|
|
32
|
+
"types": "./lib/types/index.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vl_rolldown_build",
|
|
38
|
+
"dev": "vl_rolldown_build-watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
47
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0",
|
|
48
|
+
"@vitus-labs/tools-lint": "^1.11.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/machine — Reactive state machines for Pyreon.
|
|
3
|
+
*
|
|
4
|
+
* A machine is a constrained signal — it can only hold specific values
|
|
5
|
+
* and can only transition between them via specific events.
|
|
6
|
+
* Everything else (data, side effects, async) uses existing Pyreon primitives.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { createMachine } from '@pyreon/machine'
|
|
11
|
+
*
|
|
12
|
+
* const machine = createMachine({
|
|
13
|
+
* initial: 'idle',
|
|
14
|
+
* states: {
|
|
15
|
+
* idle: { on: { FETCH: 'loading' } },
|
|
16
|
+
* loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
17
|
+
* done: {},
|
|
18
|
+
* error: { on: { RETRY: 'loading' } },
|
|
19
|
+
* },
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* machine() // 'idle' — reads like a signal
|
|
23
|
+
* machine.send('FETCH') // transition
|
|
24
|
+
* {() => machine.matches('loading') && <Spinner />}
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export { createMachine } from './machine'
|
|
29
|
+
|
|
30
|
+
// Types
|
|
31
|
+
export type {
|
|
32
|
+
EnterCallback,
|
|
33
|
+
InferEvents,
|
|
34
|
+
InferStates,
|
|
35
|
+
Machine,
|
|
36
|
+
MachineConfig,
|
|
37
|
+
MachineEvent,
|
|
38
|
+
StateConfig,
|
|
39
|
+
TransitionCallback,
|
|
40
|
+
TransitionConfig,
|
|
41
|
+
} from './types'
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import type {
|
|
3
|
+
EnterCallback,
|
|
4
|
+
InferEvents,
|
|
5
|
+
InferStates,
|
|
6
|
+
Machine,
|
|
7
|
+
MachineConfig,
|
|
8
|
+
MachineEvent,
|
|
9
|
+
TransitionCallback,
|
|
10
|
+
TransitionConfig,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a reactive state machine — a constrained signal with type-safe transitions.
|
|
15
|
+
*
|
|
16
|
+
* The returned instance is callable (reads like a signal) and exposes
|
|
17
|
+
* `send()`, `matches()`, `can()`, and listeners for state changes.
|
|
18
|
+
*
|
|
19
|
+
* @param config - Machine definition with initial state and state configs
|
|
20
|
+
* @returns A reactive machine instance
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const machine = createMachine({
|
|
25
|
+
* initial: 'idle',
|
|
26
|
+
* states: {
|
|
27
|
+
* idle: { on: { FETCH: 'loading' } },
|
|
28
|
+
* loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
29
|
+
* done: {},
|
|
30
|
+
* error: { on: { RETRY: 'loading' } },
|
|
31
|
+
* },
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* machine() // 'idle'
|
|
35
|
+
* machine.send('FETCH')
|
|
36
|
+
* machine() // 'loading'
|
|
37
|
+
*
|
|
38
|
+
* // Reactive in JSX
|
|
39
|
+
* {() => machine.matches('loading') && <Spinner />}
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function createMachine<
|
|
43
|
+
const TConfig extends MachineConfig<string, string>,
|
|
44
|
+
>(config: TConfig): Machine<InferStates<TConfig>, InferEvents<TConfig>> {
|
|
45
|
+
type TState = InferStates<TConfig>
|
|
46
|
+
type TEvent = InferEvents<TConfig>
|
|
47
|
+
|
|
48
|
+
const { initial, states } = config as unknown as MachineConfig<TState, TEvent>
|
|
49
|
+
|
|
50
|
+
// Validate initial state
|
|
51
|
+
if (!(initial in states)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[@pyreon/machine] Initial state '${initial}' is not defined in states`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const current = signal<TState>(initial)
|
|
58
|
+
const enterListeners = new Map<TState, Set<EnterCallback<TEvent>>>()
|
|
59
|
+
const transitionListeners = new Set<TransitionCallback<TState, TEvent>>()
|
|
60
|
+
|
|
61
|
+
function resolveTransition(event: TEvent, payload?: unknown): TState | null {
|
|
62
|
+
const stateConfig = states[current.peek()]
|
|
63
|
+
if (!stateConfig?.on) return null
|
|
64
|
+
|
|
65
|
+
const transition = stateConfig.on[event] as
|
|
66
|
+
| TransitionConfig<TState>
|
|
67
|
+
| undefined
|
|
68
|
+
if (!transition) return null
|
|
69
|
+
|
|
70
|
+
if (typeof transition === 'string') {
|
|
71
|
+
return transition
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Guarded transition
|
|
75
|
+
if (transition.guard && !transition.guard(payload)) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return transition.target
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The machine instance — callable like a signal
|
|
83
|
+
function machine(): TState {
|
|
84
|
+
return current()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
machine.send = (event: TEvent, payload?: unknown): void => {
|
|
88
|
+
const target = resolveTransition(event, payload)
|
|
89
|
+
if (target === null) return
|
|
90
|
+
|
|
91
|
+
const from = current.peek()
|
|
92
|
+
const machineEvent: MachineEvent<TEvent> = { type: event, payload }
|
|
93
|
+
|
|
94
|
+
current.set(target)
|
|
95
|
+
|
|
96
|
+
// Fire transition listeners
|
|
97
|
+
for (const cb of transitionListeners) {
|
|
98
|
+
cb(from, target, machineEvent)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fire enter listeners for the target state
|
|
102
|
+
const listeners = enterListeners.get(target)
|
|
103
|
+
if (listeners) {
|
|
104
|
+
for (const cb of listeners) {
|
|
105
|
+
cb(machineEvent)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
machine.matches = (...matchStates: TState[]): boolean => {
|
|
111
|
+
const state = current()
|
|
112
|
+
return matchStates.includes(state)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
machine.can = (event: TEvent): boolean => {
|
|
116
|
+
const stateConfig = states[current()]
|
|
117
|
+
if (!stateConfig?.on) return false
|
|
118
|
+
|
|
119
|
+
const transition = stateConfig.on[event]
|
|
120
|
+
if (!transition) return false
|
|
121
|
+
|
|
122
|
+
// For guarded transitions, we can't know without payload
|
|
123
|
+
// Return true if the event exists (guard may still reject)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
machine.nextEvents = (): TEvent[] => {
|
|
128
|
+
const stateConfig = states[current()]
|
|
129
|
+
if (!stateConfig?.on) return []
|
|
130
|
+
return Object.keys(stateConfig.on) as TEvent[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
machine.reset = (): void => {
|
|
134
|
+
current.set(initial)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
machine.onEnter = (
|
|
138
|
+
state: TState,
|
|
139
|
+
callback: EnterCallback<TEvent>,
|
|
140
|
+
): (() => void) => {
|
|
141
|
+
if (!enterListeners.has(state)) {
|
|
142
|
+
enterListeners.set(state, new Set())
|
|
143
|
+
}
|
|
144
|
+
enterListeners.get(state)!.add(callback)
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
enterListeners.get(state)?.delete(callback)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
machine.onTransition = (
|
|
152
|
+
callback: TransitionCallback<TState, TEvent>,
|
|
153
|
+
): (() => void) => {
|
|
154
|
+
transitionListeners.add(callback)
|
|
155
|
+
return () => {
|
|
156
|
+
transitionListeners.delete(callback)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
machine.dispose = (): void => {
|
|
161
|
+
enterListeners.clear()
|
|
162
|
+
transitionListeners.clear()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return machine as Machine<TState, TEvent>
|
|
166
|
+
}
|
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { computed, effect, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createMachine } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('createMachine', () => {
|
|
6
|
+
// ─── Basic state and transitions ─────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe('basic transitions', () => {
|
|
9
|
+
it('starts in initial state', () => {
|
|
10
|
+
const m = createMachine({
|
|
11
|
+
initial: 'idle',
|
|
12
|
+
states: {
|
|
13
|
+
idle: { on: { START: 'running' } },
|
|
14
|
+
running: {},
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
expect(m()).toBe('idle')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('transitions on valid event', () => {
|
|
21
|
+
const m = createMachine({
|
|
22
|
+
initial: 'idle',
|
|
23
|
+
states: {
|
|
24
|
+
idle: { on: { START: 'running' } },
|
|
25
|
+
running: { on: { STOP: 'idle' } },
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
m.send('START')
|
|
29
|
+
expect(m()).toBe('running')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('ignores invalid events (no-op)', () => {
|
|
33
|
+
const m = createMachine({
|
|
34
|
+
initial: 'idle',
|
|
35
|
+
states: {
|
|
36
|
+
idle: { on: { START: 'running' } },
|
|
37
|
+
running: { on: { STOP: 'idle' } },
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
m.send('STOP' as any) // not valid in 'idle'
|
|
41
|
+
expect(m()).toBe('idle')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('supports self-transitions', () => {
|
|
45
|
+
const m = createMachine({
|
|
46
|
+
initial: 'counting',
|
|
47
|
+
states: {
|
|
48
|
+
counting: { on: { INCREMENT: 'counting' } },
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
m.send('INCREMENT')
|
|
52
|
+
expect(m()).toBe('counting')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('supports multiple transitions from one state', () => {
|
|
56
|
+
const m = createMachine({
|
|
57
|
+
initial: 'idle',
|
|
58
|
+
states: {
|
|
59
|
+
idle: { on: { FETCH: 'loading', CANCEL: 'cancelled' } },
|
|
60
|
+
loading: {},
|
|
61
|
+
cancelled: {},
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
m.send('CANCEL')
|
|
66
|
+
expect(m()).toBe('cancelled')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('handles states with no transitions (final states)', () => {
|
|
70
|
+
const m = createMachine({
|
|
71
|
+
initial: 'idle',
|
|
72
|
+
states: {
|
|
73
|
+
idle: { on: { DONE: 'finished' } },
|
|
74
|
+
finished: {},
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
m.send('DONE')
|
|
78
|
+
expect(m()).toBe('finished')
|
|
79
|
+
m.send('DONE') // ignored — no transitions from 'finished'
|
|
80
|
+
expect(m()).toBe('finished')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('throws on invalid initial state', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
createMachine({
|
|
86
|
+
initial: 'nonexistent' as any,
|
|
87
|
+
states: {
|
|
88
|
+
idle: {},
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
).toThrow('[@pyreon/machine] Initial state')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ─── Guards ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('guards', () => {
|
|
98
|
+
it('transitions when guard returns true', () => {
|
|
99
|
+
const m = createMachine({
|
|
100
|
+
initial: 'editing',
|
|
101
|
+
states: {
|
|
102
|
+
editing: {
|
|
103
|
+
on: {
|
|
104
|
+
SUBMIT: { target: 'submitting', guard: () => true },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
submitting: {},
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
m.send('SUBMIT')
|
|
111
|
+
expect(m()).toBe('submitting')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('blocks transition when guard returns false', () => {
|
|
115
|
+
const m = createMachine({
|
|
116
|
+
initial: 'editing',
|
|
117
|
+
states: {
|
|
118
|
+
editing: {
|
|
119
|
+
on: {
|
|
120
|
+
SUBMIT: { target: 'submitting', guard: () => false },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
submitting: {},
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
m.send('SUBMIT')
|
|
127
|
+
expect(m()).toBe('editing')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('guard receives event payload', () => {
|
|
131
|
+
const guardFn = vi.fn((payload?: unknown) => {
|
|
132
|
+
return (payload as any)?.valid === true
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const m = createMachine({
|
|
136
|
+
initial: 'editing',
|
|
137
|
+
states: {
|
|
138
|
+
editing: {
|
|
139
|
+
on: {
|
|
140
|
+
SUBMIT: { target: 'submitting', guard: guardFn },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
submitting: {},
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
m.send('SUBMIT', { valid: false })
|
|
148
|
+
expect(m()).toBe('editing')
|
|
149
|
+
expect(guardFn).toHaveBeenCalledWith({ valid: false })
|
|
150
|
+
|
|
151
|
+
m.send('SUBMIT', { valid: true })
|
|
152
|
+
expect(m()).toBe('submitting')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('guard with reactive signal', () => {
|
|
156
|
+
const isValid = signal(false)
|
|
157
|
+
|
|
158
|
+
const m = createMachine({
|
|
159
|
+
initial: 'editing',
|
|
160
|
+
states: {
|
|
161
|
+
editing: {
|
|
162
|
+
on: {
|
|
163
|
+
SUBMIT: {
|
|
164
|
+
target: 'submitting',
|
|
165
|
+
guard: () => isValid.peek(),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
submitting: {},
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
m.send('SUBMIT')
|
|
174
|
+
expect(m()).toBe('editing')
|
|
175
|
+
|
|
176
|
+
isValid.set(true)
|
|
177
|
+
m.send('SUBMIT')
|
|
178
|
+
expect(m()).toBe('submitting')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ─── matches ─────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('matches()', () => {
|
|
185
|
+
it('returns true for current state', () => {
|
|
186
|
+
const m = createMachine({
|
|
187
|
+
initial: 'idle',
|
|
188
|
+
states: {
|
|
189
|
+
idle: { on: { START: 'running' } },
|
|
190
|
+
running: {},
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
expect(m.matches('idle')).toBe(true)
|
|
194
|
+
expect(m.matches('running')).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('supports multiple states', () => {
|
|
198
|
+
const m = createMachine({
|
|
199
|
+
initial: 'loading',
|
|
200
|
+
states: {
|
|
201
|
+
idle: {},
|
|
202
|
+
loading: {},
|
|
203
|
+
error: {},
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
expect(m.matches('loading', 'error')).toBe(true)
|
|
207
|
+
expect(m.matches('idle', 'error')).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('is reactive in effects', () => {
|
|
211
|
+
const m = createMachine({
|
|
212
|
+
initial: 'idle',
|
|
213
|
+
states: {
|
|
214
|
+
idle: { on: { START: 'running' } },
|
|
215
|
+
running: { on: { STOP: 'idle' } },
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
const results: boolean[] = []
|
|
219
|
+
|
|
220
|
+
effect(() => {
|
|
221
|
+
results.push(m.matches('running'))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(results).toEqual([false])
|
|
225
|
+
|
|
226
|
+
m.send('START')
|
|
227
|
+
expect(results).toEqual([false, true])
|
|
228
|
+
|
|
229
|
+
m.send('STOP')
|
|
230
|
+
expect(results).toEqual([false, true, false])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ─── can ─────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('can()', () => {
|
|
237
|
+
it('returns true for valid events', () => {
|
|
238
|
+
const m = createMachine({
|
|
239
|
+
initial: 'idle',
|
|
240
|
+
states: {
|
|
241
|
+
idle: { on: { START: 'running' } },
|
|
242
|
+
running: { on: { STOP: 'idle' } },
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
expect(m.can('START')).toBe(true)
|
|
246
|
+
expect(m.can('STOP')).toBe(false)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('is reactive', () => {
|
|
250
|
+
const m = createMachine({
|
|
251
|
+
initial: 'idle',
|
|
252
|
+
states: {
|
|
253
|
+
idle: { on: { START: 'running' } },
|
|
254
|
+
running: { on: { STOP: 'idle' } },
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
const results: boolean[] = []
|
|
258
|
+
|
|
259
|
+
effect(() => {
|
|
260
|
+
results.push(m.can('STOP'))
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(results).toEqual([false])
|
|
264
|
+
|
|
265
|
+
m.send('START')
|
|
266
|
+
expect(results).toEqual([false, true])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('returns true for guarded transitions (guard not evaluated)', () => {
|
|
270
|
+
const m = createMachine({
|
|
271
|
+
initial: 'editing',
|
|
272
|
+
states: {
|
|
273
|
+
editing: {
|
|
274
|
+
on: {
|
|
275
|
+
SUBMIT: { target: 'submitting', guard: () => false },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
submitting: {},
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
// can() returns true because the event exists, even though guard would fail
|
|
282
|
+
expect(m.can('SUBMIT')).toBe(true)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// ─── nextEvents ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe('nextEvents()', () => {
|
|
289
|
+
it('returns available events from current state', () => {
|
|
290
|
+
const m = createMachine({
|
|
291
|
+
initial: 'idle',
|
|
292
|
+
states: {
|
|
293
|
+
idle: { on: { FETCH: 'loading', RESET: 'idle' } },
|
|
294
|
+
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
295
|
+
done: {},
|
|
296
|
+
error: {},
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
expect(m.nextEvents()).toEqual(expect.arrayContaining(['FETCH', 'RESET']))
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('returns empty array for final states', () => {
|
|
303
|
+
const m = createMachine({
|
|
304
|
+
initial: 'idle',
|
|
305
|
+
states: {
|
|
306
|
+
idle: { on: { DONE: 'finished' } },
|
|
307
|
+
finished: {},
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
m.send('DONE')
|
|
311
|
+
expect(m.nextEvents()).toEqual([])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('is reactive', () => {
|
|
315
|
+
const m = createMachine({
|
|
316
|
+
initial: 'idle',
|
|
317
|
+
states: {
|
|
318
|
+
idle: { on: { START: 'running' } },
|
|
319
|
+
running: { on: { STOP: 'idle', PAUSE: 'paused' } },
|
|
320
|
+
paused: { on: { RESUME: 'running' } },
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
const results: string[][] = []
|
|
324
|
+
|
|
325
|
+
effect(() => {
|
|
326
|
+
results.push(m.nextEvents())
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
m.send('START')
|
|
330
|
+
expect(results).toHaveLength(2)
|
|
331
|
+
expect(results[1]).toEqual(expect.arrayContaining(['STOP', 'PAUSE']))
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// ─── reset ───────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe('reset()', () => {
|
|
338
|
+
it('returns to initial state', () => {
|
|
339
|
+
const m = createMachine({
|
|
340
|
+
initial: 'idle',
|
|
341
|
+
states: {
|
|
342
|
+
idle: { on: { START: 'running' } },
|
|
343
|
+
running: { on: { STOP: 'idle' } },
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
m.send('START')
|
|
347
|
+
expect(m()).toBe('running')
|
|
348
|
+
|
|
349
|
+
m.reset()
|
|
350
|
+
expect(m()).toBe('idle')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// ─── Reactivity ────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
describe('reactivity', () => {
|
|
357
|
+
it('machine() is reactive in effect', () => {
|
|
358
|
+
const m = createMachine({
|
|
359
|
+
initial: 'a',
|
|
360
|
+
states: {
|
|
361
|
+
a: { on: { NEXT: 'b' } },
|
|
362
|
+
b: { on: { NEXT: 'c' } },
|
|
363
|
+
c: {},
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
const states: string[] = []
|
|
367
|
+
|
|
368
|
+
effect(() => {
|
|
369
|
+
states.push(m())
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
m.send('NEXT')
|
|
373
|
+
m.send('NEXT')
|
|
374
|
+
|
|
375
|
+
expect(states).toEqual(['a', 'b', 'c'])
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('machine() is reactive in computed', () => {
|
|
379
|
+
const m = createMachine({
|
|
380
|
+
initial: 'idle',
|
|
381
|
+
states: {
|
|
382
|
+
idle: { on: { LOAD: 'loading' } },
|
|
383
|
+
loading: { on: { DONE: 'idle' } },
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const isLoading = computed(() => m() === 'loading')
|
|
388
|
+
expect(isLoading()).toBe(false)
|
|
389
|
+
|
|
390
|
+
m.send('LOAD')
|
|
391
|
+
expect(isLoading()).toBe(true)
|
|
392
|
+
|
|
393
|
+
m.send('DONE')
|
|
394
|
+
expect(isLoading()).toBe(false)
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ─── onEnter ─────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
describe('onEnter()', () => {
|
|
401
|
+
it('fires when entering a state', () => {
|
|
402
|
+
const m = createMachine({
|
|
403
|
+
initial: 'idle',
|
|
404
|
+
states: {
|
|
405
|
+
idle: { on: { LOAD: 'loading' } },
|
|
406
|
+
loading: { on: { DONE: 'idle' } },
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
const entered: string[] = []
|
|
410
|
+
|
|
411
|
+
m.onEnter('loading', (event) => {
|
|
412
|
+
entered.push(event.type)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
m.send('LOAD')
|
|
416
|
+
expect(entered).toEqual(['LOAD'])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('does not fire for other states', () => {
|
|
420
|
+
const m = createMachine({
|
|
421
|
+
initial: 'a',
|
|
422
|
+
states: {
|
|
423
|
+
a: { on: { GO: 'b' } },
|
|
424
|
+
b: { on: { GO: 'c' } },
|
|
425
|
+
c: {},
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
const fn = vi.fn()
|
|
429
|
+
|
|
430
|
+
m.onEnter('c', fn)
|
|
431
|
+
m.send('GO') // a → b
|
|
432
|
+
expect(fn).not.toHaveBeenCalled()
|
|
433
|
+
|
|
434
|
+
m.send('GO') // b → c
|
|
435
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('receives event payload', () => {
|
|
439
|
+
const m = createMachine({
|
|
440
|
+
initial: 'idle',
|
|
441
|
+
states: {
|
|
442
|
+
idle: { on: { SELECT: 'selected' } },
|
|
443
|
+
selected: {},
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
let received: unknown = null
|
|
447
|
+
|
|
448
|
+
m.onEnter('selected', (event) => {
|
|
449
|
+
received = event.payload
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
m.send('SELECT', { id: 42 })
|
|
453
|
+
expect(received).toEqual({ id: 42 })
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('fires on self-transitions', () => {
|
|
457
|
+
const m = createMachine({
|
|
458
|
+
initial: 'counting',
|
|
459
|
+
states: {
|
|
460
|
+
counting: { on: { INC: 'counting' } },
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
const fn = vi.fn()
|
|
464
|
+
|
|
465
|
+
m.onEnter('counting', fn)
|
|
466
|
+
m.send('INC')
|
|
467
|
+
m.send('INC')
|
|
468
|
+
|
|
469
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('returns unsubscribe function', () => {
|
|
473
|
+
const m = createMachine({
|
|
474
|
+
initial: 'a',
|
|
475
|
+
states: {
|
|
476
|
+
a: { on: { GO: 'b' } },
|
|
477
|
+
b: { on: { GO: 'a' } },
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
const fn = vi.fn()
|
|
481
|
+
|
|
482
|
+
const unsub = m.onEnter('b', fn)
|
|
483
|
+
m.send('GO') // a → b
|
|
484
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
485
|
+
|
|
486
|
+
unsub()
|
|
487
|
+
m.send('GO') // b → a
|
|
488
|
+
m.send('GO') // a → b again
|
|
489
|
+
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('multiple listeners for same state', () => {
|
|
493
|
+
const m = createMachine({
|
|
494
|
+
initial: 'idle',
|
|
495
|
+
states: {
|
|
496
|
+
idle: { on: { GO: 'active' } },
|
|
497
|
+
active: {},
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
const fn1 = vi.fn()
|
|
501
|
+
const fn2 = vi.fn()
|
|
502
|
+
|
|
503
|
+
m.onEnter('active', fn1)
|
|
504
|
+
m.onEnter('active', fn2)
|
|
505
|
+
|
|
506
|
+
m.send('GO')
|
|
507
|
+
expect(fn1).toHaveBeenCalledOnce()
|
|
508
|
+
expect(fn2).toHaveBeenCalledOnce()
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// ─── onTransition ────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
describe('onTransition()', () => {
|
|
515
|
+
it('fires on every transition', () => {
|
|
516
|
+
const m = createMachine({
|
|
517
|
+
initial: 'a',
|
|
518
|
+
states: {
|
|
519
|
+
a: { on: { NEXT: 'b' } },
|
|
520
|
+
b: { on: { NEXT: 'c' } },
|
|
521
|
+
c: {},
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
const transitions: [string, string, string][] = []
|
|
525
|
+
|
|
526
|
+
m.onTransition((from, to, event) => {
|
|
527
|
+
transitions.push([from, to, event.type])
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
m.send('NEXT')
|
|
531
|
+
m.send('NEXT')
|
|
532
|
+
|
|
533
|
+
expect(transitions).toEqual([
|
|
534
|
+
['a', 'b', 'NEXT'],
|
|
535
|
+
['b', 'c', 'NEXT'],
|
|
536
|
+
])
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('does not fire when event is ignored', () => {
|
|
540
|
+
const m = createMachine({
|
|
541
|
+
initial: 'idle',
|
|
542
|
+
states: {
|
|
543
|
+
idle: { on: { START: 'running' } },
|
|
544
|
+
running: {},
|
|
545
|
+
},
|
|
546
|
+
})
|
|
547
|
+
const fn = vi.fn()
|
|
548
|
+
|
|
549
|
+
m.onTransition(fn)
|
|
550
|
+
m.send('STOP' as any) // invalid event
|
|
551
|
+
expect(fn).not.toHaveBeenCalled()
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('returns unsubscribe function', () => {
|
|
555
|
+
const m = createMachine({
|
|
556
|
+
initial: 'a',
|
|
557
|
+
states: {
|
|
558
|
+
a: { on: { GO: 'b' } },
|
|
559
|
+
b: { on: { GO: 'a' } },
|
|
560
|
+
},
|
|
561
|
+
})
|
|
562
|
+
const fn = vi.fn()
|
|
563
|
+
|
|
564
|
+
const unsub = m.onTransition(fn)
|
|
565
|
+
m.send('GO')
|
|
566
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
567
|
+
|
|
568
|
+
unsub()
|
|
569
|
+
m.send('GO')
|
|
570
|
+
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
571
|
+
})
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// ─── dispose ─────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
describe('dispose()', () => {
|
|
577
|
+
it('removes all listeners', () => {
|
|
578
|
+
const m = createMachine({
|
|
579
|
+
initial: 'a',
|
|
580
|
+
states: {
|
|
581
|
+
a: { on: { GO: 'b' } },
|
|
582
|
+
b: { on: { GO: 'a' } },
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
const enterFn = vi.fn()
|
|
586
|
+
const transitionFn = vi.fn()
|
|
587
|
+
|
|
588
|
+
m.onEnter('b', enterFn)
|
|
589
|
+
m.onTransition(transitionFn)
|
|
590
|
+
|
|
591
|
+
m.dispose()
|
|
592
|
+
|
|
593
|
+
m.send('GO')
|
|
594
|
+
expect(enterFn).not.toHaveBeenCalled()
|
|
595
|
+
expect(transitionFn).not.toHaveBeenCalled()
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
// ─── Real-world patterns ───────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
describe('real-world patterns', () => {
|
|
602
|
+
it('multi-step wizard', () => {
|
|
603
|
+
const m = createMachine({
|
|
604
|
+
initial: 'step1',
|
|
605
|
+
states: {
|
|
606
|
+
step1: { on: { NEXT: 'step2' } },
|
|
607
|
+
step2: { on: { NEXT: 'step3', BACK: 'step1' } },
|
|
608
|
+
step3: { on: { SUBMIT: 'submitting', BACK: 'step2' } },
|
|
609
|
+
submitting: { on: { SUCCESS: 'done', ERROR: 'step3' } },
|
|
610
|
+
done: {},
|
|
611
|
+
},
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
m.send('NEXT') // step1 → step2
|
|
615
|
+
m.send('NEXT') // step2 → step3
|
|
616
|
+
expect(m()).toBe('step3')
|
|
617
|
+
|
|
618
|
+
m.send('BACK') // step3 → step2
|
|
619
|
+
expect(m()).toBe('step2')
|
|
620
|
+
|
|
621
|
+
m.send('NEXT') // step2 → step3
|
|
622
|
+
m.send('SUBMIT') // step3 → submitting
|
|
623
|
+
expect(m()).toBe('submitting')
|
|
624
|
+
|
|
625
|
+
m.send('SUCCESS')
|
|
626
|
+
expect(m()).toBe('done')
|
|
627
|
+
|
|
628
|
+
// Final state — no more transitions
|
|
629
|
+
m.send('SUBMIT')
|
|
630
|
+
expect(m()).toBe('done')
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('async fetch with onEnter', () => {
|
|
634
|
+
const m = createMachine({
|
|
635
|
+
initial: 'idle',
|
|
636
|
+
states: {
|
|
637
|
+
idle: { on: { FETCH: 'loading' } },
|
|
638
|
+
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
639
|
+
done: { on: { REFETCH: 'loading' } },
|
|
640
|
+
error: { on: { RETRY: 'loading' } },
|
|
641
|
+
},
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
const data = signal<string | null>(null)
|
|
645
|
+
|
|
646
|
+
m.onEnter('loading', () => {
|
|
647
|
+
// Simulate async — in real code this would be an API call
|
|
648
|
+
data.set('loaded data')
|
|
649
|
+
m.send('SUCCESS')
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
m.send('FETCH')
|
|
653
|
+
expect(m()).toBe('done')
|
|
654
|
+
expect(data()).toBe('loaded data')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('toggle with reactive UI', () => {
|
|
658
|
+
const m = createMachine({
|
|
659
|
+
initial: 'off',
|
|
660
|
+
states: {
|
|
661
|
+
off: { on: { TOGGLE: 'on' } },
|
|
662
|
+
on: { on: { TOGGLE: 'off' } },
|
|
663
|
+
},
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
const labels: string[] = []
|
|
667
|
+
effect(() => {
|
|
668
|
+
labels.push(m.matches('on') ? 'ON' : 'OFF')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
m.send('TOGGLE')
|
|
672
|
+
m.send('TOGGLE')
|
|
673
|
+
m.send('TOGGLE')
|
|
674
|
+
|
|
675
|
+
expect(labels).toEqual(['OFF', 'ON', 'OFF', 'ON'])
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
it('form with validation guard', () => {
|
|
679
|
+
const isValid = signal(false)
|
|
680
|
+
|
|
681
|
+
const m = createMachine({
|
|
682
|
+
initial: 'editing',
|
|
683
|
+
states: {
|
|
684
|
+
editing: {
|
|
685
|
+
on: {
|
|
686
|
+
SUBMIT: {
|
|
687
|
+
target: 'submitting',
|
|
688
|
+
guard: () => isValid.peek(),
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
|
|
693
|
+
done: {},
|
|
694
|
+
},
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
m.send('SUBMIT') // guard fails
|
|
698
|
+
expect(m()).toBe('editing')
|
|
699
|
+
|
|
700
|
+
isValid.set(true)
|
|
701
|
+
m.send('SUBMIT') // guard passes
|
|
702
|
+
expect(m()).toBe('submitting')
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('player with pause/resume', () => {
|
|
706
|
+
const m = createMachine({
|
|
707
|
+
initial: 'stopped',
|
|
708
|
+
states: {
|
|
709
|
+
stopped: { on: { PLAY: 'playing' } },
|
|
710
|
+
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
|
|
711
|
+
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
|
|
712
|
+
},
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
m.send('PLAY')
|
|
716
|
+
expect(m()).toBe('playing')
|
|
717
|
+
|
|
718
|
+
m.send('PAUSE')
|
|
719
|
+
expect(m()).toBe('paused')
|
|
720
|
+
|
|
721
|
+
m.send('PLAY')
|
|
722
|
+
expect(m()).toBe('playing')
|
|
723
|
+
|
|
724
|
+
m.send('STOP')
|
|
725
|
+
expect(m()).toBe('stopped')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('analytics tracking via onTransition', () => {
|
|
729
|
+
const m = createMachine({
|
|
730
|
+
initial: 'step1',
|
|
731
|
+
states: {
|
|
732
|
+
step1: { on: { NEXT: 'step2' } },
|
|
733
|
+
step2: { on: { NEXT: 'step3' } },
|
|
734
|
+
step3: {},
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
const tracked: string[] = []
|
|
739
|
+
m.onTransition((from, to) => {
|
|
740
|
+
tracked.push(`${from} → ${to}`)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
m.send('NEXT')
|
|
744
|
+
m.send('NEXT')
|
|
745
|
+
|
|
746
|
+
expect(tracked).toEqual(['step1 → step2', 'step2 → step3'])
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('reusable machine definition', () => {
|
|
750
|
+
const toggleDef = {
|
|
751
|
+
initial: 'off' as const,
|
|
752
|
+
states: {
|
|
753
|
+
off: { on: { TOGGLE: 'on' as const } },
|
|
754
|
+
on: { on: { TOGGLE: 'off' as const } },
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const m1 = createMachine(toggleDef)
|
|
759
|
+
const m2 = createMachine(toggleDef)
|
|
760
|
+
|
|
761
|
+
m1.send('TOGGLE')
|
|
762
|
+
expect(m1()).toBe('on')
|
|
763
|
+
expect(m2()).toBe('off') // independent instance
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transition target — either a state name or an object with target and guard.
|
|
3
|
+
*/
|
|
4
|
+
export type TransitionConfig<TState extends string> =
|
|
5
|
+
| TState
|
|
6
|
+
| { target: TState; guard: (payload?: unknown) => boolean }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* State definition — maps event names to transition configs.
|
|
10
|
+
*/
|
|
11
|
+
export interface StateConfig<TState extends string, TEvent extends string> {
|
|
12
|
+
on?: Partial<Record<TEvent, TransitionConfig<TState>>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Machine definition — initial state and state configs.
|
|
17
|
+
*/
|
|
18
|
+
export interface MachineConfig<TState extends string, TEvent extends string> {
|
|
19
|
+
initial: TState
|
|
20
|
+
states: Record<TState, StateConfig<TState, TEvent>>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Event object passed to listeners.
|
|
25
|
+
*/
|
|
26
|
+
export interface MachineEvent<TEvent extends string = string> {
|
|
27
|
+
type: TEvent
|
|
28
|
+
payload?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Callback for onEnter — receives the event that caused the transition.
|
|
33
|
+
*/
|
|
34
|
+
export type EnterCallback<TEvent extends string = string> = (
|
|
35
|
+
event: MachineEvent<TEvent>,
|
|
36
|
+
) => void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Callback for onTransition — receives from state, to state, and the event.
|
|
40
|
+
*/
|
|
41
|
+
export type TransitionCallback<
|
|
42
|
+
TState extends string = string,
|
|
43
|
+
TEvent extends string = string,
|
|
44
|
+
> = (from: TState, to: TState, event: MachineEvent<TEvent>) => void
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The machine instance returned by `createMachine()`.
|
|
48
|
+
*/
|
|
49
|
+
export interface Machine<TState extends string, TEvent extends string> {
|
|
50
|
+
/** Read current state — reactive in effects/computeds/JSX */
|
|
51
|
+
(): TState
|
|
52
|
+
|
|
53
|
+
/** Send an event to trigger a transition */
|
|
54
|
+
send: (event: TEvent, payload?: unknown) => void
|
|
55
|
+
|
|
56
|
+
/** Check if the machine is in one of the given states — reactive */
|
|
57
|
+
matches: (...states: TState[]) => boolean
|
|
58
|
+
|
|
59
|
+
/** Check if an event would trigger a valid transition from current state */
|
|
60
|
+
can: (event: TEvent) => boolean
|
|
61
|
+
|
|
62
|
+
/** Get all valid events from the current state — reactive */
|
|
63
|
+
nextEvents: () => TEvent[]
|
|
64
|
+
|
|
65
|
+
/** Reset to initial state */
|
|
66
|
+
reset: () => void
|
|
67
|
+
|
|
68
|
+
/** Register a callback for when the machine enters a specific state */
|
|
69
|
+
onEnter: (state: TState, callback: EnterCallback<TEvent>) => () => void
|
|
70
|
+
|
|
71
|
+
/** Register a callback for any state transition */
|
|
72
|
+
onTransition: (callback: TransitionCallback<TState, TEvent>) => () => void
|
|
73
|
+
|
|
74
|
+
/** Remove all listeners and clean up */
|
|
75
|
+
dispose: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Type inference helpers ──────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/** Extract state names from a machine config */
|
|
81
|
+
export type InferStates<T> = T extends { states: Record<infer S, unknown> }
|
|
82
|
+
? S & string
|
|
83
|
+
: never
|
|
84
|
+
|
|
85
|
+
/** Extract event names from a machine config */
|
|
86
|
+
export type InferEvents<T> = T extends {
|
|
87
|
+
states: Record<string, { on?: Partial<Record<infer E, unknown>> }>
|
|
88
|
+
}
|
|
89
|
+
? E & string
|
|
90
|
+
: never
|