@pyreon/machine 0.11.4 → 0.11.6
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/README.md +18 -14
- package/lib/index.js.map +1 -1
- package/package.json +14 -14
- package/src/index.ts +2 -2
- package/src/machine.ts +3 -3
- package/src/tests/api.test.ts +137 -137
- package/src/tests/guards.test.ts +62 -62
- package/src/tests/listeners.test.ts +98 -98
- package/src/tests/machine.test.ts +281 -281
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
import { computed, effect, signal } from
|
|
2
|
-
import { describe, expect, it, vi } from
|
|
3
|
-
import { createMachine } from
|
|
1
|
+
import { computed, effect, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createMachine } from '../index'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
5
|
+
describe('createMachine', () => {
|
|
6
6
|
// ─── Basic state and transitions ─────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
describe(
|
|
9
|
-
it(
|
|
8
|
+
describe('basic transitions', () => {
|
|
9
|
+
it('starts in initial state', () => {
|
|
10
10
|
const m = createMachine({
|
|
11
|
-
initial:
|
|
11
|
+
initial: 'idle',
|
|
12
12
|
states: {
|
|
13
|
-
idle: { on: { START:
|
|
13
|
+
idle: { on: { START: 'running' } },
|
|
14
14
|
running: {},
|
|
15
15
|
},
|
|
16
16
|
})
|
|
17
|
-
expect(m()).toBe(
|
|
17
|
+
expect(m()).toBe('idle')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it(
|
|
20
|
+
it('transitions on valid event', () => {
|
|
21
21
|
const m = createMachine({
|
|
22
|
-
initial:
|
|
22
|
+
initial: 'idle',
|
|
23
23
|
states: {
|
|
24
|
-
idle: { on: { START:
|
|
25
|
-
running: { on: { STOP:
|
|
24
|
+
idle: { on: { START: 'running' } },
|
|
25
|
+
running: { on: { STOP: 'idle' } },
|
|
26
26
|
},
|
|
27
27
|
})
|
|
28
|
-
m.send(
|
|
29
|
-
expect(m()).toBe(
|
|
28
|
+
m.send('START')
|
|
29
|
+
expect(m()).toBe('running')
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
-
it(
|
|
32
|
+
it('ignores invalid events (no-op)', () => {
|
|
33
33
|
const m = createMachine({
|
|
34
|
-
initial:
|
|
34
|
+
initial: 'idle',
|
|
35
35
|
states: {
|
|
36
|
-
idle: { on: { START:
|
|
37
|
-
running: { on: { STOP:
|
|
36
|
+
idle: { on: { START: 'running' } },
|
|
37
|
+
running: { on: { STOP: 'idle' } },
|
|
38
38
|
},
|
|
39
39
|
})
|
|
40
|
-
m.send(
|
|
41
|
-
expect(m()).toBe(
|
|
40
|
+
m.send('STOP' as any) // not valid in 'idle'
|
|
41
|
+
expect(m()).toBe('idle')
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
-
it(
|
|
44
|
+
it('supports self-transitions', () => {
|
|
45
45
|
const m = createMachine({
|
|
46
|
-
initial:
|
|
46
|
+
initial: 'counting',
|
|
47
47
|
states: {
|
|
48
|
-
counting: { on: { INCREMENT:
|
|
48
|
+
counting: { on: { INCREMENT: 'counting' } },
|
|
49
49
|
},
|
|
50
50
|
})
|
|
51
|
-
m.send(
|
|
52
|
-
expect(m()).toBe(
|
|
51
|
+
m.send('INCREMENT')
|
|
52
|
+
expect(m()).toBe('counting')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it(
|
|
55
|
+
it('supports multiple transitions from one state', () => {
|
|
56
56
|
const m = createMachine({
|
|
57
|
-
initial:
|
|
57
|
+
initial: 'idle',
|
|
58
58
|
states: {
|
|
59
|
-
idle: { on: { FETCH:
|
|
59
|
+
idle: { on: { FETCH: 'loading', CANCEL: 'cancelled' } },
|
|
60
60
|
loading: {},
|
|
61
61
|
cancelled: {},
|
|
62
62
|
},
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
m.send(
|
|
66
|
-
expect(m()).toBe(
|
|
65
|
+
m.send('CANCEL')
|
|
66
|
+
expect(m()).toBe('cancelled')
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
it(
|
|
69
|
+
it('handles states with no transitions (final states)', () => {
|
|
70
70
|
const m = createMachine({
|
|
71
|
-
initial:
|
|
71
|
+
initial: 'idle',
|
|
72
72
|
states: {
|
|
73
|
-
idle: { on: { DONE:
|
|
73
|
+
idle: { on: { DONE: 'finished' } },
|
|
74
74
|
finished: {},
|
|
75
75
|
},
|
|
76
76
|
})
|
|
77
|
-
m.send(
|
|
78
|
-
expect(m()).toBe(
|
|
79
|
-
m.send(
|
|
80
|
-
expect(m()).toBe(
|
|
77
|
+
m.send('DONE')
|
|
78
|
+
expect(m()).toBe('finished')
|
|
79
|
+
m.send('DONE') // ignored — no transitions from 'finished'
|
|
80
|
+
expect(m()).toBe('finished')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it(
|
|
83
|
+
it('throws on invalid initial state', () => {
|
|
84
84
|
expect(() =>
|
|
85
85
|
createMachine({
|
|
86
|
-
initial:
|
|
86
|
+
initial: 'nonexistent' as any,
|
|
87
87
|
states: {
|
|
88
88
|
idle: {},
|
|
89
89
|
},
|
|
90
90
|
}),
|
|
91
|
-
).toThrow(
|
|
91
|
+
).toThrow('[@pyreon/machine] Initial state')
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
// ─── Guards ──────────────────────────────────────────────────────────
|
|
96
96
|
|
|
97
|
-
describe(
|
|
98
|
-
it(
|
|
97
|
+
describe('guards', () => {
|
|
98
|
+
it('transitions when guard returns true', () => {
|
|
99
99
|
const m = createMachine({
|
|
100
|
-
initial:
|
|
100
|
+
initial: 'editing',
|
|
101
101
|
states: {
|
|
102
102
|
editing: {
|
|
103
103
|
on: {
|
|
104
|
-
SUBMIT: { target:
|
|
104
|
+
SUBMIT: { target: 'submitting', guard: () => true },
|
|
105
105
|
},
|
|
106
106
|
},
|
|
107
107
|
submitting: {},
|
|
108
108
|
},
|
|
109
109
|
})
|
|
110
|
-
m.send(
|
|
111
|
-
expect(m()).toBe(
|
|
110
|
+
m.send('SUBMIT')
|
|
111
|
+
expect(m()).toBe('submitting')
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
-
it(
|
|
114
|
+
it('blocks transition when guard returns false', () => {
|
|
115
115
|
const m = createMachine({
|
|
116
|
-
initial:
|
|
116
|
+
initial: 'editing',
|
|
117
117
|
states: {
|
|
118
118
|
editing: {
|
|
119
119
|
on: {
|
|
120
|
-
SUBMIT: { target:
|
|
120
|
+
SUBMIT: { target: 'submitting', guard: () => false },
|
|
121
121
|
},
|
|
122
122
|
},
|
|
123
123
|
submitting: {},
|
|
124
124
|
},
|
|
125
125
|
})
|
|
126
|
-
m.send(
|
|
127
|
-
expect(m()).toBe(
|
|
126
|
+
m.send('SUBMIT')
|
|
127
|
+
expect(m()).toBe('editing')
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
it(
|
|
130
|
+
it('guard receives event payload', () => {
|
|
131
131
|
const guardFn = vi.fn((payload?: unknown) => {
|
|
132
132
|
return (payload as any)?.valid === true
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
const m = createMachine({
|
|
136
|
-
initial:
|
|
136
|
+
initial: 'editing',
|
|
137
137
|
states: {
|
|
138
138
|
editing: {
|
|
139
139
|
on: {
|
|
140
|
-
SUBMIT: { target:
|
|
140
|
+
SUBMIT: { target: 'submitting', guard: guardFn },
|
|
141
141
|
},
|
|
142
142
|
},
|
|
143
143
|
submitting: {},
|
|
144
144
|
},
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
-
m.send(
|
|
148
|
-
expect(m()).toBe(
|
|
147
|
+
m.send('SUBMIT', { valid: false })
|
|
148
|
+
expect(m()).toBe('editing')
|
|
149
149
|
expect(guardFn).toHaveBeenCalledWith({ valid: false })
|
|
150
150
|
|
|
151
|
-
m.send(
|
|
152
|
-
expect(m()).toBe(
|
|
151
|
+
m.send('SUBMIT', { valid: true })
|
|
152
|
+
expect(m()).toBe('submitting')
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
-
it(
|
|
155
|
+
it('guard with reactive signal', () => {
|
|
156
156
|
const isValid = signal(false)
|
|
157
157
|
|
|
158
158
|
const m = createMachine({
|
|
159
|
-
initial:
|
|
159
|
+
initial: 'editing',
|
|
160
160
|
states: {
|
|
161
161
|
editing: {
|
|
162
162
|
on: {
|
|
163
163
|
SUBMIT: {
|
|
164
|
-
target:
|
|
164
|
+
target: 'submitting',
|
|
165
165
|
guard: () => isValid.peek(),
|
|
166
166
|
},
|
|
167
167
|
},
|
|
@@ -170,154 +170,154 @@ describe("createMachine", () => {
|
|
|
170
170
|
},
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
-
m.send(
|
|
174
|
-
expect(m()).toBe(
|
|
173
|
+
m.send('SUBMIT')
|
|
174
|
+
expect(m()).toBe('editing')
|
|
175
175
|
|
|
176
176
|
isValid.set(true)
|
|
177
|
-
m.send(
|
|
178
|
-
expect(m()).toBe(
|
|
177
|
+
m.send('SUBMIT')
|
|
178
|
+
expect(m()).toBe('submitting')
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
181
|
|
|
182
182
|
// ─── matches ─────────────────────────────────────────────────────────
|
|
183
183
|
|
|
184
|
-
describe(
|
|
185
|
-
it(
|
|
184
|
+
describe('matches()', () => {
|
|
185
|
+
it('returns true for current state', () => {
|
|
186
186
|
const m = createMachine({
|
|
187
|
-
initial:
|
|
187
|
+
initial: 'idle',
|
|
188
188
|
states: {
|
|
189
|
-
idle: { on: { START:
|
|
189
|
+
idle: { on: { START: 'running' } },
|
|
190
190
|
running: {},
|
|
191
191
|
},
|
|
192
192
|
})
|
|
193
|
-
expect(m.matches(
|
|
194
|
-
expect(m.matches(
|
|
193
|
+
expect(m.matches('idle')).toBe(true)
|
|
194
|
+
expect(m.matches('running')).toBe(false)
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
-
it(
|
|
197
|
+
it('supports multiple states', () => {
|
|
198
198
|
const m = createMachine({
|
|
199
|
-
initial:
|
|
199
|
+
initial: 'loading',
|
|
200
200
|
states: {
|
|
201
201
|
idle: {},
|
|
202
202
|
loading: {},
|
|
203
203
|
error: {},
|
|
204
204
|
},
|
|
205
205
|
})
|
|
206
|
-
expect(m.matches(
|
|
207
|
-
expect(m.matches(
|
|
206
|
+
expect(m.matches('loading', 'error')).toBe(true)
|
|
207
|
+
expect(m.matches('idle', 'error')).toBe(false)
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
-
it(
|
|
210
|
+
it('is reactive in effects', () => {
|
|
211
211
|
const m = createMachine({
|
|
212
|
-
initial:
|
|
212
|
+
initial: 'idle',
|
|
213
213
|
states: {
|
|
214
|
-
idle: { on: { START:
|
|
215
|
-
running: { on: { STOP:
|
|
214
|
+
idle: { on: { START: 'running' } },
|
|
215
|
+
running: { on: { STOP: 'idle' } },
|
|
216
216
|
},
|
|
217
217
|
})
|
|
218
218
|
const results: boolean[] = []
|
|
219
219
|
|
|
220
220
|
effect(() => {
|
|
221
|
-
results.push(m.matches(
|
|
221
|
+
results.push(m.matches('running'))
|
|
222
222
|
})
|
|
223
223
|
|
|
224
224
|
expect(results).toEqual([false])
|
|
225
225
|
|
|
226
|
-
m.send(
|
|
226
|
+
m.send('START')
|
|
227
227
|
expect(results).toEqual([false, true])
|
|
228
228
|
|
|
229
|
-
m.send(
|
|
229
|
+
m.send('STOP')
|
|
230
230
|
expect(results).toEqual([false, true, false])
|
|
231
231
|
})
|
|
232
232
|
})
|
|
233
233
|
|
|
234
234
|
// ─── can ─────────────────────────────────────────────────────────────
|
|
235
235
|
|
|
236
|
-
describe(
|
|
237
|
-
it(
|
|
236
|
+
describe('can()', () => {
|
|
237
|
+
it('returns true for valid events', () => {
|
|
238
238
|
const m = createMachine({
|
|
239
|
-
initial:
|
|
239
|
+
initial: 'idle',
|
|
240
240
|
states: {
|
|
241
|
-
idle: { on: { START:
|
|
242
|
-
running: { on: { STOP:
|
|
241
|
+
idle: { on: { START: 'running' } },
|
|
242
|
+
running: { on: { STOP: 'idle' } },
|
|
243
243
|
},
|
|
244
244
|
})
|
|
245
|
-
expect(m.can(
|
|
246
|
-
expect(m.can(
|
|
245
|
+
expect(m.can('START')).toBe(true)
|
|
246
|
+
expect(m.can('STOP')).toBe(false)
|
|
247
247
|
})
|
|
248
248
|
|
|
249
|
-
it(
|
|
249
|
+
it('is reactive', () => {
|
|
250
250
|
const m = createMachine({
|
|
251
|
-
initial:
|
|
251
|
+
initial: 'idle',
|
|
252
252
|
states: {
|
|
253
|
-
idle: { on: { START:
|
|
254
|
-
running: { on: { STOP:
|
|
253
|
+
idle: { on: { START: 'running' } },
|
|
254
|
+
running: { on: { STOP: 'idle' } },
|
|
255
255
|
},
|
|
256
256
|
})
|
|
257
257
|
const results: boolean[] = []
|
|
258
258
|
|
|
259
259
|
effect(() => {
|
|
260
|
-
results.push(m.can(
|
|
260
|
+
results.push(m.can('STOP'))
|
|
261
261
|
})
|
|
262
262
|
|
|
263
263
|
expect(results).toEqual([false])
|
|
264
264
|
|
|
265
|
-
m.send(
|
|
265
|
+
m.send('START')
|
|
266
266
|
expect(results).toEqual([false, true])
|
|
267
267
|
})
|
|
268
268
|
|
|
269
|
-
it(
|
|
269
|
+
it('returns true for guarded transitions (guard not evaluated)', () => {
|
|
270
270
|
const m = createMachine({
|
|
271
|
-
initial:
|
|
271
|
+
initial: 'editing',
|
|
272
272
|
states: {
|
|
273
273
|
editing: {
|
|
274
274
|
on: {
|
|
275
|
-
SUBMIT: { target:
|
|
275
|
+
SUBMIT: { target: 'submitting', guard: () => false },
|
|
276
276
|
},
|
|
277
277
|
},
|
|
278
278
|
submitting: {},
|
|
279
279
|
},
|
|
280
280
|
})
|
|
281
281
|
// can() returns true because the event exists, even though guard would fail
|
|
282
|
-
expect(m.can(
|
|
282
|
+
expect(m.can('SUBMIT')).toBe(true)
|
|
283
283
|
})
|
|
284
284
|
})
|
|
285
285
|
|
|
286
286
|
// ─── nextEvents ──────────────────────────────────────────────────────
|
|
287
287
|
|
|
288
|
-
describe(
|
|
289
|
-
it(
|
|
288
|
+
describe('nextEvents()', () => {
|
|
289
|
+
it('returns available events from current state', () => {
|
|
290
290
|
const m = createMachine({
|
|
291
|
-
initial:
|
|
291
|
+
initial: 'idle',
|
|
292
292
|
states: {
|
|
293
|
-
idle: { on: { FETCH:
|
|
294
|
-
loading: { on: { SUCCESS:
|
|
293
|
+
idle: { on: { FETCH: 'loading', RESET: 'idle' } },
|
|
294
|
+
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
295
295
|
done: {},
|
|
296
296
|
error: {},
|
|
297
297
|
},
|
|
298
298
|
})
|
|
299
|
-
expect(m.nextEvents()).toEqual(expect.arrayContaining([
|
|
299
|
+
expect(m.nextEvents()).toEqual(expect.arrayContaining(['FETCH', 'RESET']))
|
|
300
300
|
})
|
|
301
301
|
|
|
302
|
-
it(
|
|
302
|
+
it('returns empty array for final states', () => {
|
|
303
303
|
const m = createMachine({
|
|
304
|
-
initial:
|
|
304
|
+
initial: 'idle',
|
|
305
305
|
states: {
|
|
306
|
-
idle: { on: { DONE:
|
|
306
|
+
idle: { on: { DONE: 'finished' } },
|
|
307
307
|
finished: {},
|
|
308
308
|
},
|
|
309
309
|
})
|
|
310
|
-
m.send(
|
|
310
|
+
m.send('DONE')
|
|
311
311
|
expect(m.nextEvents()).toEqual([])
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
-
it(
|
|
314
|
+
it('is reactive', () => {
|
|
315
315
|
const m = createMachine({
|
|
316
|
-
initial:
|
|
316
|
+
initial: 'idle',
|
|
317
317
|
states: {
|
|
318
|
-
idle: { on: { START:
|
|
319
|
-
running: { on: { STOP:
|
|
320
|
-
paused: { on: { RESUME:
|
|
318
|
+
idle: { on: { START: 'running' } },
|
|
319
|
+
running: { on: { STOP: 'idle', PAUSE: 'paused' } },
|
|
320
|
+
paused: { on: { RESUME: 'running' } },
|
|
321
321
|
},
|
|
322
322
|
})
|
|
323
323
|
const results: string[][] = []
|
|
@@ -326,40 +326,40 @@ describe("createMachine", () => {
|
|
|
326
326
|
results.push(m.nextEvents())
|
|
327
327
|
})
|
|
328
328
|
|
|
329
|
-
m.send(
|
|
329
|
+
m.send('START')
|
|
330
330
|
expect(results).toHaveLength(2)
|
|
331
|
-
expect(results[1]).toEqual(expect.arrayContaining([
|
|
331
|
+
expect(results[1]).toEqual(expect.arrayContaining(['STOP', 'PAUSE']))
|
|
332
332
|
})
|
|
333
333
|
})
|
|
334
334
|
|
|
335
335
|
// ─── reset ───────────────────────────────────────────────────────────
|
|
336
336
|
|
|
337
|
-
describe(
|
|
338
|
-
it(
|
|
337
|
+
describe('reset()', () => {
|
|
338
|
+
it('returns to initial state', () => {
|
|
339
339
|
const m = createMachine({
|
|
340
|
-
initial:
|
|
340
|
+
initial: 'idle',
|
|
341
341
|
states: {
|
|
342
|
-
idle: { on: { START:
|
|
343
|
-
running: { on: { STOP:
|
|
342
|
+
idle: { on: { START: 'running' } },
|
|
343
|
+
running: { on: { STOP: 'idle' } },
|
|
344
344
|
},
|
|
345
345
|
})
|
|
346
|
-
m.send(
|
|
347
|
-
expect(m()).toBe(
|
|
346
|
+
m.send('START')
|
|
347
|
+
expect(m()).toBe('running')
|
|
348
348
|
|
|
349
349
|
m.reset()
|
|
350
|
-
expect(m()).toBe(
|
|
350
|
+
expect(m()).toBe('idle')
|
|
351
351
|
})
|
|
352
352
|
})
|
|
353
353
|
|
|
354
354
|
// ─── Reactivity ────────────────────────────────────────────────────
|
|
355
355
|
|
|
356
|
-
describe(
|
|
357
|
-
it(
|
|
356
|
+
describe('reactivity', () => {
|
|
357
|
+
it('machine() is reactive in effect', () => {
|
|
358
358
|
const m = createMachine({
|
|
359
|
-
initial:
|
|
359
|
+
initial: 'a',
|
|
360
360
|
states: {
|
|
361
|
-
a: { on: { NEXT:
|
|
362
|
-
b: { on: { NEXT:
|
|
361
|
+
a: { on: { NEXT: 'b' } },
|
|
362
|
+
b: { on: { NEXT: 'c' } },
|
|
363
363
|
c: {},
|
|
364
364
|
},
|
|
365
365
|
})
|
|
@@ -369,141 +369,141 @@ describe("createMachine", () => {
|
|
|
369
369
|
states.push(m())
|
|
370
370
|
})
|
|
371
371
|
|
|
372
|
-
m.send(
|
|
373
|
-
m.send(
|
|
372
|
+
m.send('NEXT')
|
|
373
|
+
m.send('NEXT')
|
|
374
374
|
|
|
375
|
-
expect(states).toEqual([
|
|
375
|
+
expect(states).toEqual(['a', 'b', 'c'])
|
|
376
376
|
})
|
|
377
377
|
|
|
378
|
-
it(
|
|
378
|
+
it('machine() is reactive in computed', () => {
|
|
379
379
|
const m = createMachine({
|
|
380
|
-
initial:
|
|
380
|
+
initial: 'idle',
|
|
381
381
|
states: {
|
|
382
|
-
idle: { on: { LOAD:
|
|
383
|
-
loading: { on: { DONE:
|
|
382
|
+
idle: { on: { LOAD: 'loading' } },
|
|
383
|
+
loading: { on: { DONE: 'idle' } },
|
|
384
384
|
},
|
|
385
385
|
})
|
|
386
386
|
|
|
387
|
-
const isLoading = computed(() => m() ===
|
|
387
|
+
const isLoading = computed(() => m() === 'loading')
|
|
388
388
|
expect(isLoading()).toBe(false)
|
|
389
389
|
|
|
390
|
-
m.send(
|
|
390
|
+
m.send('LOAD')
|
|
391
391
|
expect(isLoading()).toBe(true)
|
|
392
392
|
|
|
393
|
-
m.send(
|
|
393
|
+
m.send('DONE')
|
|
394
394
|
expect(isLoading()).toBe(false)
|
|
395
395
|
})
|
|
396
396
|
})
|
|
397
397
|
|
|
398
398
|
// ─── onEnter ─────────────────────────────────────────────────────────
|
|
399
399
|
|
|
400
|
-
describe(
|
|
401
|
-
it(
|
|
400
|
+
describe('onEnter()', () => {
|
|
401
|
+
it('fires when entering a state', () => {
|
|
402
402
|
const m = createMachine({
|
|
403
|
-
initial:
|
|
403
|
+
initial: 'idle',
|
|
404
404
|
states: {
|
|
405
|
-
idle: { on: { LOAD:
|
|
406
|
-
loading: { on: { DONE:
|
|
405
|
+
idle: { on: { LOAD: 'loading' } },
|
|
406
|
+
loading: { on: { DONE: 'idle' } },
|
|
407
407
|
},
|
|
408
408
|
})
|
|
409
409
|
const entered: string[] = []
|
|
410
410
|
|
|
411
|
-
m.onEnter(
|
|
411
|
+
m.onEnter('loading', (event) => {
|
|
412
412
|
entered.push(event.type)
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
-
m.send(
|
|
416
|
-
expect(entered).toEqual([
|
|
415
|
+
m.send('LOAD')
|
|
416
|
+
expect(entered).toEqual(['LOAD'])
|
|
417
417
|
})
|
|
418
418
|
|
|
419
|
-
it(
|
|
419
|
+
it('does not fire for other states', () => {
|
|
420
420
|
const m = createMachine({
|
|
421
|
-
initial:
|
|
421
|
+
initial: 'a',
|
|
422
422
|
states: {
|
|
423
|
-
a: { on: { GO:
|
|
424
|
-
b: { on: { GO:
|
|
423
|
+
a: { on: { GO: 'b' } },
|
|
424
|
+
b: { on: { GO: 'c' } },
|
|
425
425
|
c: {},
|
|
426
426
|
},
|
|
427
427
|
})
|
|
428
428
|
const fn = vi.fn()
|
|
429
429
|
|
|
430
|
-
m.onEnter(
|
|
431
|
-
m.send(
|
|
430
|
+
m.onEnter('c', fn)
|
|
431
|
+
m.send('GO') // a → b
|
|
432
432
|
expect(fn).not.toHaveBeenCalled()
|
|
433
433
|
|
|
434
|
-
m.send(
|
|
434
|
+
m.send('GO') // b → c
|
|
435
435
|
expect(fn).toHaveBeenCalledOnce()
|
|
436
436
|
})
|
|
437
437
|
|
|
438
|
-
it(
|
|
438
|
+
it('receives event payload', () => {
|
|
439
439
|
const m = createMachine({
|
|
440
|
-
initial:
|
|
440
|
+
initial: 'idle',
|
|
441
441
|
states: {
|
|
442
|
-
idle: { on: { SELECT:
|
|
442
|
+
idle: { on: { SELECT: 'selected' } },
|
|
443
443
|
selected: {},
|
|
444
444
|
},
|
|
445
445
|
})
|
|
446
446
|
let received: unknown = null
|
|
447
447
|
|
|
448
|
-
m.onEnter(
|
|
448
|
+
m.onEnter('selected', (event) => {
|
|
449
449
|
received = event.payload
|
|
450
450
|
})
|
|
451
451
|
|
|
452
|
-
m.send(
|
|
452
|
+
m.send('SELECT', { id: 42 })
|
|
453
453
|
expect(received).toEqual({ id: 42 })
|
|
454
454
|
})
|
|
455
455
|
|
|
456
|
-
it(
|
|
456
|
+
it('fires on self-transitions', () => {
|
|
457
457
|
const m = createMachine({
|
|
458
|
-
initial:
|
|
458
|
+
initial: 'counting',
|
|
459
459
|
states: {
|
|
460
|
-
counting: { on: { INC:
|
|
460
|
+
counting: { on: { INC: 'counting' } },
|
|
461
461
|
},
|
|
462
462
|
})
|
|
463
463
|
const fn = vi.fn()
|
|
464
464
|
|
|
465
|
-
m.onEnter(
|
|
466
|
-
m.send(
|
|
467
|
-
m.send(
|
|
465
|
+
m.onEnter('counting', fn)
|
|
466
|
+
m.send('INC')
|
|
467
|
+
m.send('INC')
|
|
468
468
|
|
|
469
469
|
expect(fn).toHaveBeenCalledTimes(2)
|
|
470
470
|
})
|
|
471
471
|
|
|
472
|
-
it(
|
|
472
|
+
it('returns unsubscribe function', () => {
|
|
473
473
|
const m = createMachine({
|
|
474
|
-
initial:
|
|
474
|
+
initial: 'a',
|
|
475
475
|
states: {
|
|
476
|
-
a: { on: { GO:
|
|
477
|
-
b: { on: { GO:
|
|
476
|
+
a: { on: { GO: 'b' } },
|
|
477
|
+
b: { on: { GO: 'a' } },
|
|
478
478
|
},
|
|
479
479
|
})
|
|
480
480
|
const fn = vi.fn()
|
|
481
481
|
|
|
482
|
-
const unsub = m.onEnter(
|
|
483
|
-
m.send(
|
|
482
|
+
const unsub = m.onEnter('b', fn)
|
|
483
|
+
m.send('GO') // a → b
|
|
484
484
|
expect(fn).toHaveBeenCalledOnce()
|
|
485
485
|
|
|
486
486
|
unsub()
|
|
487
|
-
m.send(
|
|
488
|
-
m.send(
|
|
487
|
+
m.send('GO') // b → a
|
|
488
|
+
m.send('GO') // a → b again
|
|
489
489
|
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
490
490
|
})
|
|
491
491
|
|
|
492
|
-
it(
|
|
492
|
+
it('multiple listeners for same state', () => {
|
|
493
493
|
const m = createMachine({
|
|
494
|
-
initial:
|
|
494
|
+
initial: 'idle',
|
|
495
495
|
states: {
|
|
496
|
-
idle: { on: { GO:
|
|
496
|
+
idle: { on: { GO: 'active' } },
|
|
497
497
|
active: {},
|
|
498
498
|
},
|
|
499
499
|
})
|
|
500
500
|
const fn1 = vi.fn()
|
|
501
501
|
const fn2 = vi.fn()
|
|
502
502
|
|
|
503
|
-
m.onEnter(
|
|
504
|
-
m.onEnter(
|
|
503
|
+
m.onEnter('active', fn1)
|
|
504
|
+
m.onEnter('active', fn2)
|
|
505
505
|
|
|
506
|
-
m.send(
|
|
506
|
+
m.send('GO')
|
|
507
507
|
expect(fn1).toHaveBeenCalledOnce()
|
|
508
508
|
expect(fn2).toHaveBeenCalledOnce()
|
|
509
509
|
})
|
|
@@ -511,13 +511,13 @@ describe("createMachine", () => {
|
|
|
511
511
|
|
|
512
512
|
// ─── onTransition ────────────────────────────────────────────────────
|
|
513
513
|
|
|
514
|
-
describe(
|
|
515
|
-
it(
|
|
514
|
+
describe('onTransition()', () => {
|
|
515
|
+
it('fires on every transition', () => {
|
|
516
516
|
const m = createMachine({
|
|
517
|
-
initial:
|
|
517
|
+
initial: 'a',
|
|
518
518
|
states: {
|
|
519
|
-
a: { on: { NEXT:
|
|
520
|
-
b: { on: { NEXT:
|
|
519
|
+
a: { on: { NEXT: 'b' } },
|
|
520
|
+
b: { on: { NEXT: 'c' } },
|
|
521
521
|
c: {},
|
|
522
522
|
},
|
|
523
523
|
})
|
|
@@ -527,70 +527,70 @@ describe("createMachine", () => {
|
|
|
527
527
|
transitions.push([from, to, event.type])
|
|
528
528
|
})
|
|
529
529
|
|
|
530
|
-
m.send(
|
|
531
|
-
m.send(
|
|
530
|
+
m.send('NEXT')
|
|
531
|
+
m.send('NEXT')
|
|
532
532
|
|
|
533
533
|
expect(transitions).toEqual([
|
|
534
|
-
[
|
|
535
|
-
[
|
|
534
|
+
['a', 'b', 'NEXT'],
|
|
535
|
+
['b', 'c', 'NEXT'],
|
|
536
536
|
])
|
|
537
537
|
})
|
|
538
538
|
|
|
539
|
-
it(
|
|
539
|
+
it('does not fire when event is ignored', () => {
|
|
540
540
|
const m = createMachine({
|
|
541
|
-
initial:
|
|
541
|
+
initial: 'idle',
|
|
542
542
|
states: {
|
|
543
|
-
idle: { on: { START:
|
|
543
|
+
idle: { on: { START: 'running' } },
|
|
544
544
|
running: {},
|
|
545
545
|
},
|
|
546
546
|
})
|
|
547
547
|
const fn = vi.fn()
|
|
548
548
|
|
|
549
549
|
m.onTransition(fn)
|
|
550
|
-
m.send(
|
|
550
|
+
m.send('STOP' as any) // invalid event
|
|
551
551
|
expect(fn).not.toHaveBeenCalled()
|
|
552
552
|
})
|
|
553
553
|
|
|
554
|
-
it(
|
|
554
|
+
it('returns unsubscribe function', () => {
|
|
555
555
|
const m = createMachine({
|
|
556
|
-
initial:
|
|
556
|
+
initial: 'a',
|
|
557
557
|
states: {
|
|
558
|
-
a: { on: { GO:
|
|
559
|
-
b: { on: { GO:
|
|
558
|
+
a: { on: { GO: 'b' } },
|
|
559
|
+
b: { on: { GO: 'a' } },
|
|
560
560
|
},
|
|
561
561
|
})
|
|
562
562
|
const fn = vi.fn()
|
|
563
563
|
|
|
564
564
|
const unsub = m.onTransition(fn)
|
|
565
|
-
m.send(
|
|
565
|
+
m.send('GO')
|
|
566
566
|
expect(fn).toHaveBeenCalledOnce()
|
|
567
567
|
|
|
568
568
|
unsub()
|
|
569
|
-
m.send(
|
|
569
|
+
m.send('GO')
|
|
570
570
|
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
571
571
|
})
|
|
572
572
|
})
|
|
573
573
|
|
|
574
574
|
// ─── dispose ─────────────────────────────────────────────────────────
|
|
575
575
|
|
|
576
|
-
describe(
|
|
577
|
-
it(
|
|
576
|
+
describe('dispose()', () => {
|
|
577
|
+
it('removes all listeners', () => {
|
|
578
578
|
const m = createMachine({
|
|
579
|
-
initial:
|
|
579
|
+
initial: 'a',
|
|
580
580
|
states: {
|
|
581
|
-
a: { on: { GO:
|
|
582
|
-
b: { on: { GO:
|
|
581
|
+
a: { on: { GO: 'b' } },
|
|
582
|
+
b: { on: { GO: 'a' } },
|
|
583
583
|
},
|
|
584
584
|
})
|
|
585
585
|
const enterFn = vi.fn()
|
|
586
586
|
const transitionFn = vi.fn()
|
|
587
587
|
|
|
588
|
-
m.onEnter(
|
|
588
|
+
m.onEnter('b', enterFn)
|
|
589
589
|
m.onTransition(transitionFn)
|
|
590
590
|
|
|
591
591
|
m.dispose()
|
|
592
592
|
|
|
593
|
-
m.send(
|
|
593
|
+
m.send('GO')
|
|
594
594
|
expect(enterFn).not.toHaveBeenCalled()
|
|
595
595
|
expect(transitionFn).not.toHaveBeenCalled()
|
|
596
596
|
})
|
|
@@ -598,139 +598,139 @@ describe("createMachine", () => {
|
|
|
598
598
|
|
|
599
599
|
// ─── Real-world patterns ───────────────────────────────────────────
|
|
600
600
|
|
|
601
|
-
describe(
|
|
602
|
-
it(
|
|
601
|
+
describe('real-world patterns', () => {
|
|
602
|
+
it('multi-step wizard', () => {
|
|
603
603
|
const m = createMachine({
|
|
604
|
-
initial:
|
|
604
|
+
initial: 'step1',
|
|
605
605
|
states: {
|
|
606
|
-
step1: { on: { NEXT:
|
|
607
|
-
step2: { on: { NEXT:
|
|
608
|
-
step3: { on: { SUBMIT:
|
|
609
|
-
submitting: { on: { SUCCESS:
|
|
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
610
|
done: {},
|
|
611
611
|
},
|
|
612
612
|
})
|
|
613
613
|
|
|
614
|
-
m.send(
|
|
615
|
-
m.send(
|
|
616
|
-
expect(m()).toBe(
|
|
614
|
+
m.send('NEXT') // step1 → step2
|
|
615
|
+
m.send('NEXT') // step2 → step3
|
|
616
|
+
expect(m()).toBe('step3')
|
|
617
617
|
|
|
618
|
-
m.send(
|
|
619
|
-
expect(m()).toBe(
|
|
618
|
+
m.send('BACK') // step3 → step2
|
|
619
|
+
expect(m()).toBe('step2')
|
|
620
620
|
|
|
621
|
-
m.send(
|
|
622
|
-
m.send(
|
|
623
|
-
expect(m()).toBe(
|
|
621
|
+
m.send('NEXT') // step2 → step3
|
|
622
|
+
m.send('SUBMIT') // step3 → submitting
|
|
623
|
+
expect(m()).toBe('submitting')
|
|
624
624
|
|
|
625
|
-
m.send(
|
|
626
|
-
expect(m()).toBe(
|
|
625
|
+
m.send('SUCCESS')
|
|
626
|
+
expect(m()).toBe('done')
|
|
627
627
|
|
|
628
628
|
// Final state — no more transitions
|
|
629
|
-
m.send(
|
|
630
|
-
expect(m()).toBe(
|
|
629
|
+
m.send('SUBMIT')
|
|
630
|
+
expect(m()).toBe('done')
|
|
631
631
|
})
|
|
632
632
|
|
|
633
|
-
it(
|
|
633
|
+
it('async fetch with onEnter', () => {
|
|
634
634
|
const m = createMachine({
|
|
635
|
-
initial:
|
|
635
|
+
initial: 'idle',
|
|
636
636
|
states: {
|
|
637
|
-
idle: { on: { FETCH:
|
|
638
|
-
loading: { on: { SUCCESS:
|
|
639
|
-
done: { on: { REFETCH:
|
|
640
|
-
error: { on: { RETRY:
|
|
637
|
+
idle: { on: { FETCH: 'loading' } },
|
|
638
|
+
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
|
|
639
|
+
done: { on: { REFETCH: 'loading' } },
|
|
640
|
+
error: { on: { RETRY: 'loading' } },
|
|
641
641
|
},
|
|
642
642
|
})
|
|
643
643
|
|
|
644
644
|
const data = signal<string | null>(null)
|
|
645
645
|
|
|
646
|
-
m.onEnter(
|
|
646
|
+
m.onEnter('loading', () => {
|
|
647
647
|
// Simulate async — in real code this would be an API call
|
|
648
|
-
data.set(
|
|
649
|
-
m.send(
|
|
648
|
+
data.set('loaded data')
|
|
649
|
+
m.send('SUCCESS')
|
|
650
650
|
})
|
|
651
651
|
|
|
652
|
-
m.send(
|
|
653
|
-
expect(m()).toBe(
|
|
654
|
-
expect(data()).toBe(
|
|
652
|
+
m.send('FETCH')
|
|
653
|
+
expect(m()).toBe('done')
|
|
654
|
+
expect(data()).toBe('loaded data')
|
|
655
655
|
})
|
|
656
656
|
|
|
657
|
-
it(
|
|
657
|
+
it('toggle with reactive UI', () => {
|
|
658
658
|
const m = createMachine({
|
|
659
|
-
initial:
|
|
659
|
+
initial: 'off',
|
|
660
660
|
states: {
|
|
661
|
-
off: { on: { TOGGLE:
|
|
662
|
-
on: { on: { TOGGLE:
|
|
661
|
+
off: { on: { TOGGLE: 'on' } },
|
|
662
|
+
on: { on: { TOGGLE: 'off' } },
|
|
663
663
|
},
|
|
664
664
|
})
|
|
665
665
|
|
|
666
666
|
const labels: string[] = []
|
|
667
667
|
effect(() => {
|
|
668
|
-
labels.push(m.matches(
|
|
668
|
+
labels.push(m.matches('on') ? 'ON' : 'OFF')
|
|
669
669
|
})
|
|
670
670
|
|
|
671
|
-
m.send(
|
|
672
|
-
m.send(
|
|
673
|
-
m.send(
|
|
671
|
+
m.send('TOGGLE')
|
|
672
|
+
m.send('TOGGLE')
|
|
673
|
+
m.send('TOGGLE')
|
|
674
674
|
|
|
675
|
-
expect(labels).toEqual([
|
|
675
|
+
expect(labels).toEqual(['OFF', 'ON', 'OFF', 'ON'])
|
|
676
676
|
})
|
|
677
677
|
|
|
678
|
-
it(
|
|
678
|
+
it('form with validation guard', () => {
|
|
679
679
|
const isValid = signal(false)
|
|
680
680
|
|
|
681
681
|
const m = createMachine({
|
|
682
|
-
initial:
|
|
682
|
+
initial: 'editing',
|
|
683
683
|
states: {
|
|
684
684
|
editing: {
|
|
685
685
|
on: {
|
|
686
686
|
SUBMIT: {
|
|
687
|
-
target:
|
|
687
|
+
target: 'submitting',
|
|
688
688
|
guard: () => isValid.peek(),
|
|
689
689
|
},
|
|
690
690
|
},
|
|
691
691
|
},
|
|
692
|
-
submitting: { on: { SUCCESS:
|
|
692
|
+
submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
|
|
693
693
|
done: {},
|
|
694
694
|
},
|
|
695
695
|
})
|
|
696
696
|
|
|
697
|
-
m.send(
|
|
698
|
-
expect(m()).toBe(
|
|
697
|
+
m.send('SUBMIT') // guard fails
|
|
698
|
+
expect(m()).toBe('editing')
|
|
699
699
|
|
|
700
700
|
isValid.set(true)
|
|
701
|
-
m.send(
|
|
702
|
-
expect(m()).toBe(
|
|
701
|
+
m.send('SUBMIT') // guard passes
|
|
702
|
+
expect(m()).toBe('submitting')
|
|
703
703
|
})
|
|
704
704
|
|
|
705
|
-
it(
|
|
705
|
+
it('player with pause/resume', () => {
|
|
706
706
|
const m = createMachine({
|
|
707
|
-
initial:
|
|
707
|
+
initial: 'stopped',
|
|
708
708
|
states: {
|
|
709
|
-
stopped: { on: { PLAY:
|
|
710
|
-
playing: { on: { PAUSE:
|
|
711
|
-
paused: { on: { PLAY:
|
|
709
|
+
stopped: { on: { PLAY: 'playing' } },
|
|
710
|
+
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
|
|
711
|
+
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
|
|
712
712
|
},
|
|
713
713
|
})
|
|
714
714
|
|
|
715
|
-
m.send(
|
|
716
|
-
expect(m()).toBe(
|
|
715
|
+
m.send('PLAY')
|
|
716
|
+
expect(m()).toBe('playing')
|
|
717
717
|
|
|
718
|
-
m.send(
|
|
719
|
-
expect(m()).toBe(
|
|
718
|
+
m.send('PAUSE')
|
|
719
|
+
expect(m()).toBe('paused')
|
|
720
720
|
|
|
721
|
-
m.send(
|
|
722
|
-
expect(m()).toBe(
|
|
721
|
+
m.send('PLAY')
|
|
722
|
+
expect(m()).toBe('playing')
|
|
723
723
|
|
|
724
|
-
m.send(
|
|
725
|
-
expect(m()).toBe(
|
|
724
|
+
m.send('STOP')
|
|
725
|
+
expect(m()).toBe('stopped')
|
|
726
726
|
})
|
|
727
727
|
|
|
728
|
-
it(
|
|
728
|
+
it('analytics tracking via onTransition', () => {
|
|
729
729
|
const m = createMachine({
|
|
730
|
-
initial:
|
|
730
|
+
initial: 'step1',
|
|
731
731
|
states: {
|
|
732
|
-
step1: { on: { NEXT:
|
|
733
|
-
step2: { on: { NEXT:
|
|
732
|
+
step1: { on: { NEXT: 'step2' } },
|
|
733
|
+
step2: { on: { NEXT: 'step3' } },
|
|
734
734
|
step3: {},
|
|
735
735
|
},
|
|
736
736
|
})
|
|
@@ -740,27 +740,27 @@ describe("createMachine", () => {
|
|
|
740
740
|
tracked.push(`${from} → ${to}`)
|
|
741
741
|
})
|
|
742
742
|
|
|
743
|
-
m.send(
|
|
744
|
-
m.send(
|
|
743
|
+
m.send('NEXT')
|
|
744
|
+
m.send('NEXT')
|
|
745
745
|
|
|
746
|
-
expect(tracked).toEqual([
|
|
746
|
+
expect(tracked).toEqual(['step1 → step2', 'step2 → step3'])
|
|
747
747
|
})
|
|
748
748
|
|
|
749
|
-
it(
|
|
749
|
+
it('reusable machine definition', () => {
|
|
750
750
|
const toggleDef = {
|
|
751
|
-
initial:
|
|
751
|
+
initial: 'off' as const,
|
|
752
752
|
states: {
|
|
753
|
-
off: { on: { TOGGLE:
|
|
754
|
-
on: { on: { TOGGLE:
|
|
753
|
+
off: { on: { TOGGLE: 'on' as const } },
|
|
754
|
+
on: { on: { TOGGLE: 'off' as const } },
|
|
755
755
|
},
|
|
756
756
|
}
|
|
757
757
|
|
|
758
758
|
const m1 = createMachine(toggleDef)
|
|
759
759
|
const m2 = createMachine(toggleDef)
|
|
760
760
|
|
|
761
|
-
m1.send(
|
|
762
|
-
expect(m1()).toBe(
|
|
763
|
-
expect(m2()).toBe(
|
|
761
|
+
m1.send('TOGGLE')
|
|
762
|
+
expect(m1()).toBe('on')
|
|
763
|
+
expect(m2()).toBe('off') // independent instance
|
|
764
764
|
})
|
|
765
765
|
})
|
|
766
766
|
})
|