@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,563 @@
|
|
|
1
|
+
// observable.test — deterministic tests for the data-effect observable runtime.
|
|
2
|
+
//
|
|
3
|
+
// All tests are pure — no I/O, no real timing (vi.useFakeTimers for timeouts).
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from "vitest"
|
|
6
|
+
import type { Program } from "../machine.js"
|
|
7
|
+
import { createObservableProgram, type StateTransition } from "../observable.js"
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Test program — a simple counter with data effects
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
type CountModel = { status: "idle"; count: number } | { status: "done" }
|
|
14
|
+
|
|
15
|
+
type CountMsg =
|
|
16
|
+
| { type: "inc" }
|
|
17
|
+
| { type: "dec" }
|
|
18
|
+
| { type: "inc-async" }
|
|
19
|
+
| { type: "set"; value: number }
|
|
20
|
+
| { type: "finish" }
|
|
21
|
+
|
|
22
|
+
type CountEffect = { type: "log"; value: number } | { type: "schedule-inc" }
|
|
23
|
+
|
|
24
|
+
function counterProgram(
|
|
25
|
+
initial = 0,
|
|
26
|
+
): Program<CountMsg, CountModel, CountEffect> {
|
|
27
|
+
return {
|
|
28
|
+
init: [{ status: "idle", count: initial }],
|
|
29
|
+
update(msg, model) {
|
|
30
|
+
if (model.status === "done") return [model]
|
|
31
|
+
|
|
32
|
+
switch (msg.type) {
|
|
33
|
+
case "inc":
|
|
34
|
+
return [
|
|
35
|
+
{ status: "idle", count: model.count + 1 },
|
|
36
|
+
{ type: "log", value: model.count + 1 },
|
|
37
|
+
]
|
|
38
|
+
case "dec":
|
|
39
|
+
return [{ status: "idle", count: model.count - 1 }]
|
|
40
|
+
case "inc-async":
|
|
41
|
+
return [model, { type: "schedule-inc" }]
|
|
42
|
+
case "set":
|
|
43
|
+
return [{ status: "idle", count: msg.value }]
|
|
44
|
+
case "finish":
|
|
45
|
+
return [{ status: "done" as const }]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
done(_model) {
|
|
49
|
+
// teardown hook — tracked via spy in tests
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function setup(initial = 0) {
|
|
55
|
+
const executor =
|
|
56
|
+
vi.fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
|
|
57
|
+
const program = counterProgram(initial)
|
|
58
|
+
const doneSpy = vi.spyOn(program, "done")
|
|
59
|
+
const handle = createObservableProgram(program, executor)
|
|
60
|
+
return { handle, executor, doneSpy }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Init + getState
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("createObservableProgram — init", () => {
|
|
68
|
+
it("initializes with the program's init model", () => {
|
|
69
|
+
const { handle } = setup(5)
|
|
70
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 5 })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("executes initial effects via executor", () => {
|
|
74
|
+
const executor = vi.fn()
|
|
75
|
+
const program: Program<string, number, string> = {
|
|
76
|
+
init: [0, "fx-a", "fx-b"],
|
|
77
|
+
update(_msg, model) {
|
|
78
|
+
return [model]
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
createObservableProgram(program, executor)
|
|
82
|
+
expect(executor).toHaveBeenCalledTimes(2)
|
|
83
|
+
expect(executor).toHaveBeenCalledWith("fx-a", expect.any(Function))
|
|
84
|
+
expect(executor).toHaveBeenCalledWith("fx-b", expect.any(Function))
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// dispatch + getState
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe("createObservableProgram — dispatch", () => {
|
|
93
|
+
it("updates model on dispatch", () => {
|
|
94
|
+
const { handle } = setup(0)
|
|
95
|
+
handle.dispatch({ type: "inc" })
|
|
96
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 1 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("calls executor for each effect produced by update", () => {
|
|
100
|
+
const { handle, executor } = setup(0)
|
|
101
|
+
handle.dispatch({ type: "inc" })
|
|
102
|
+
// One "log" effect
|
|
103
|
+
expect(executor).toHaveBeenCalledWith(
|
|
104
|
+
{ type: "log", value: 1 },
|
|
105
|
+
expect.any(Function),
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("supports multiple sequential dispatches", () => {
|
|
110
|
+
const { handle } = setup(0)
|
|
111
|
+
handle.dispatch({ type: "inc" })
|
|
112
|
+
handle.dispatch({ type: "inc" })
|
|
113
|
+
handle.dispatch({ type: "dec" })
|
|
114
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 1 })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("ignores dispatch after dispose", () => {
|
|
118
|
+
const { handle } = setup(0)
|
|
119
|
+
handle.dispose()
|
|
120
|
+
handle.dispatch({ type: "inc" })
|
|
121
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 0 })
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Re-entrant dispatch
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
describe("createObservableProgram — re-entrant dispatch", () => {
|
|
130
|
+
it("queues re-entrant messages and processes them after current cycle", () => {
|
|
131
|
+
const executor = vi
|
|
132
|
+
.fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
|
|
133
|
+
.mockImplementation((effect, dispatch) => {
|
|
134
|
+
if (effect.type === "schedule-inc") {
|
|
135
|
+
dispatch({ type: "inc" })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const program = counterProgram(0)
|
|
140
|
+
const handle = createObservableProgram(program, executor)
|
|
141
|
+
|
|
142
|
+
// inc-async produces a schedule-inc effect, which re-entrantly dispatches inc
|
|
143
|
+
handle.dispatch({ type: "inc-async" })
|
|
144
|
+
|
|
145
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 1 })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("delivers transitions for both outer and re-entrant dispatch", () => {
|
|
149
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
150
|
+
const executor = vi
|
|
151
|
+
.fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
|
|
152
|
+
.mockImplementation((effect, dispatch) => {
|
|
153
|
+
if (effect.type === "schedule-inc") {
|
|
154
|
+
dispatch({ type: "inc" })
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const program = counterProgram(0)
|
|
159
|
+
const handle = createObservableProgram(program, executor)
|
|
160
|
+
|
|
161
|
+
handle.subscribeToTransitions(t => transitions.push(t))
|
|
162
|
+
|
|
163
|
+
// set to 5 (no effect), then inc-async triggers re-entrant inc
|
|
164
|
+
handle.dispatch({ type: "set", value: 5 })
|
|
165
|
+
handle.dispatch({ type: "inc-async" })
|
|
166
|
+
|
|
167
|
+
// set: idle/0 → idle/5, inc-async: no model change (same ref), inc: idle/5 → idle/6
|
|
168
|
+
// inc-async produces no model change (returns same model), so no transition for it
|
|
169
|
+
expect(transitions).toHaveLength(2)
|
|
170
|
+
expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
|
|
171
|
+
expect(transitions[0]?.to).toEqual({ status: "idle", count: 5 })
|
|
172
|
+
expect(transitions[1]?.from).toEqual({ status: "idle", count: 5 })
|
|
173
|
+
expect(transitions[1]?.to).toEqual({ status: "idle", count: 6 })
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// subscribeToTransitions
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("createObservableProgram — subscribeToTransitions", () => {
|
|
182
|
+
it("fires listener on state change with from, to, timestamp", () => {
|
|
183
|
+
const { handle } = setup(0)
|
|
184
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
185
|
+
|
|
186
|
+
handle.subscribeToTransitions(t => transitions.push(t))
|
|
187
|
+
handle.dispatch({ type: "inc" })
|
|
188
|
+
|
|
189
|
+
expect(transitions).toHaveLength(1)
|
|
190
|
+
expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
|
|
191
|
+
expect(transitions[0]?.to).toEqual({ status: "idle", count: 1 })
|
|
192
|
+
expect(typeof transitions[0]?.timestamp).toBe("number")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("does not fire when model reference is unchanged", () => {
|
|
196
|
+
const { handle } = setup(0)
|
|
197
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
198
|
+
|
|
199
|
+
handle.subscribeToTransitions(t => transitions.push(t))
|
|
200
|
+
// inc-async returns the same model object (no state change) before the
|
|
201
|
+
// re-entrant dispatch — but we use a non-re-entrant executor here
|
|
202
|
+
const executor2 = vi.fn()
|
|
203
|
+
const program2: Program<string, { v: number }, never> = {
|
|
204
|
+
init: [{ v: 1 }],
|
|
205
|
+
update(_msg, model) {
|
|
206
|
+
return [model] // same reference
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
const handle2 = createObservableProgram(program2, executor2)
|
|
210
|
+
const transitions2: Array<StateTransition<{ v: number }>> = []
|
|
211
|
+
handle2.subscribeToTransitions(t => transitions2.push(t))
|
|
212
|
+
handle2.dispatch("noop")
|
|
213
|
+
|
|
214
|
+
expect(transitions2).toHaveLength(0)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("supports multiple listeners", () => {
|
|
218
|
+
const { handle } = setup(0)
|
|
219
|
+
const a: Array<StateTransition<CountModel>> = []
|
|
220
|
+
const b: Array<StateTransition<CountModel>> = []
|
|
221
|
+
|
|
222
|
+
handle.subscribeToTransitions(t => a.push(t))
|
|
223
|
+
handle.subscribeToTransitions(t => b.push(t))
|
|
224
|
+
|
|
225
|
+
handle.dispatch({ type: "inc" })
|
|
226
|
+
|
|
227
|
+
expect(a).toHaveLength(1)
|
|
228
|
+
expect(b).toHaveLength(1)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("returns an unsubscribe function", () => {
|
|
232
|
+
const { handle } = setup(0)
|
|
233
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
234
|
+
|
|
235
|
+
const unsub = handle.subscribeToTransitions(t => transitions.push(t))
|
|
236
|
+
handle.dispatch({ type: "inc" })
|
|
237
|
+
expect(transitions).toHaveLength(1)
|
|
238
|
+
|
|
239
|
+
unsub()
|
|
240
|
+
handle.dispatch({ type: "inc" })
|
|
241
|
+
expect(transitions).toHaveLength(1) // no new transition
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("swallows errors in listeners without breaking dispatch", () => {
|
|
245
|
+
const { handle } = setup(0)
|
|
246
|
+
const good: Array<StateTransition<CountModel>> = []
|
|
247
|
+
|
|
248
|
+
handle.subscribeToTransitions(() => {
|
|
249
|
+
throw new Error("boom")
|
|
250
|
+
})
|
|
251
|
+
handle.subscribeToTransitions(t => good.push(t))
|
|
252
|
+
|
|
253
|
+
// Should not throw
|
|
254
|
+
handle.dispatch({ type: "inc" })
|
|
255
|
+
|
|
256
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 1 })
|
|
257
|
+
expect(good).toHaveLength(1)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// waitForState
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe("createObservableProgram — waitForState", () => {
|
|
266
|
+
it("resolves immediately if predicate already matches", async () => {
|
|
267
|
+
const { handle } = setup(5)
|
|
268
|
+
const state = await handle.waitForState(
|
|
269
|
+
s => s.status === "idle" && s.count === 5,
|
|
270
|
+
)
|
|
271
|
+
expect(state).toEqual({ status: "idle", count: 5 })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("resolves when a future transition matches", async () => {
|
|
275
|
+
const { handle } = setup(0)
|
|
276
|
+
const promise = handle.waitForState(
|
|
277
|
+
s => s.status === "idle" && s.count === 3,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
handle.dispatch({ type: "inc" })
|
|
281
|
+
handle.dispatch({ type: "inc" })
|
|
282
|
+
handle.dispatch({ type: "inc" })
|
|
283
|
+
|
|
284
|
+
const state = await promise
|
|
285
|
+
expect(state).toEqual({ status: "idle", count: 3 })
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("rejects on timeout", async () => {
|
|
289
|
+
vi.useFakeTimers()
|
|
290
|
+
try {
|
|
291
|
+
const { handle } = setup(0)
|
|
292
|
+
const promise = handle.waitForState(s => s.status === "done", {
|
|
293
|
+
timeoutMs: 100,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
vi.advanceTimersByTime(100)
|
|
297
|
+
|
|
298
|
+
await expect(promise).rejects.toThrow(
|
|
299
|
+
"Timeout waiting for state after 100ms",
|
|
300
|
+
)
|
|
301
|
+
} finally {
|
|
302
|
+
vi.useRealTimers()
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("cleans up timeout when resolved before timeout", async () => {
|
|
307
|
+
vi.useFakeTimers()
|
|
308
|
+
try {
|
|
309
|
+
const { handle } = setup(0)
|
|
310
|
+
const promise = handle.waitForState(
|
|
311
|
+
s => s.status === "idle" && s.count === 1,
|
|
312
|
+
{ timeoutMs: 5000 },
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
handle.dispatch({ type: "inc" })
|
|
316
|
+
|
|
317
|
+
const state = await promise
|
|
318
|
+
expect(state).toEqual({ status: "idle", count: 1 })
|
|
319
|
+
|
|
320
|
+
// Advancing timers should not cause a rejection (timeout was cleared)
|
|
321
|
+
vi.advanceTimersByTime(10000)
|
|
322
|
+
} finally {
|
|
323
|
+
vi.useRealTimers()
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("waits indefinitely when no timeout is specified", async () => {
|
|
328
|
+
const { handle } = setup(0)
|
|
329
|
+
const promise = handle.waitForState(
|
|
330
|
+
s => s.status === "idle" && s.count === 1,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
// Dispatch later
|
|
334
|
+
handle.dispatch({ type: "inc" })
|
|
335
|
+
|
|
336
|
+
const state = await promise
|
|
337
|
+
expect(state).toEqual({ status: "idle", count: 1 })
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// waitForStatus
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe("createObservableProgram — waitForStatus", () => {
|
|
346
|
+
it("resolves immediately when status already matches", async () => {
|
|
347
|
+
const { handle } = setup(0)
|
|
348
|
+
const state = await handle.waitForStatus("idle")
|
|
349
|
+
expect(state).toEqual({ status: "idle", count: 0 })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it("resolves when status changes to match", async () => {
|
|
353
|
+
const { handle } = setup(0)
|
|
354
|
+
const promise = handle.waitForStatus("done")
|
|
355
|
+
|
|
356
|
+
handle.dispatch({ type: "finish" })
|
|
357
|
+
|
|
358
|
+
const state = await promise
|
|
359
|
+
expect(state).toEqual({ status: "done" })
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it("rejects on timeout", async () => {
|
|
363
|
+
vi.useFakeTimers()
|
|
364
|
+
try {
|
|
365
|
+
const { handle } = setup(0)
|
|
366
|
+
const promise = handle.waitForStatus("done", { timeoutMs: 50 })
|
|
367
|
+
|
|
368
|
+
vi.advanceTimersByTime(50)
|
|
369
|
+
|
|
370
|
+
await expect(promise).rejects.toThrow(
|
|
371
|
+
"Timeout waiting for state after 50ms",
|
|
372
|
+
)
|
|
373
|
+
} finally {
|
|
374
|
+
vi.useRealTimers()
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// dispose
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
describe("createObservableProgram — dispose", () => {
|
|
384
|
+
it("calls program.done with the final state", () => {
|
|
385
|
+
const { handle, doneSpy } = setup(7)
|
|
386
|
+
handle.dispose()
|
|
387
|
+
expect(doneSpy).toHaveBeenCalledWith({ status: "idle", count: 7 })
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it("stops dispatch after dispose", () => {
|
|
391
|
+
const { handle, executor } = setup(0)
|
|
392
|
+
handle.dispose()
|
|
393
|
+
handle.dispatch({ type: "inc" })
|
|
394
|
+
expect(handle.getState()).toEqual({ status: "idle", count: 0 })
|
|
395
|
+
// executor should only have been called for init effects (none in this case)
|
|
396
|
+
expect(executor).not.toHaveBeenCalled()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("is idempotent — second call does nothing", () => {
|
|
400
|
+
const { handle, doneSpy } = setup(0)
|
|
401
|
+
handle.dispose()
|
|
402
|
+
handle.dispose()
|
|
403
|
+
expect(doneSpy).toHaveBeenCalledTimes(1)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it("works with programs that have no done hook", () => {
|
|
407
|
+
const executor = vi.fn()
|
|
408
|
+
const program: Program<string, number, never> = {
|
|
409
|
+
init: [0],
|
|
410
|
+
update(_msg, model) {
|
|
411
|
+
return [model]
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
const handle = createObservableProgram(program, executor)
|
|
415
|
+
// Should not throw
|
|
416
|
+
expect(() => handle.dispose()).not.toThrow()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Transition delivery is synchronous
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
describe("createObservableProgram — synchronous transitions", () => {
|
|
425
|
+
it("delivers transitions synchronously within dispatch", () => {
|
|
426
|
+
const { handle } = setup(0)
|
|
427
|
+
let stateSeenInListener: CountModel | undefined
|
|
428
|
+
|
|
429
|
+
handle.subscribeToTransitions(t => {
|
|
430
|
+
stateSeenInListener = handle.getState()
|
|
431
|
+
// getState() should already reflect the new state
|
|
432
|
+
expect(t.to).toEqual(stateSeenInListener)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
handle.dispatch({ type: "inc" })
|
|
436
|
+
expect(stateSeenInListener).toEqual({ status: "idle", count: 1 })
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Transition listeners fire before effects execute
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
describe("createObservableProgram — listeners fire before effects", () => {
|
|
445
|
+
it("transition listener sees state change before effect executor runs", () => {
|
|
446
|
+
const order: string[] = []
|
|
447
|
+
|
|
448
|
+
const executor = vi
|
|
449
|
+
.fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
|
|
450
|
+
.mockImplementation(effect => {
|
|
451
|
+
order.push(`effect:${effect.type}`)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const program = counterProgram(0)
|
|
455
|
+
const handle = createObservableProgram(program, executor)
|
|
456
|
+
|
|
457
|
+
handle.subscribeToTransitions(() => {
|
|
458
|
+
order.push("transition")
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
handle.dispatch({ type: "inc" })
|
|
462
|
+
|
|
463
|
+
// The transition listener must fire before the "log" effect executes.
|
|
464
|
+
// This ordering is critical: lifecycle callbacks (onStateChange, onReconnected)
|
|
465
|
+
// depend on seeing the transition before I/O effects run.
|
|
466
|
+
expect(order).toEqual(["transition", "effect:log"])
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// Dispose stops transition delivery
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
describe("createObservableProgram — dispose stops transitions", () => {
|
|
475
|
+
it("transition listeners do not fire after dispose", () => {
|
|
476
|
+
const { handle } = setup(0)
|
|
477
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
478
|
+
|
|
479
|
+
handle.subscribeToTransitions(t => transitions.push(t))
|
|
480
|
+
handle.dispatch({ type: "inc" })
|
|
481
|
+
expect(transitions).toHaveLength(1)
|
|
482
|
+
|
|
483
|
+
handle.dispose()
|
|
484
|
+
handle.dispatch({ type: "inc" })
|
|
485
|
+
|
|
486
|
+
// No new transition — dispatch is a no-op after dispose
|
|
487
|
+
expect(transitions).toHaveLength(1)
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Multiple rapid dispatches preserve transition ordering
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
describe("createObservableProgram — transition ordering under rapid dispatch", () => {
|
|
496
|
+
it("delivers transitions in dispatch order across multiple synchronous dispatches", () => {
|
|
497
|
+
const { handle } = setup(0)
|
|
498
|
+
const statuses: Array<{ from: string; to: string }> = []
|
|
499
|
+
|
|
500
|
+
handle.subscribeToTransitions(t => {
|
|
501
|
+
const from =
|
|
502
|
+
t.from.status === "idle"
|
|
503
|
+
? `idle/${(t.from as { count: number }).count}`
|
|
504
|
+
: t.from.status
|
|
505
|
+
const to =
|
|
506
|
+
t.to.status === "idle"
|
|
507
|
+
? `idle/${(t.to as { count: number }).count}`
|
|
508
|
+
: t.to.status
|
|
509
|
+
statuses.push({ from, to })
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Three rapid dispatches — transitions must arrive in order
|
|
513
|
+
handle.dispatch({ type: "inc" })
|
|
514
|
+
handle.dispatch({ type: "inc" })
|
|
515
|
+
handle.dispatch({ type: "finish" })
|
|
516
|
+
|
|
517
|
+
expect(statuses).toEqual([
|
|
518
|
+
{ from: "idle/0", to: "idle/1" },
|
|
519
|
+
{ from: "idle/1", to: "idle/2" },
|
|
520
|
+
{ from: "idle/2", to: "done" },
|
|
521
|
+
])
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Full lifecycle
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
describe("createObservableProgram — full lifecycle", () => {
|
|
530
|
+
it("init → dispatch → observe → dispose", () => {
|
|
531
|
+
const { handle, executor, doneSpy } = setup(0)
|
|
532
|
+
const transitions: Array<StateTransition<CountModel>> = []
|
|
533
|
+
|
|
534
|
+
handle.subscribeToTransitions(t => transitions.push(t))
|
|
535
|
+
|
|
536
|
+
// Dispatch several messages
|
|
537
|
+
handle.dispatch({ type: "inc" })
|
|
538
|
+
handle.dispatch({ type: "inc" })
|
|
539
|
+
handle.dispatch({ type: "set", value: 10 })
|
|
540
|
+
handle.dispatch({ type: "finish" })
|
|
541
|
+
|
|
542
|
+
expect(handle.getState()).toEqual({ status: "done" })
|
|
543
|
+
expect(transitions).toHaveLength(4)
|
|
544
|
+
expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
|
|
545
|
+
expect(transitions[0]?.to).toEqual({ status: "idle", count: 1 })
|
|
546
|
+
expect(transitions[3]?.to).toEqual({ status: "done" })
|
|
547
|
+
|
|
548
|
+
// Effects were called for each inc (2 incs produce 2 log effects)
|
|
549
|
+
const logCalls = executor.mock.calls.filter(
|
|
550
|
+
([fx]) => (fx as CountEffect).type === "log",
|
|
551
|
+
)
|
|
552
|
+
expect(logCalls).toHaveLength(2)
|
|
553
|
+
|
|
554
|
+
// Dispose
|
|
555
|
+
handle.dispose()
|
|
556
|
+
expect(doneSpy).toHaveBeenCalledWith({ status: "done" })
|
|
557
|
+
|
|
558
|
+
// No further dispatch after dispose
|
|
559
|
+
handle.dispatch({ type: "inc" })
|
|
560
|
+
expect(handle.getState()).toEqual({ status: "done" })
|
|
561
|
+
expect(transitions).toHaveLength(4) // unchanged
|
|
562
|
+
})
|
|
563
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @kyneta/machine — universal Mealy machine algebra with effect interpreter.
|
|
2
|
+
//
|
|
3
|
+
// Program<Msg, Model, Fx> is the pure algebra: init + update + done.
|
|
4
|
+
// runtime() is the interpreter: wires dispatch to update, executes effects.
|
|
5
|
+
// Effects are continuations (dispatch) => void — opaque to the runtime.
|
|
6
|
+
//
|
|
7
|
+
// createObservableProgram() is the data-effect counterpart to runtime().
|
|
8
|
+
// It accepts a custom executor for data effects and provides state
|
|
9
|
+
// observation: subscribeToTransitions, waitForState, waitForStatus.
|
|
10
|
+
|
|
11
|
+
export type { Dispatch, Disposer, Effect, Program } from "./machine.js"
|
|
12
|
+
export { runtime } from "./machine.js"
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
ObservableHandle,
|
|
16
|
+
StateTransition,
|
|
17
|
+
TransitionListener,
|
|
18
|
+
} from "./observable.js"
|
|
19
|
+
export { createObservableProgram } from "./observable.js"
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Dispatch a message into a running program. */
|
|
2
|
+
export type Dispatch<Msg> = (msg: Msg) => void
|
|
3
|
+
|
|
4
|
+
/** An effect is a continuation that may dispatch messages. */
|
|
5
|
+
export type Effect<Msg> = (dispatch: Dispatch<Msg>) => void
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A Mealy machine — pure state transitions with effect outputs.
|
|
9
|
+
*
|
|
10
|
+
* `Fx` defaults to `Effect<Msg>` (closure effects) but can be any
|
|
11
|
+
* data type for programs with custom effect executors.
|
|
12
|
+
*
|
|
13
|
+
* - `init`: initial state and zero or more effects to execute at startup.
|
|
14
|
+
* - `update`: pure transition — given a message and the current state,
|
|
15
|
+
* return the new state and zero or more effects.
|
|
16
|
+
* - `done`: optional teardown hook, called with the final state when
|
|
17
|
+
* the runtime is disposed.
|
|
18
|
+
*/
|
|
19
|
+
export type Program<Msg, Model, Fx = Effect<Msg>> = {
|
|
20
|
+
init: [Model, ...Fx[]]
|
|
21
|
+
update(msg: Msg, model: Model): [Model, ...Fx[]]
|
|
22
|
+
done?(model: Model): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Dispose a running program — stops message processing and calls `done`. */
|
|
26
|
+
export type Disposer = () => void
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a program whose effects are `Effect<Msg>` closures.
|
|
30
|
+
*
|
|
31
|
+
* The runtime:
|
|
32
|
+
* 1. Extracts `[model, ...effects]` from `program.init`.
|
|
33
|
+
* 2. Executes each initial effect with `dispatch`.
|
|
34
|
+
* 3. Calls `view(model, dispatch)` if provided.
|
|
35
|
+
* 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,
|
|
36
|
+
* executes effects, calls `view`.
|
|
37
|
+
* 5. Returns a `Disposer` that stops dispatch and calls `program.done`.
|
|
38
|
+
*
|
|
39
|
+
* Effects are executed synchronously in order. An effect may call
|
|
40
|
+
* `dispatch` re-entrantly — the runtime processes re-entrant messages
|
|
41
|
+
* after the current dispatch cycle completes (queue-based).
|
|
42
|
+
*/
|
|
43
|
+
export function runtime<Msg, Model>(
|
|
44
|
+
program: Program<Msg, Model>,
|
|
45
|
+
view?: (model: Model, dispatch: Dispatch<Msg>) => void,
|
|
46
|
+
): Disposer {
|
|
47
|
+
let state: Model
|
|
48
|
+
let isRunning = true
|
|
49
|
+
const pending: Msg[] = []
|
|
50
|
+
let isDispatching = false
|
|
51
|
+
|
|
52
|
+
function dispatch(msg: Msg): void {
|
|
53
|
+
if (!isRunning) return
|
|
54
|
+
|
|
55
|
+
pending.push(msg)
|
|
56
|
+
if (isDispatching) return
|
|
57
|
+
|
|
58
|
+
isDispatching = true
|
|
59
|
+
try {
|
|
60
|
+
while (pending.length > 0) {
|
|
61
|
+
const next = pending.shift()!
|
|
62
|
+
const [newModel, ...effects] = program.update(next, state)
|
|
63
|
+
state = newModel
|
|
64
|
+
for (const effect of effects) {
|
|
65
|
+
effect(dispatch)
|
|
66
|
+
}
|
|
67
|
+
if (view) view(state, dispatch)
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
isDispatching = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Initialize
|
|
75
|
+
const [initialModel, ...initialEffects] = program.init
|
|
76
|
+
state = initialModel
|
|
77
|
+
for (const effect of initialEffects) {
|
|
78
|
+
effect(dispatch)
|
|
79
|
+
}
|
|
80
|
+
if (view) view(state, dispatch)
|
|
81
|
+
|
|
82
|
+
// Return disposer
|
|
83
|
+
return () => {
|
|
84
|
+
if (!isRunning) return
|
|
85
|
+
isRunning = false
|
|
86
|
+
program.done?.(state)
|
|
87
|
+
}
|
|
88
|
+
}
|