@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.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +143 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/__tests__/machine.test.ts +371 -0
- package/src/__tests__/observable.test.ts +563 -0
- package/src/index.ts +19 -0
- package/src/machine.ts +88 -0
- package/src/observable.ts +270 -0
|
@@ -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
|
+
})
|