@pyreon/machine 0.11.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/machine",
3
- "version": "0.11.3",
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.3"
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.3",
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
+ })