@pyreon/machine 0.11.2 → 0.11.4
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/package.json +3 -3
- package/src/tests/api.test.ts +362 -0
- package/src/tests/guards.test.ts +189 -0
- package/src/tests/listeners.test.ts +288 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/machine",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Reactive state machines for Pyreon — constrained signals with type-safe transitions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"lint": "biome check ."
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@pyreon/reactivity": "^0.11.
|
|
44
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
|
-
"@pyreon/reactivity": "^0.11.
|
|
48
|
+
"@pyreon/reactivity": "^0.11.4",
|
|
49
49
|
"@vitus-labs/tools-lint": "^1.11.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { computed, effect } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { createMachine } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("createMachine — nextEvents()", () => {
|
|
6
|
+
it("returns valid events from current state", () => {
|
|
7
|
+
const m = createMachine({
|
|
8
|
+
initial: "idle",
|
|
9
|
+
states: {
|
|
10
|
+
idle: { on: { FETCH: "loading", RESET: "idle" } },
|
|
11
|
+
loading: { on: { SUCCESS: "done", ERROR: "error" } },
|
|
12
|
+
done: {},
|
|
13
|
+
error: {},
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
expect(m.nextEvents()).toEqual(expect.arrayContaining(["FETCH", "RESET"]))
|
|
17
|
+
expect(m.nextEvents()).toHaveLength(2)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("returns empty array for final states (no transitions)", () => {
|
|
21
|
+
const m = createMachine({
|
|
22
|
+
initial: "idle",
|
|
23
|
+
states: {
|
|
24
|
+
idle: { on: { DONE: "finished" } },
|
|
25
|
+
finished: {},
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
m.send("DONE")
|
|
29
|
+
expect(m.nextEvents()).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("updates after transition", () => {
|
|
33
|
+
const m = createMachine({
|
|
34
|
+
initial: "idle",
|
|
35
|
+
states: {
|
|
36
|
+
idle: { on: { START: "running" } },
|
|
37
|
+
running: { on: { STOP: "idle", PAUSE: "paused" } },
|
|
38
|
+
paused: { on: { RESUME: "running" } },
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(m.nextEvents()).toEqual(["START"])
|
|
43
|
+
|
|
44
|
+
m.send("START")
|
|
45
|
+
expect(m.nextEvents()).toEqual(expect.arrayContaining(["STOP", "PAUSE"]))
|
|
46
|
+
|
|
47
|
+
m.send("PAUSE")
|
|
48
|
+
expect(m.nextEvents()).toEqual(["RESUME"])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is reactive in effects", () => {
|
|
52
|
+
const m = createMachine({
|
|
53
|
+
initial: "idle",
|
|
54
|
+
states: {
|
|
55
|
+
idle: { on: { GO: "active" } },
|
|
56
|
+
active: { on: { STOP: "idle", PAUSE: "paused" } },
|
|
57
|
+
paused: {},
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
const results: string[][] = []
|
|
61
|
+
|
|
62
|
+
effect(() => {
|
|
63
|
+
results.push(m.nextEvents())
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
m.send("GO")
|
|
67
|
+
expect(results).toHaveLength(2)
|
|
68
|
+
expect(results[0]).toEqual(["GO"])
|
|
69
|
+
expect(results[1]).toEqual(expect.arrayContaining(["STOP", "PAUSE"]))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("includes guarded event names", () => {
|
|
73
|
+
const m = createMachine({
|
|
74
|
+
initial: "editing",
|
|
75
|
+
states: {
|
|
76
|
+
editing: {
|
|
77
|
+
on: {
|
|
78
|
+
SUBMIT: { target: "submitting", guard: () => false },
|
|
79
|
+
CANCEL: "cancelled",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
submitting: {},
|
|
83
|
+
cancelled: {},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
// nextEvents returns all event keys, even guarded ones
|
|
87
|
+
expect(m.nextEvents()).toEqual(expect.arrayContaining(["SUBMIT", "CANCEL"]))
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("createMachine — can()", () => {
|
|
92
|
+
it("returns true for valid events from current state", () => {
|
|
93
|
+
const m = createMachine({
|
|
94
|
+
initial: "idle",
|
|
95
|
+
states: {
|
|
96
|
+
idle: { on: { START: "running" } },
|
|
97
|
+
running: { on: { STOP: "idle" } },
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
expect(m.can("START")).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("returns false for events not in current state", () => {
|
|
104
|
+
const m = createMachine({
|
|
105
|
+
initial: "idle",
|
|
106
|
+
states: {
|
|
107
|
+
idle: { on: { START: "running" } },
|
|
108
|
+
running: { on: { STOP: "idle" } },
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
expect(m.can("STOP")).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("returns false for completely unknown events", () => {
|
|
115
|
+
const m = createMachine({
|
|
116
|
+
initial: "idle",
|
|
117
|
+
states: {
|
|
118
|
+
idle: { on: { START: "running" } },
|
|
119
|
+
running: {},
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
expect(m.can("NONEXISTENT" as any)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("returns false when in a final state (no transitions)", () => {
|
|
126
|
+
const m = createMachine({
|
|
127
|
+
initial: "idle",
|
|
128
|
+
states: {
|
|
129
|
+
idle: { on: { DONE: "finished" } },
|
|
130
|
+
finished: {},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
m.send("DONE")
|
|
134
|
+
expect(m.can("DONE")).toBe(false)
|
|
135
|
+
expect(m.can("START" as any)).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("returns true for guarded transitions (guard not evaluated by can())", () => {
|
|
139
|
+
const m = createMachine({
|
|
140
|
+
initial: "editing",
|
|
141
|
+
states: {
|
|
142
|
+
editing: {
|
|
143
|
+
on: { SUBMIT: { target: "submitting", guard: () => false } },
|
|
144
|
+
},
|
|
145
|
+
submitting: {},
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
// can() checks existence only, not guard
|
|
149
|
+
expect(m.can("SUBMIT")).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("is reactive — updates when state changes", () => {
|
|
153
|
+
const m = createMachine({
|
|
154
|
+
initial: "idle",
|
|
155
|
+
states: {
|
|
156
|
+
idle: { on: { START: "running" } },
|
|
157
|
+
running: { on: { STOP: "idle" } },
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
const canStop: boolean[] = []
|
|
161
|
+
|
|
162
|
+
effect(() => {
|
|
163
|
+
canStop.push(m.can("STOP"))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
expect(canStop).toEqual([false])
|
|
167
|
+
|
|
168
|
+
m.send("START")
|
|
169
|
+
expect(canStop).toEqual([false, true])
|
|
170
|
+
|
|
171
|
+
m.send("STOP")
|
|
172
|
+
expect(canStop).toEqual([false, true, false])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("works in computed", () => {
|
|
176
|
+
const m = createMachine({
|
|
177
|
+
initial: "idle",
|
|
178
|
+
states: {
|
|
179
|
+
idle: { on: { START: "running" } },
|
|
180
|
+
running: { on: { STOP: "idle" } },
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const canStart = computed(() => m.can("START"))
|
|
185
|
+
expect(canStart()).toBe(true)
|
|
186
|
+
|
|
187
|
+
m.send("START")
|
|
188
|
+
expect(canStart()).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe("createMachine — reset()", () => {
|
|
193
|
+
it("returns to initial state", () => {
|
|
194
|
+
const m = createMachine({
|
|
195
|
+
initial: "idle",
|
|
196
|
+
states: {
|
|
197
|
+
idle: { on: { START: "running" } },
|
|
198
|
+
running: { on: { STOP: "idle" } },
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
m.send("START")
|
|
202
|
+
expect(m()).toBe("running")
|
|
203
|
+
|
|
204
|
+
m.reset()
|
|
205
|
+
expect(m()).toBe("idle")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("reset from deeply nested state", () => {
|
|
209
|
+
const m = createMachine({
|
|
210
|
+
initial: "step1",
|
|
211
|
+
states: {
|
|
212
|
+
step1: { on: { NEXT: "step2" } },
|
|
213
|
+
step2: { on: { NEXT: "step3" } },
|
|
214
|
+
step3: { on: { NEXT: "step4" } },
|
|
215
|
+
step4: {},
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
m.send("NEXT")
|
|
219
|
+
m.send("NEXT")
|
|
220
|
+
m.send("NEXT")
|
|
221
|
+
expect(m()).toBe("step4")
|
|
222
|
+
|
|
223
|
+
m.reset()
|
|
224
|
+
expect(m()).toBe("step1")
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("reset is reactive", () => {
|
|
228
|
+
const m = createMachine({
|
|
229
|
+
initial: "idle",
|
|
230
|
+
states: {
|
|
231
|
+
idle: { on: { GO: "active" } },
|
|
232
|
+
active: {},
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
const states: string[] = []
|
|
236
|
+
|
|
237
|
+
effect(() => {
|
|
238
|
+
states.push(m())
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
m.send("GO")
|
|
242
|
+
m.reset()
|
|
243
|
+
|
|
244
|
+
expect(states).toEqual(["idle", "active", "idle"])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("reset does not fire onEnter for initial state", () => {
|
|
248
|
+
const m = createMachine({
|
|
249
|
+
initial: "idle",
|
|
250
|
+
states: {
|
|
251
|
+
idle: { on: { GO: "active" } },
|
|
252
|
+
active: { on: { BACK: "idle" } },
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
const fn = vi.fn()
|
|
256
|
+
m.onEnter("idle", fn)
|
|
257
|
+
|
|
258
|
+
m.send("GO")
|
|
259
|
+
m.reset() // Sets state directly, does not go through send()
|
|
260
|
+
// reset() uses current.set() directly, not send(), so no enter callback
|
|
261
|
+
expect(fn).not.toHaveBeenCalled()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("after reset, transitions work from initial state", () => {
|
|
265
|
+
const m = createMachine({
|
|
266
|
+
initial: "idle",
|
|
267
|
+
states: {
|
|
268
|
+
idle: { on: { GO: "active" } },
|
|
269
|
+
active: { on: { DONE: "finished" } },
|
|
270
|
+
finished: {},
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
m.send("GO")
|
|
274
|
+
m.send("DONE")
|
|
275
|
+
expect(m()).toBe("finished")
|
|
276
|
+
|
|
277
|
+
m.reset()
|
|
278
|
+
expect(m()).toBe("idle")
|
|
279
|
+
|
|
280
|
+
m.send("GO")
|
|
281
|
+
expect(m()).toBe("active")
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("reset from initial state is a no-op (same value)", () => {
|
|
285
|
+
const m = createMachine({
|
|
286
|
+
initial: "idle",
|
|
287
|
+
states: {
|
|
288
|
+
idle: { on: { GO: "active" } },
|
|
289
|
+
active: {},
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
m.reset()
|
|
294
|
+
expect(m()).toBe("idle")
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe("createMachine — dispose()", () => {
|
|
299
|
+
it("removes all onEnter listeners", () => {
|
|
300
|
+
const m = createMachine({
|
|
301
|
+
initial: "a",
|
|
302
|
+
states: {
|
|
303
|
+
a: { on: { GO: "b" } },
|
|
304
|
+
b: { on: { GO: "a" } },
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
const fn = vi.fn()
|
|
308
|
+
m.onEnter("b", fn)
|
|
309
|
+
|
|
310
|
+
m.dispose()
|
|
311
|
+
|
|
312
|
+
m.send("GO")
|
|
313
|
+
expect(fn).not.toHaveBeenCalled()
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("removes all onTransition listeners", () => {
|
|
317
|
+
const m = createMachine({
|
|
318
|
+
initial: "a",
|
|
319
|
+
states: {
|
|
320
|
+
a: { on: { GO: "b" } },
|
|
321
|
+
b: {},
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
const fn = vi.fn()
|
|
325
|
+
m.onTransition(fn)
|
|
326
|
+
|
|
327
|
+
m.dispose()
|
|
328
|
+
|
|
329
|
+
m.send("GO")
|
|
330
|
+
expect(fn).not.toHaveBeenCalled()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("machine still transitions after dispose (only listeners removed)", () => {
|
|
334
|
+
const m = createMachine({
|
|
335
|
+
initial: "a",
|
|
336
|
+
states: {
|
|
337
|
+
a: { on: { GO: "b" } },
|
|
338
|
+
b: {},
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
m.onEnter("b", vi.fn())
|
|
342
|
+
m.onTransition(vi.fn())
|
|
343
|
+
|
|
344
|
+
m.dispose()
|
|
345
|
+
|
|
346
|
+
m.send("GO")
|
|
347
|
+
expect(m()).toBe("b") // transitions still work
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("dispose is idempotent", () => {
|
|
351
|
+
const m = createMachine({
|
|
352
|
+
initial: "a",
|
|
353
|
+
states: {
|
|
354
|
+
a: { on: { GO: "b" } },
|
|
355
|
+
b: {},
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
m.dispose()
|
|
359
|
+
m.dispose() // should not throw
|
|
360
|
+
expect(m()).toBe("a")
|
|
361
|
+
})
|
|
362
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { createMachine } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("createMachine — guard conditions", () => {
|
|
6
|
+
it("transitions when guard returns true", () => {
|
|
7
|
+
const m = createMachine({
|
|
8
|
+
initial: "editing",
|
|
9
|
+
states: {
|
|
10
|
+
editing: {
|
|
11
|
+
on: { SUBMIT: { target: "submitting", guard: () => true } },
|
|
12
|
+
},
|
|
13
|
+
submitting: {},
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
m.send("SUBMIT")
|
|
17
|
+
expect(m()).toBe("submitting")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("blocks transition when guard returns false", () => {
|
|
21
|
+
const m = createMachine({
|
|
22
|
+
initial: "editing",
|
|
23
|
+
states: {
|
|
24
|
+
editing: {
|
|
25
|
+
on: { SUBMIT: { target: "submitting", guard: () => false } },
|
|
26
|
+
},
|
|
27
|
+
submitting: {},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
m.send("SUBMIT")
|
|
31
|
+
expect(m()).toBe("editing")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("guard receives event payload", () => {
|
|
35
|
+
const guardFn = vi.fn((payload?: unknown) => {
|
|
36
|
+
return (payload as any)?.amount > 0
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const m = createMachine({
|
|
40
|
+
initial: "idle",
|
|
41
|
+
states: {
|
|
42
|
+
idle: {
|
|
43
|
+
on: { PAY: { target: "processing", guard: guardFn } },
|
|
44
|
+
},
|
|
45
|
+
processing: {},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
m.send("PAY", { amount: 0 })
|
|
50
|
+
expect(m()).toBe("idle")
|
|
51
|
+
expect(guardFn).toHaveBeenCalledWith({ amount: 0 })
|
|
52
|
+
|
|
53
|
+
m.send("PAY", { amount: 100 })
|
|
54
|
+
expect(m()).toBe("processing")
|
|
55
|
+
expect(guardFn).toHaveBeenCalledWith({ amount: 100 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("guard without payload receives undefined", () => {
|
|
59
|
+
const guardFn = vi.fn(() => true)
|
|
60
|
+
|
|
61
|
+
const m = createMachine({
|
|
62
|
+
initial: "idle",
|
|
63
|
+
states: {
|
|
64
|
+
idle: {
|
|
65
|
+
on: { GO: { target: "active", guard: guardFn } },
|
|
66
|
+
},
|
|
67
|
+
active: {},
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
m.send("GO")
|
|
72
|
+
expect(guardFn).toHaveBeenCalledWith(undefined)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("guard with reactive signal dependency", () => {
|
|
76
|
+
const isValid = signal(false)
|
|
77
|
+
|
|
78
|
+
const m = createMachine({
|
|
79
|
+
initial: "editing",
|
|
80
|
+
states: {
|
|
81
|
+
editing: {
|
|
82
|
+
on: {
|
|
83
|
+
SUBMIT: { target: "submitting", guard: () => isValid.peek() },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
submitting: {},
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
m.send("SUBMIT")
|
|
91
|
+
expect(m()).toBe("editing") // guard blocks
|
|
92
|
+
|
|
93
|
+
isValid.set(true)
|
|
94
|
+
m.send("SUBMIT")
|
|
95
|
+
expect(m()).toBe("submitting") // guard passes
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("guard can check multiple conditions", () => {
|
|
99
|
+
const m = createMachine({
|
|
100
|
+
initial: "idle",
|
|
101
|
+
states: {
|
|
102
|
+
idle: {
|
|
103
|
+
on: {
|
|
104
|
+
START: {
|
|
105
|
+
target: "running",
|
|
106
|
+
guard: (payload?: unknown) => {
|
|
107
|
+
const p = payload as { ready: boolean; count: number } | undefined
|
|
108
|
+
return p !== undefined && p.ready && p.count > 0
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
running: {},
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
m.send("START", { ready: false, count: 5 })
|
|
118
|
+
expect(m()).toBe("idle")
|
|
119
|
+
|
|
120
|
+
m.send("START", { ready: true, count: 0 })
|
|
121
|
+
expect(m()).toBe("idle")
|
|
122
|
+
|
|
123
|
+
m.send("START", { ready: true, count: 5 })
|
|
124
|
+
expect(m()).toBe("running")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("different events on same state can have different guards", () => {
|
|
128
|
+
const m = createMachine({
|
|
129
|
+
initial: "editing",
|
|
130
|
+
states: {
|
|
131
|
+
editing: {
|
|
132
|
+
on: {
|
|
133
|
+
SAVE: { target: "saved", guard: () => true },
|
|
134
|
+
PUBLISH: { target: "published", guard: () => false },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
saved: {},
|
|
138
|
+
published: {},
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
m.send("PUBLISH")
|
|
143
|
+
expect(m()).toBe("editing") // guard blocks
|
|
144
|
+
|
|
145
|
+
m.send("SAVE")
|
|
146
|
+
expect(m()).toBe("saved") // guard passes
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("guard blocks do not fire onEnter or onTransition", () => {
|
|
150
|
+
const m = createMachine({
|
|
151
|
+
initial: "a",
|
|
152
|
+
states: {
|
|
153
|
+
a: { on: { GO: { target: "b", guard: () => false } } },
|
|
154
|
+
b: {},
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const enterFn = vi.fn()
|
|
159
|
+
const transitionFn = vi.fn()
|
|
160
|
+
m.onEnter("b", enterFn)
|
|
161
|
+
m.onTransition(transitionFn)
|
|
162
|
+
|
|
163
|
+
m.send("GO")
|
|
164
|
+
expect(enterFn).not.toHaveBeenCalled()
|
|
165
|
+
expect(transitionFn).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("mixing guarded and non-guarded transitions", () => {
|
|
169
|
+
const m = createMachine({
|
|
170
|
+
initial: "idle",
|
|
171
|
+
states: {
|
|
172
|
+
idle: {
|
|
173
|
+
on: {
|
|
174
|
+
FETCH: "loading", // no guard
|
|
175
|
+
ADMIN: { target: "admin", guard: () => false }, // guarded
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
loading: {},
|
|
179
|
+
admin: {},
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
m.send("ADMIN")
|
|
184
|
+
expect(m()).toBe("idle") // blocked
|
|
185
|
+
|
|
186
|
+
m.send("FETCH")
|
|
187
|
+
expect(m()).toBe("loading") // no guard, passes
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { createMachine } from "../index"
|
|
3
|
+
|
|
4
|
+
describe("createMachine — onEnter callbacks", () => {
|
|
5
|
+
it("fires callback when entering the specified state", () => {
|
|
6
|
+
const m = createMachine({
|
|
7
|
+
initial: "idle",
|
|
8
|
+
states: {
|
|
9
|
+
idle: { on: { LOAD: "loading" } },
|
|
10
|
+
loading: { on: { DONE: "idle" } },
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
const entered: string[] = []
|
|
14
|
+
|
|
15
|
+
m.onEnter("loading", (event) => {
|
|
16
|
+
entered.push(event.type)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
m.send("LOAD")
|
|
20
|
+
expect(entered).toEqual(["LOAD"])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("does not fire for other state entries", () => {
|
|
24
|
+
const m = createMachine({
|
|
25
|
+
initial: "a",
|
|
26
|
+
states: {
|
|
27
|
+
a: { on: { GO: "b" } },
|
|
28
|
+
b: { on: { GO: "c" } },
|
|
29
|
+
c: {},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
const fn = vi.fn()
|
|
33
|
+
m.onEnter("c", fn)
|
|
34
|
+
|
|
35
|
+
m.send("GO") // a -> b
|
|
36
|
+
expect(fn).not.toHaveBeenCalled()
|
|
37
|
+
|
|
38
|
+
m.send("GO") // b -> c
|
|
39
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("receives event payload", () => {
|
|
43
|
+
const m = createMachine({
|
|
44
|
+
initial: "idle",
|
|
45
|
+
states: {
|
|
46
|
+
idle: { on: { SELECT: "selected" } },
|
|
47
|
+
selected: {},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
let received: unknown = null
|
|
51
|
+
|
|
52
|
+
m.onEnter("selected", (event) => {
|
|
53
|
+
received = event.payload
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
m.send("SELECT", { id: 42, name: "item" })
|
|
57
|
+
expect(received).toEqual({ id: 42, name: "item" })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("fires on self-transitions", () => {
|
|
61
|
+
const m = createMachine({
|
|
62
|
+
initial: "counting",
|
|
63
|
+
states: {
|
|
64
|
+
counting: { on: { INC: "counting" } },
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
const fn = vi.fn()
|
|
68
|
+
m.onEnter("counting", fn)
|
|
69
|
+
|
|
70
|
+
m.send("INC")
|
|
71
|
+
m.send("INC")
|
|
72
|
+
m.send("INC")
|
|
73
|
+
|
|
74
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("unsubscribe function stops future callbacks", () => {
|
|
78
|
+
const m = createMachine({
|
|
79
|
+
initial: "a",
|
|
80
|
+
states: {
|
|
81
|
+
a: { on: { GO: "b" } },
|
|
82
|
+
b: { on: { GO: "a" } },
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const fn = vi.fn()
|
|
86
|
+
const unsub = m.onEnter("b", fn)
|
|
87
|
+
|
|
88
|
+
m.send("GO") // a -> b
|
|
89
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
90
|
+
|
|
91
|
+
unsub()
|
|
92
|
+
|
|
93
|
+
m.send("GO") // b -> a
|
|
94
|
+
m.send("GO") // a -> b again
|
|
95
|
+
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("multiple listeners for same state all fire", () => {
|
|
99
|
+
const m = createMachine({
|
|
100
|
+
initial: "idle",
|
|
101
|
+
states: {
|
|
102
|
+
idle: { on: { GO: "active" } },
|
|
103
|
+
active: {},
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
const fn1 = vi.fn()
|
|
107
|
+
const fn2 = vi.fn()
|
|
108
|
+
const fn3 = vi.fn()
|
|
109
|
+
|
|
110
|
+
m.onEnter("active", fn1)
|
|
111
|
+
m.onEnter("active", fn2)
|
|
112
|
+
m.onEnter("active", fn3)
|
|
113
|
+
|
|
114
|
+
m.send("GO")
|
|
115
|
+
expect(fn1).toHaveBeenCalledOnce()
|
|
116
|
+
expect(fn2).toHaveBeenCalledOnce()
|
|
117
|
+
expect(fn3).toHaveBeenCalledOnce()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("onEnter for a state that is never entered is never called", () => {
|
|
121
|
+
const m = createMachine({
|
|
122
|
+
initial: "idle",
|
|
123
|
+
states: {
|
|
124
|
+
idle: { on: { GO: "active" } },
|
|
125
|
+
active: {},
|
|
126
|
+
unreachable: {},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
const fn = vi.fn()
|
|
130
|
+
m.onEnter("unreachable", fn)
|
|
131
|
+
|
|
132
|
+
m.send("GO")
|
|
133
|
+
expect(fn).not.toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("createMachine — onTransition callbacks", () => {
|
|
138
|
+
it("fires on every state transition", () => {
|
|
139
|
+
const m = createMachine({
|
|
140
|
+
initial: "a",
|
|
141
|
+
states: {
|
|
142
|
+
a: { on: { NEXT: "b" } },
|
|
143
|
+
b: { on: { NEXT: "c" } },
|
|
144
|
+
c: {},
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
const transitions: [string, string, string][] = []
|
|
148
|
+
|
|
149
|
+
m.onTransition((from, to, event) => {
|
|
150
|
+
transitions.push([from, to, event.type])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
m.send("NEXT") // a -> b
|
|
154
|
+
m.send("NEXT") // b -> c
|
|
155
|
+
|
|
156
|
+
expect(transitions).toEqual([
|
|
157
|
+
["a", "b", "NEXT"],
|
|
158
|
+
["b", "c", "NEXT"],
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("does not fire when event is ignored (no valid transition)", () => {
|
|
163
|
+
const m = createMachine({
|
|
164
|
+
initial: "idle",
|
|
165
|
+
states: {
|
|
166
|
+
idle: { on: { START: "running" } },
|
|
167
|
+
running: {},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
const fn = vi.fn()
|
|
171
|
+
m.onTransition(fn)
|
|
172
|
+
|
|
173
|
+
m.send("STOP" as any) // invalid
|
|
174
|
+
expect(fn).not.toHaveBeenCalled()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("does not fire when guard blocks transition", () => {
|
|
178
|
+
const m = createMachine({
|
|
179
|
+
initial: "idle",
|
|
180
|
+
states: {
|
|
181
|
+
idle: {
|
|
182
|
+
on: { GO: { target: "active", guard: () => false } },
|
|
183
|
+
},
|
|
184
|
+
active: {},
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
const fn = vi.fn()
|
|
188
|
+
m.onTransition(fn)
|
|
189
|
+
|
|
190
|
+
m.send("GO")
|
|
191
|
+
expect(fn).not.toHaveBeenCalled()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("receives event payload", () => {
|
|
195
|
+
const m = createMachine({
|
|
196
|
+
initial: "idle",
|
|
197
|
+
states: {
|
|
198
|
+
idle: { on: { LOAD: "loading" } },
|
|
199
|
+
loading: {},
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
let receivedPayload: unknown = null
|
|
203
|
+
|
|
204
|
+
m.onTransition((_from, _to, event) => {
|
|
205
|
+
receivedPayload = event.payload
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
m.send("LOAD", { url: "/api/data" })
|
|
209
|
+
expect(receivedPayload).toEqual({ url: "/api/data" })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("fires on self-transitions", () => {
|
|
213
|
+
const m = createMachine({
|
|
214
|
+
initial: "counting",
|
|
215
|
+
states: {
|
|
216
|
+
counting: { on: { INC: "counting" } },
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
const fn = vi.fn()
|
|
220
|
+
m.onTransition(fn)
|
|
221
|
+
|
|
222
|
+
m.send("INC")
|
|
223
|
+
m.send("INC")
|
|
224
|
+
|
|
225
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
226
|
+
expect(fn).toHaveBeenCalledWith(
|
|
227
|
+
"counting",
|
|
228
|
+
"counting",
|
|
229
|
+
expect.objectContaining({ type: "INC" }),
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("unsubscribe stops future callbacks", () => {
|
|
234
|
+
const m = createMachine({
|
|
235
|
+
initial: "a",
|
|
236
|
+
states: {
|
|
237
|
+
a: { on: { GO: "b" } },
|
|
238
|
+
b: { on: { GO: "a" } },
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
const fn = vi.fn()
|
|
242
|
+
const unsub = m.onTransition(fn)
|
|
243
|
+
|
|
244
|
+
m.send("GO")
|
|
245
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
246
|
+
|
|
247
|
+
unsub()
|
|
248
|
+
m.send("GO")
|
|
249
|
+
m.send("GO")
|
|
250
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("multiple transition listeners all fire", () => {
|
|
254
|
+
const m = createMachine({
|
|
255
|
+
initial: "idle",
|
|
256
|
+
states: {
|
|
257
|
+
idle: { on: { GO: "active" } },
|
|
258
|
+
active: {},
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
const fn1 = vi.fn()
|
|
262
|
+
const fn2 = vi.fn()
|
|
263
|
+
|
|
264
|
+
m.onTransition(fn1)
|
|
265
|
+
m.onTransition(fn2)
|
|
266
|
+
|
|
267
|
+
m.send("GO")
|
|
268
|
+
expect(fn1).toHaveBeenCalledOnce()
|
|
269
|
+
expect(fn2).toHaveBeenCalledOnce()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("onTransition fires before onEnter", () => {
|
|
273
|
+
const m = createMachine({
|
|
274
|
+
initial: "idle",
|
|
275
|
+
states: {
|
|
276
|
+
idle: { on: { GO: "active" } },
|
|
277
|
+
active: {},
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
const order: string[] = []
|
|
281
|
+
|
|
282
|
+
m.onTransition(() => order.push("transition"))
|
|
283
|
+
m.onEnter("active", () => order.push("enter"))
|
|
284
|
+
|
|
285
|
+
m.send("GO")
|
|
286
|
+
expect(order).toEqual(["transition", "enter"])
|
|
287
|
+
})
|
|
288
|
+
})
|