@kyneta/machine 1.3.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.
@@ -0,0 +1,371 @@
1
+ // machine.test — deterministic tests for the Mealy machine runtime.
2
+ //
3
+ // All tests are pure — no I/O, no timing, no async.
4
+
5
+ import { describe, expect, it, vi } from "vitest"
6
+ import type { Dispatch, Effect, Program } from "../machine.js"
7
+ import { runtime } from "../machine.js"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ type CountMsg = "inc" | "dec" | "reset"
14
+
15
+ function counterProgram(
16
+ initial = 0,
17
+ initialEffects: Effect<CountMsg>[] = [],
18
+ ): Program<CountMsg, number> {
19
+ return {
20
+ init: [initial, ...initialEffects],
21
+ update(msg, model) {
22
+ switch (msg) {
23
+ case "inc":
24
+ return [model + 1]
25
+ case "dec":
26
+ return [model - 1]
27
+ case "reset":
28
+ return [0]
29
+ }
30
+ },
31
+ }
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("runtime", () => {
39
+ it("calls init and sets initial state", () => {
40
+ const views: number[] = []
41
+ runtime(counterProgram(42), model => {
42
+ views.push(model)
43
+ })
44
+ expect(views).toEqual([42])
45
+ })
46
+
47
+ it("executes initial effects", () => {
48
+ const executed: string[] = []
49
+ const effect: Effect<CountMsg> = () => {
50
+ executed.push("init-effect")
51
+ }
52
+ runtime(counterProgram(0, [effect]))
53
+ expect(executed).toEqual(["init-effect"])
54
+ })
55
+
56
+ it("dispatch calls update and applies state transition", () => {
57
+ const views: number[] = []
58
+ let dispatch!: Dispatch<CountMsg>
59
+ runtime(counterProgram(0), (model, d) => {
60
+ views.push(model)
61
+ dispatch = d
62
+ })
63
+
64
+ dispatch("inc")
65
+ dispatch("inc")
66
+ dispatch("dec")
67
+
68
+ expect(views).toEqual([0, 1, 2, 1])
69
+ })
70
+
71
+ it("dispatch executes effects from update", () => {
72
+ const executed: string[] = []
73
+
74
+ const program: Program<string, number> = {
75
+ init: [0],
76
+ update(msg, model) {
77
+ if (msg === "go") {
78
+ return [model + 1, () => executed.push("effect-from-update")]
79
+ }
80
+ return [model]
81
+ },
82
+ }
83
+
84
+ let dispatch!: Dispatch<string>
85
+ runtime(program, (_model, d) => {
86
+ dispatch = d
87
+ })
88
+
89
+ dispatch("go")
90
+ expect(executed).toEqual(["effect-from-update"])
91
+ })
92
+
93
+ it("works without a view callback", () => {
94
+ // Should not throw
95
+ const dispose = runtime(counterProgram(0))
96
+ dispose()
97
+ })
98
+
99
+ it("handles re-entrant dispatch from effects", () => {
100
+ const views: number[] = []
101
+
102
+ const program: Program<string, number> = {
103
+ init: [0],
104
+ update(msg, model) {
105
+ switch (msg) {
106
+ case "trigger":
107
+ return [model + 1, dispatch => dispatch("followup")]
108
+ case "followup":
109
+ return [model + 10]
110
+ default:
111
+ return [model]
112
+ }
113
+ },
114
+ }
115
+
116
+ let dispatch!: Dispatch<string>
117
+ runtime(program, (model, d) => {
118
+ views.push(model)
119
+ dispatch = d
120
+ })
121
+
122
+ dispatch("trigger")
123
+
124
+ // "trigger" → model=1, effect dispatches "followup"
125
+ // "followup" → model=11
126
+ expect(views).toEqual([0, 1, 11])
127
+ })
128
+
129
+ it("disposer stops message processing", () => {
130
+ const views: number[] = []
131
+ let dispatch!: Dispatch<CountMsg>
132
+ const dispose = runtime(counterProgram(0), (model, d) => {
133
+ views.push(model)
134
+ dispatch = d
135
+ })
136
+
137
+ dispatch("inc")
138
+ dispose()
139
+ dispatch("inc") // should be a no-op
140
+
141
+ expect(views).toEqual([0, 1])
142
+ })
143
+
144
+ it("done is called with final state on dispose", () => {
145
+ const done = vi.fn()
146
+
147
+ const program: Program<CountMsg, number> = {
148
+ ...counterProgram(5),
149
+ done,
150
+ }
151
+
152
+ let dispatch!: Dispatch<CountMsg>
153
+ const dispose = runtime(program, (_model, d) => {
154
+ dispatch = d
155
+ })
156
+
157
+ dispatch("inc")
158
+ dispatch("inc")
159
+ dispose()
160
+
161
+ expect(done).toHaveBeenCalledOnce()
162
+ expect(done).toHaveBeenCalledWith(7)
163
+ })
164
+
165
+ it("multiple effects from a single update are all executed", () => {
166
+ const executed: number[] = []
167
+
168
+ const program: Program<string, number> = {
169
+ init: [0],
170
+ update(msg, model) {
171
+ if (msg === "multi") {
172
+ return [
173
+ model,
174
+ () => executed.push(1),
175
+ () => executed.push(2),
176
+ () => executed.push(3),
177
+ ]
178
+ }
179
+ return [model]
180
+ },
181
+ }
182
+
183
+ let dispatch!: Dispatch<string>
184
+ runtime(program, (_model, d) => {
185
+ dispatch = d
186
+ })
187
+
188
+ dispatch("multi")
189
+ expect(executed).toEqual([1, 2, 3])
190
+ })
191
+
192
+ it("multiple initial effects are all executed in order", () => {
193
+ const executed: number[] = []
194
+
195
+ const program: Program<string, number> = {
196
+ init: [
197
+ 0,
198
+ () => executed.push(1),
199
+ () => executed.push(2),
200
+ () => executed.push(3),
201
+ ],
202
+ update(_msg, model) {
203
+ return [model]
204
+ },
205
+ }
206
+
207
+ runtime(program)
208
+ expect(executed).toEqual([1, 2, 3])
209
+ })
210
+
211
+ it("dispose is idempotent", () => {
212
+ const done = vi.fn()
213
+ const program: Program<CountMsg, number> = {
214
+ ...counterProgram(0),
215
+ done,
216
+ }
217
+
218
+ const dispose = runtime(program)
219
+ dispose()
220
+ dispose()
221
+ dispose()
222
+
223
+ expect(done).toHaveBeenCalledOnce()
224
+ })
225
+
226
+ it("initial effects can dispatch messages", () => {
227
+ const views: number[] = []
228
+
229
+ const program: Program<CountMsg, number> = {
230
+ init: [
231
+ 0,
232
+ dispatch => {
233
+ dispatch("inc")
234
+ dispatch("inc")
235
+ },
236
+ ],
237
+ update(msg, model) {
238
+ switch (msg) {
239
+ case "inc":
240
+ return [model + 1]
241
+ case "dec":
242
+ return [model - 1]
243
+ case "reset":
244
+ return [0]
245
+ }
246
+ },
247
+ }
248
+
249
+ runtime(program, model => {
250
+ views.push(model)
251
+ })
252
+
253
+ // Effect dispatches "inc" twice during init. The first dispatch("inc")
254
+ // enters the dispatch loop: update → state=1, view(1). The second "inc"
255
+ // is queued and processed next: update → state=2, view(2). Then the
256
+ // post-init view(state) fires with state=2.
257
+ expect(views).toEqual([1, 2, 2])
258
+ })
259
+
260
+ it("dispose from within an effect stops processing and calls done once", () => {
261
+ const done = vi.fn()
262
+ let disposeHandle!: () => void
263
+
264
+ const program: Program<string, number> = {
265
+ init: [0],
266
+ update(msg, model) {
267
+ if (msg === "stop") {
268
+ return [
269
+ model + 1,
270
+ () => disposeHandle(),
271
+ () => {
272
+ // This effect should still run — effects from the same
273
+ // update are already in the for-loop. But further dispatches
274
+ // should be dropped.
275
+ },
276
+ ]
277
+ }
278
+ if (msg === "after-stop") {
279
+ // Should never be reached
280
+ return [model + 100]
281
+ }
282
+ return [model]
283
+ },
284
+ done,
285
+ }
286
+
287
+ let dispatch!: Dispatch<string>
288
+ disposeHandle = runtime(program, (_model, d) => {
289
+ dispatch = d
290
+ })
291
+
292
+ dispatch("stop")
293
+ dispatch("after-stop") // should be a no-op — already disposed
294
+
295
+ expect(done).toHaveBeenCalledOnce()
296
+ expect(done).toHaveBeenCalledWith(1)
297
+ })
298
+
299
+ it("dispose from within a view callback stops further dispatches", () => {
300
+ const done = vi.fn()
301
+ let disposeHandle!: () => void
302
+ const views: number[] = []
303
+
304
+ const program: Program<CountMsg, number> = {
305
+ ...counterProgram(0),
306
+ done,
307
+ }
308
+
309
+ let dispatch!: Dispatch<CountMsg>
310
+ disposeHandle = runtime(program, (model, d) => {
311
+ views.push(model)
312
+ dispatch = d
313
+ if (model === 2) disposeHandle()
314
+ })
315
+
316
+ dispatch("inc")
317
+ dispatch("inc") // view(2) → dispose
318
+ dispatch("inc") // should be a no-op
319
+
320
+ expect(views).toEqual([0, 1, 2])
321
+ expect(done).toHaveBeenCalledOnce()
322
+ expect(done).toHaveBeenCalledWith(2)
323
+ })
324
+
325
+ it("all effects from one update run before any queued message is processed", () => {
326
+ const log: string[] = []
327
+
328
+ const program: Program<string, number> = {
329
+ init: [0],
330
+ update(msg, model) {
331
+ switch (msg) {
332
+ case "trigger":
333
+ return [
334
+ model,
335
+ dispatch => {
336
+ log.push("effect-A")
337
+ dispatch("queued-by-A")
338
+ },
339
+ dispatch => {
340
+ log.push("effect-B")
341
+ dispatch("queued-by-B")
342
+ },
343
+ ]
344
+ case "queued-by-A":
345
+ log.push("process-queued-by-A")
346
+ return [model]
347
+ case "queued-by-B":
348
+ log.push("process-queued-by-B")
349
+ return [model]
350
+ default:
351
+ return [model]
352
+ }
353
+ },
354
+ }
355
+
356
+ let dispatch!: Dispatch<string>
357
+ runtime(program, (_model, d) => {
358
+ dispatch = d
359
+ })
360
+
361
+ dispatch("trigger")
362
+
363
+ // Both effects run before either queued message is processed
364
+ expect(log).toEqual([
365
+ "effect-A",
366
+ "effect-B",
367
+ "process-queued-by-A",
368
+ "process-queued-by-B",
369
+ ])
370
+ })
371
+ })