@praxisjs/fsm 2.1.7 → 2.2.1

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.
@@ -1,219 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
-
3
- import { Transition, StateMachine } from "../decorators";
4
- import { createMachine } from "../machine";
5
- import type { Machine } from "../machine";
6
-
7
- const TOGGLE_DEF = {
8
- initial: "off" as const,
9
- states: {
10
- off: { on: { toggle: "on" as const } },
11
- on: { on: { toggle: "off" as const } },
12
- },
13
- } as const;
14
-
15
- // Helper to simulate TC39 field decorator context
16
- function fieldCtx(name: string) {
17
- const initializers: Array<(this: object) => void> = [];
18
- return {
19
- ctx: {
20
- name,
21
- kind: "field" as const,
22
- addInitializer(fn: (this: object) => void) { initializers.push(fn); },
23
- } as ClassFieldDecoratorContext,
24
- run(instance: object) {
25
- initializers.forEach((fn) => { fn.call(instance); });
26
- },
27
- };
28
- }
29
-
30
- // Helper to simulate TC39 method decorator context
31
- function mockMethodContext(name: string) {
32
- const initializers: Array<(this: object) => void> = [];
33
- return {
34
- name,
35
- kind: "method" as const,
36
- addInitializer(fn: (this: object) => void) { initializers.push(fn); },
37
- runInitializers(instance: object) {
38
- initializers.forEach((fn) => { fn.call(instance); });
39
- },
40
- };
41
- }
42
-
43
- // ── StateMachine ──────────────────────────────────────────────────────────────
44
-
45
- describe("StateMachine decorator", () => {
46
- it("injects a machine via field decorator", () => {
47
- const { ctx, run } = fieldCtx("machine");
48
- StateMachine(TOGGLE_DEF)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
49
-
50
- const instance: Record<string, unknown> = {};
51
- run(instance);
52
- expect(instance.machine).toBeDefined();
53
- expect((instance.machine as Machine<string, string>).state()).toBe("off");
54
- });
55
-
56
- it("returns a separate machine per instance", () => {
57
- const { ctx, run } = fieldCtx("machine");
58
- StateMachine(TOGGLE_DEF)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
59
-
60
- const a: Record<string, unknown> = {};
61
- const b: Record<string, unknown> = {};
62
- run(a);
63
- run(b);
64
- (a.machine as Machine<string, string>).send("toggle");
65
- expect((a.machine as Machine<string, string>).state()).toBe("on");
66
- expect((b.machine as Machine<string, string>).state()).toBe("off");
67
- });
68
-
69
- it("supports a custom field name", () => {
70
- const { ctx, run } = fieldCtx("fsm");
71
- StateMachine(TOGGLE_DEF)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
72
-
73
- const instance: Record<string, unknown> = {};
74
- run(instance);
75
- expect(instance.fsm).toBeDefined();
76
- expect((instance.fsm as Machine<string, string>).state()).toBe("off");
77
- });
78
-
79
- it("returns the same machine instance on repeated access", () => {
80
- const { ctx, run } = fieldCtx("machine");
81
- StateMachine(TOGGLE_DEF)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
82
-
83
- const instance: Record<string, unknown> = {};
84
- run(instance);
85
- expect(instance.machine).toBe(instance.machine);
86
- });
87
-
88
- it("setter is a no-op — assigning to the field does not replace the machine", () => {
89
- const { ctx, run } = fieldCtx("machine");
90
- StateMachine(TOGGLE_DEF)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
91
-
92
- const instance: Record<string, unknown> = {};
93
- run(instance);
94
- const original = instance.machine;
95
- instance.machine = "overwrite-attempt"; // triggers set(): void {} (no-op)
96
- expect(instance.machine).toBe(original);
97
- });
98
-
99
- it("accepts a factory function — machine created with instance as this", () => {
100
- const { ctx, run } = fieldCtx("machine");
101
- const instances: object[] = [];
102
- const factory = function (this: object) {
103
- instances.push(this);
104
- return TOGGLE_DEF;
105
- };
106
- StateMachine(factory)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"off"|"on","toggle">>);
107
-
108
- const instance: Record<string, unknown> = {};
109
- run(instance);
110
- // Access triggers lazy creation
111
- const m = instance.machine as Machine<string, string>;
112
- expect(m.state()).toBe("off");
113
- expect(instances).toHaveLength(1);
114
- expect(instances[0]).toBe(instance);
115
- });
116
-
117
- it("factory receives correct instance — guard can close over instance state", () => {
118
- const { ctx, run } = fieldCtx("machine");
119
- let allow = false;
120
-
121
- const factory = function (this: object) {
122
- return {
123
- initial: "a" as const,
124
- states: {
125
- a: { on: { NEXT: { target: "b" as const, guard: () => allow } } },
126
- b: {},
127
- },
128
- };
129
- };
130
- StateMachine(factory)(undefined, ctx as ClassFieldDecoratorContext<object, Machine<"a"|"b","NEXT">>);
131
-
132
- const instance: Record<string, unknown> = {};
133
- run(instance);
134
- const m = instance.machine as Machine<string, string>;
135
-
136
- expect(m.send("NEXT")).toBe(false);
137
- allow = true;
138
- expect(m.send("NEXT")).toBe(true);
139
- expect(m.state()).toBe("b");
140
- });
141
- });
142
-
143
- // ── Transition ────────────────────────────────────────────────────────────────
144
-
145
- describe("Transition decorator", () => {
146
- it("calls the original method when send succeeds", () => {
147
- const original = vi.fn(() => "ran");
148
- const ctx = mockMethodContext("doTransition");
149
- Transition("machine", "toggle")(original, ctx as unknown as ClassMethodDecoratorContext);
150
-
151
- const instance = { machine: createMachine(TOGGLE_DEF) } as Record<string, unknown>;
152
- ctx.runInitializers(instance);
153
- const method = (instance as Record<string, () => unknown>).doTransition;
154
-
155
- method();
156
- expect(original).toHaveBeenCalled();
157
- });
158
-
159
- it("returns without calling method when machine is missing", () => {
160
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
161
- const original = vi.fn();
162
- const ctx = mockMethodContext("doTransition");
163
- Transition("machine", "toggle")(original, ctx as unknown as ClassMethodDecoratorContext);
164
-
165
- const instance = {} as Record<string, unknown>;
166
- ctx.runInitializers(instance);
167
- const method = (instance as Record<string, () => unknown>).doTransition;
168
-
169
- method();
170
- expect(original).not.toHaveBeenCalled();
171
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Transition]"));
172
- warn.mockRestore();
173
- });
174
-
175
- it("does not call method when transition is invalid", () => {
176
- const original = vi.fn();
177
- const ctx = mockMethodContext("doTransition");
178
- Transition("machine", "toggle")(original, ctx as unknown as ClassMethodDecoratorContext);
179
-
180
- const instance = {
181
- machine: createMachine({ initial: "on" as const, states: { on: {} } }),
182
- } as Record<string, unknown>;
183
- ctx.runInitializers(instance);
184
- const method = (instance as Record<string, () => unknown>).doTransition;
185
-
186
- method();
187
- expect(original).not.toHaveBeenCalled();
188
- });
189
-
190
- it("passes through arguments to the original method", () => {
191
- const original = vi.fn((_x: unknown) => undefined);
192
- const ctx = mockMethodContext("doTransition");
193
- Transition("machine", "toggle")(original, ctx as unknown as ClassMethodDecoratorContext);
194
-
195
- const instance = { machine: createMachine(TOGGLE_DEF) } as Record<string, unknown>;
196
- ctx.runInitializers(instance);
197
- const method = (instance as Record<string, (...a: unknown[]) => unknown>).doTransition;
198
-
199
- method(42);
200
- expect(original).toHaveBeenCalledWith(42);
201
- });
202
-
203
- it("@Transition on method without @StateMachine — warns and returns undefined", () => {
204
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
205
- const original = vi.fn(() => "ran");
206
- const ctx = mockMethodContext("doTransition");
207
- Transition("machine", "toggle")(original, ctx as unknown as ClassMethodDecoratorContext);
208
-
209
- const instance = {} as Record<string, unknown>;
210
- ctx.runInitializers(instance);
211
- const method = (instance as Record<string, () => unknown>).doTransition;
212
-
213
- const result = method();
214
- expect(result).toBeUndefined();
215
- expect(original).not.toHaveBeenCalled();
216
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Transition]"));
217
- warn.mockRestore();
218
- });
219
- });
@@ -1,433 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
-
3
- import { createMachine } from "../machine";
4
-
5
- type TrafficState = "red" | "green" | "yellow";
6
- type TrafficEvent = "GO" | "SLOW" | "STOP";
7
-
8
- function trafficLight() {
9
- return createMachine<TrafficState, TrafficEvent>({
10
- initial: "red",
11
- states: {
12
- red: { on: { GO: "green" } },
13
- green: { on: { SLOW: "yellow" } },
14
- yellow: { on: { STOP: "red" } },
15
- },
16
- });
17
- }
18
-
19
- describe("createMachine", () => {
20
- it("initializes in the given state", () => {
21
- const m = trafficLight();
22
- expect(m.state()).toBe("red");
23
- });
24
-
25
- it("transitions on a valid event", () => {
26
- const m = trafficLight();
27
- m.send("GO");
28
- expect(m.state()).toBe("green");
29
- });
30
-
31
- it("returns true from send() on valid transition", () => {
32
- const m = trafficLight();
33
- expect(m.send("GO")).toBe(true);
34
- });
35
-
36
- it("returns false from send() on invalid event", () => {
37
- const m = trafficLight();
38
- expect(m.send("SLOW")).toBe(false); // SLOW is not valid in red
39
- });
40
-
41
- it("does not change state on invalid event", () => {
42
- const m = trafficLight();
43
- m.send("STOP"); // invalid in red
44
- expect(m.state()).toBe("red");
45
- });
46
-
47
- it("can() returns true for valid events", () => {
48
- const m = trafficLight();
49
- expect(m.can("GO")).toBe(true);
50
- });
51
-
52
- it("can() returns false for invalid events", () => {
53
- const m = trafficLight();
54
- expect(m.can("SLOW")).toBe(false);
55
- });
56
-
57
- it("is() checks current state", () => {
58
- const m = trafficLight();
59
- expect(m.is("red")).toBe(true);
60
- expect(m.is("green")).toBe(false);
61
- });
62
-
63
- it("tracks history of transitions", () => {
64
- const m = trafficLight();
65
- m.send("GO");
66
- m.send("SLOW");
67
- const h = m.history();
68
- expect(h).toHaveLength(2);
69
- expect(h[0]).toEqual({ from: "red", event: "GO", to: "green" });
70
- expect(h[1]).toEqual({ from: "green", event: "SLOW", to: "yellow" });
71
- });
72
-
73
- it("resets to initial state and clears history", () => {
74
- const m = trafficLight();
75
- m.send("GO");
76
- m.send("SLOW");
77
- m.reset();
78
- expect(m.state()).toBe("red");
79
- expect(m.history()).toHaveLength(0);
80
- });
81
-
82
- it("calls onEnter on entering a state", () => {
83
- const onEnter = vi.fn();
84
- const m = createMachine<"idle" | "active", "START">({
85
- initial: "idle",
86
- states: {
87
- idle: { on: { START: "active" } },
88
- active: { onEnter },
89
- },
90
- });
91
- m.send("START");
92
- expect(onEnter).toHaveBeenCalledOnce();
93
- });
94
-
95
- it("calls onExit on leaving a state", () => {
96
- const onExit = vi.fn();
97
- const m = createMachine<"idle" | "active", "START">({
98
- initial: "idle",
99
- states: {
100
- idle: { on: { START: "active" }, onExit },
101
- active: {},
102
- },
103
- });
104
- m.send("START");
105
- expect(onExit).toHaveBeenCalledOnce();
106
- });
107
-
108
- it("onExit throwing prevents the state change and keeps history clean", () => {
109
- const m = createMachine<"idle" | "active", "START">({
110
- initial: "idle",
111
- states: {
112
- idle: {
113
- on: { START: "active" },
114
- onExit: () => { throw new Error("exit failed"); },
115
- },
116
- active: {},
117
- },
118
- });
119
- expect(() => m.send("START")).toThrow("exit failed");
120
- expect(m.state()).toBe("idle");
121
- expect(m.history()).toHaveLength(0);
122
- });
123
-
124
- it("onEnter throwing after state change leaves machine in the new state", () => {
125
- const m = createMachine<"idle" | "active", "START">({
126
- initial: "idle",
127
- states: {
128
- idle: { on: { START: "active" } },
129
- active: { onEnter: () => { throw new Error("enter failed"); } },
130
- },
131
- });
132
- expect(() => m.send("START")).toThrow("enter failed");
133
- // State was already committed before onEnter ran
134
- expect(m.state()).toBe("active");
135
- expect(m.history()).toHaveLength(1);
136
- });
137
-
138
- it("reset() calls onEnter of the initial state", () => {
139
- const onEnter = vi.fn();
140
- const m = createMachine<"idle" | "active", "START">({
141
- initial: "idle",
142
- states: {
143
- idle: { on: { START: "active" }, onEnter },
144
- active: {},
145
- },
146
- });
147
- m.send("START");
148
- m.reset();
149
- expect(onEnter).toHaveBeenCalledOnce();
150
- });
151
-
152
- it("calls onTransition callback", () => {
153
- const onTransition = vi.fn();
154
- const m = createMachine<"a" | "b", "NEXT">({
155
- initial: "a",
156
- states: {
157
- a: { on: { NEXT: "b" } },
158
- b: {},
159
- },
160
- onTransition,
161
- });
162
- m.send("NEXT");
163
- expect(onTransition).toHaveBeenCalledWith("a", "NEXT", "b");
164
- });
165
-
166
- it("state with on:{} — can() returns false for all events", () => {
167
- const m = createMachine<"idle", "START" | "STOP">({
168
- initial: "idle",
169
- states: {
170
- idle: { on: {} },
171
- },
172
- });
173
- expect(m.can("START")).toBe(false);
174
- expect(m.can("STOP")).toBe(false);
175
- });
176
-
177
- it("circular transition A→B→A — history contains correct entries", () => {
178
- const m = createMachine<"a" | "b", "NEXT">({
179
- initial: "a",
180
- states: {
181
- a: { on: { NEXT: "b" } },
182
- b: { on: { NEXT: "a" } },
183
- },
184
- });
185
- m.send("NEXT"); // a→b
186
- m.send("NEXT"); // b→a
187
- const h = m.history();
188
- expect(h).toHaveLength(2);
189
- expect(h[0]).toEqual({ from: "a", event: "NEXT", to: "b" });
190
- expect(h[1]).toEqual({ from: "b", event: "NEXT", to: "a" });
191
- });
192
-
193
- it("send() with undefined event — does not crash, returns false", () => {
194
- const m = trafficLight();
195
- // Cast to bypass TypeScript — runtime guard should handle it gracefully
196
- expect(m.send(undefined as unknown as TrafficEvent)).toBe(false);
197
- });
198
-
199
- it("onEnter callback is called AFTER the state has changed", () => {
200
- let stateAtOnEnter: string | undefined;
201
- const m = createMachine<"idle" | "active", "START">({
202
- initial: "idle",
203
- states: {
204
- idle: { on: { START: "active" } },
205
- active: {
206
- onEnter() {
207
- stateAtOnEnter = m.state();
208
- },
209
- },
210
- },
211
- });
212
- m.send("START");
213
- expect(stateAtOnEnter).toBe("active");
214
- });
215
-
216
- it("history not polluted by failed transitions (onExit throws)", () => {
217
- const m = createMachine<"idle" | "active", "START">({
218
- initial: "idle",
219
- states: {
220
- idle: {
221
- on: { START: "active" },
222
- onExit: () => { throw new Error("blocked"); },
223
- },
224
- active: {},
225
- },
226
- });
227
- expect(() => m.send("START")).toThrow("blocked");
228
- expect(m.history()).toHaveLength(0);
229
- expect(m.state()).toBe("idle");
230
- });
231
-
232
- // ── Guards ──────────────────────────────────────────────────────────────────
233
-
234
- it("guard returning true allows the transition", () => {
235
- const m = createMachine<"a" | "b", "NEXT">({
236
- initial: "a",
237
- states: {
238
- a: { on: { NEXT: { target: "b", guard: () => true } } },
239
- b: {},
240
- },
241
- });
242
- expect(m.send("NEXT")).toBe(true);
243
- expect(m.state()).toBe("b");
244
- });
245
-
246
- it("guard returning false blocks the transition — send returns false", () => {
247
- const m = createMachine<"a" | "b", "NEXT">({
248
- initial: "a",
249
- states: {
250
- a: { on: { NEXT: { target: "b", guard: () => false } } },
251
- b: {},
252
- },
253
- });
254
- expect(m.send("NEXT")).toBe(false);
255
- expect(m.state()).toBe("a");
256
- });
257
-
258
- it("guard returning false — history not updated", () => {
259
- const m = createMachine<"a" | "b", "NEXT">({
260
- initial: "a",
261
- states: {
262
- a: { on: { NEXT: { target: "b", guard: () => false } } },
263
- b: {},
264
- },
265
- });
266
- m.send("NEXT");
267
- expect(m.history()).toHaveLength(0);
268
- });
269
-
270
- it("guard returning false — onExit is NOT called", () => {
271
- const onExit = vi.fn();
272
- const m = createMachine<"a" | "b", "NEXT">({
273
- initial: "a",
274
- states: {
275
- a: { on: { NEXT: { target: "b", guard: () => false } }, onExit },
276
- b: {},
277
- },
278
- });
279
- m.send("NEXT");
280
- expect(onExit).not.toHaveBeenCalled();
281
- });
282
-
283
- it("can() returns false when guard returns false", () => {
284
- const m = createMachine<"a" | "b", "NEXT">({
285
- initial: "a",
286
- states: {
287
- a: { on: { NEXT: { target: "b", guard: () => false } } },
288
- b: {},
289
- },
290
- });
291
- expect(m.can("NEXT")).toBe(false);
292
- });
293
-
294
- it("can() returns true when guard returns true", () => {
295
- const m = createMachine<"a" | "b", "NEXT">({
296
- initial: "a",
297
- states: {
298
- a: { on: { NEXT: { target: "b", guard: () => true } } },
299
- b: {},
300
- },
301
- });
302
- expect(m.can("NEXT")).toBe(true);
303
- });
304
-
305
- it("guard is evaluated at transition time, not at definition time", () => {
306
- let allow = false;
307
- const m = createMachine<"a" | "b", "NEXT">({
308
- initial: "a",
309
- states: {
310
- a: { on: { NEXT: { target: "b", guard: () => allow } } },
311
- b: {},
312
- },
313
- });
314
- expect(m.send("NEXT")).toBe(false);
315
- allow = true;
316
- expect(m.send("NEXT")).toBe(true);
317
- expect(m.state()).toBe("b");
318
- });
319
-
320
- it("transition without guard behaves as before (plain string target)", () => {
321
- const m = createMachine<"a" | "b", "NEXT">({
322
- initial: "a",
323
- states: {
324
- a: { on: { NEXT: "b" } },
325
- b: {},
326
- },
327
- });
328
- expect(m.can("NEXT")).toBe(true);
329
- expect(m.send("NEXT")).toBe(true);
330
- expect(m.state()).toBe("b");
331
- });
332
-
333
- // ── Per-transition action ───────────────────────────────────────────────────
334
-
335
- it("action is called on successful transition", () => {
336
- const action = vi.fn();
337
- const m = createMachine<"a" | "b", "NEXT">({
338
- initial: "a",
339
- states: {
340
- a: { on: { NEXT: { target: "b", action } } },
341
- b: {},
342
- },
343
- });
344
- m.send("NEXT");
345
- expect(action).toHaveBeenCalledOnce();
346
- });
347
-
348
- it("action is NOT called when guard blocks the transition", () => {
349
- const action = vi.fn();
350
- const m = createMachine<"a" | "b", "NEXT">({
351
- initial: "a",
352
- states: {
353
- a: { on: { NEXT: { target: "b", guard: () => false, action } } },
354
- b: {},
355
- },
356
- });
357
- m.send("NEXT");
358
- expect(action).not.toHaveBeenCalled();
359
- });
360
-
361
- it("action runs after onTransition and before onEnter", () => {
362
- const order: string[] = [];
363
- const m = createMachine<"a" | "b", "NEXT">({
364
- initial: "a",
365
- states: {
366
- a: { on: { NEXT: { target: "b", action: () => { order.push("action"); } } } },
367
- b: { onEnter: () => { order.push("onEnter"); } },
368
- },
369
- onTransition: () => { order.push("onTransition"); },
370
- });
371
- m.send("NEXT");
372
- expect(order).toEqual(["onTransition", "action", "onEnter"]);
373
- });
374
-
375
- // ── onEnter / onExit context ────────────────────────────────────────────────
376
-
377
- it("onEnter receives event and from-state in context", () => {
378
- let ctx: { event: string; from: string } | undefined;
379
- const m = createMachine<"a" | "b", "NEXT">({
380
- initial: "a",
381
- states: {
382
- a: { on: { NEXT: "b" } },
383
- b: { onEnter: (c) => { ctx = c; } },
384
- },
385
- });
386
- m.send("NEXT");
387
- expect(ctx).toEqual({ event: "NEXT", from: "a" });
388
- });
389
-
390
- it("onExit receives event and to-state in context", () => {
391
- let ctx: { event: string; to: string } | undefined;
392
- const m = createMachine<"a" | "b", "NEXT">({
393
- initial: "a",
394
- states: {
395
- a: { on: { NEXT: "b" }, onExit: (c) => { ctx = c; } },
396
- b: {},
397
- },
398
- });
399
- m.send("NEXT");
400
- expect(ctx).toEqual({ event: "NEXT", to: "b" });
401
- });
402
-
403
- it("onEnter and onExit called without context on reset()", () => {
404
- const onEnter = vi.fn();
405
- const onExit = vi.fn();
406
- const m = createMachine<"idle" | "active", "START">({
407
- initial: "idle",
408
- states: {
409
- idle: { on: { START: "active" }, onEnter },
410
- active: { onExit },
411
- },
412
- });
413
- m.send("START");
414
- m.reset();
415
- expect(onExit).toHaveBeenCalledWith(undefined);
416
- expect(onEnter).toHaveBeenCalledWith(undefined);
417
- });
418
-
419
- it("zero-argument onEnter/onExit still work with context (backward compat)", () => {
420
- const onEnter = vi.fn();
421
- const onExit = vi.fn();
422
- const m = createMachine<"a" | "b", "NEXT">({
423
- initial: "a",
424
- states: {
425
- a: { on: { NEXT: "b" }, onExit },
426
- b: { onEnter },
427
- },
428
- });
429
- m.send("NEXT");
430
- expect(onExit).toHaveBeenCalledOnce();
431
- expect(onEnter).toHaveBeenCalledOnce();
432
- });
433
- });
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src"]
8
- }