effect-machine 0.1.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/README.md +221 -0
- package/package.json +63 -0
- package/src/actor.ts +942 -0
- package/src/cluster/entity-machine.ts +202 -0
- package/src/cluster/index.ts +43 -0
- package/src/cluster/to-entity.ts +99 -0
- package/src/errors.ts +64 -0
- package/src/index.ts +102 -0
- package/src/inspection.ts +132 -0
- package/src/internal/brands.ts +51 -0
- package/src/internal/transition.ts +427 -0
- package/src/internal/utils.ts +80 -0
- package/src/machine.ts +685 -0
- package/src/persistence/adapter.ts +169 -0
- package/src/persistence/adapters/in-memory.ts +275 -0
- package/src/persistence/index.ts +24 -0
- package/src/persistence/persistent-actor.ts +601 -0
- package/src/persistence/persistent-machine.ts +131 -0
- package/src/schema.ts +316 -0
- package/src/slot.ts +281 -0
- package/src/testing.ts +282 -0
- package/tsconfig.json +68 -0
package/src/slot.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot module - schema-based, parameterized guards and effects.
|
|
3
|
+
*
|
|
4
|
+
* Guards and Effects are defined with schemas for their parameters,
|
|
5
|
+
* and provided implementations receive typed parameters plus machine context.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { Slot } from "effect-machine"
|
|
10
|
+
* import { Schema } from "effect"
|
|
11
|
+
*
|
|
12
|
+
* const MyGuards = Slot.Guards({
|
|
13
|
+
* canRetry: { max: Schema.Number },
|
|
14
|
+
* isValid: {}, // no params
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* const MyEffects = Slot.Effects({
|
|
18
|
+
* fetchData: { url: Schema.String },
|
|
19
|
+
* notify: { message: Schema.String },
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* // Used in handlers:
|
|
23
|
+
* .on(State.X, Event.Y, ({ guards, effects }) =>
|
|
24
|
+
* Effect.gen(function* () {
|
|
25
|
+
* if (yield* guards.canRetry({ max: 3 })) {
|
|
26
|
+
* yield* effects.fetchData({ url: "/api" })
|
|
27
|
+
* return State.Next
|
|
28
|
+
* }
|
|
29
|
+
* return state
|
|
30
|
+
* })
|
|
31
|
+
* )
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
import { Context } from "effect";
|
|
37
|
+
import type { Effect, Schema } from "effect";
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Type-level utilities
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/** Schema fields definition (like Schema.Struct.Fields) */
|
|
44
|
+
type Fields = Record<string, Schema.Schema.All>;
|
|
45
|
+
|
|
46
|
+
/** Extract the encoded type from schema fields (used for parameters) */
|
|
47
|
+
type FieldsToParams<F extends Fields> = keyof F extends never
|
|
48
|
+
? void
|
|
49
|
+
: Schema.Schema.Type<Schema.Struct<F>>;
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Slot Types
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A guard slot - callable function that returns Effect<boolean>.
|
|
57
|
+
*/
|
|
58
|
+
export interface GuardSlot<Name extends string, Params> {
|
|
59
|
+
readonly _tag: "GuardSlot";
|
|
60
|
+
readonly name: Name;
|
|
61
|
+
(params: Params): Effect.Effect<boolean>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An effect slot - callable function that returns Effect<void>.
|
|
66
|
+
*/
|
|
67
|
+
export interface EffectSlot<Name extends string, Params> {
|
|
68
|
+
readonly _tag: "EffectSlot";
|
|
69
|
+
readonly name: Name;
|
|
70
|
+
(params: Params): Effect.Effect<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Guard definition - name to schema fields mapping
|
|
75
|
+
*/
|
|
76
|
+
export type GuardsDef = Record<string, Fields>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Effect definition - name to schema fields mapping
|
|
80
|
+
*/
|
|
81
|
+
export type EffectsDef = Record<string, Fields>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Convert guard definitions to callable guard slots
|
|
85
|
+
*/
|
|
86
|
+
export type GuardSlots<D extends GuardsDef> = {
|
|
87
|
+
readonly [K in keyof D & string]: GuardSlot<K, FieldsToParams<D[K]>>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Convert effect definitions to callable effect slots
|
|
92
|
+
*/
|
|
93
|
+
export type EffectSlots<D extends EffectsDef> = {
|
|
94
|
+
readonly [K in keyof D & string]: EffectSlot<K, FieldsToParams<D[K]>>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Machine Context Tag
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Type for machine context - state, event, and self reference.
|
|
103
|
+
* Shared across all machines via MachineContextTag.
|
|
104
|
+
*/
|
|
105
|
+
export interface MachineContext<State, Event, Self> {
|
|
106
|
+
readonly state: State;
|
|
107
|
+
readonly event: Event;
|
|
108
|
+
readonly self: Self;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Shared Context tag for all machines.
|
|
113
|
+
* Single module-level tag instead of per-machine allocation.
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
116
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- generic context tag */
|
|
117
|
+
export const MachineContextTag =
|
|
118
|
+
Context.GenericTag<MachineContext<any, any, any>>("@effect-machine/Context");
|
|
119
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// Handler Types (for provide)
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Guard handler implementation.
|
|
127
|
+
* Receives params and context, returns Effect<boolean>.
|
|
128
|
+
*/
|
|
129
|
+
export type GuardHandler<Params, Ctx, R = never> = (
|
|
130
|
+
params: Params,
|
|
131
|
+
ctx: Ctx,
|
|
132
|
+
) => boolean | Effect.Effect<boolean, never, R>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Effect handler implementation.
|
|
136
|
+
* Receives params and context, returns Effect<void>.
|
|
137
|
+
*/
|
|
138
|
+
export type EffectHandler<Params, Ctx, R = never> = (
|
|
139
|
+
params: Params,
|
|
140
|
+
ctx: Ctx,
|
|
141
|
+
) => Effect.Effect<void, never, R>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Handler types for all guards in a definition
|
|
145
|
+
*/
|
|
146
|
+
export type GuardHandlers<D extends GuardsDef, MachineCtx, R = never> = {
|
|
147
|
+
readonly [K in keyof D & string]: GuardHandler<FieldsToParams<D[K]>, MachineCtx, R>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Handler types for all effects in a definition
|
|
152
|
+
*/
|
|
153
|
+
export type EffectHandlers<D extends EffectsDef, MachineCtx, R = never> = {
|
|
154
|
+
readonly [K in keyof D & string]: EffectHandler<FieldsToParams<D[K]>, MachineCtx, R>;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Schema Types (for Machine.make)
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Guards schema - returned by Slot.Guards()
|
|
163
|
+
*/
|
|
164
|
+
export interface GuardsSchema<D extends GuardsDef> {
|
|
165
|
+
readonly _tag: "GuardsSchema";
|
|
166
|
+
readonly definitions: D;
|
|
167
|
+
/** Create callable guard slots (used by Machine internally) */
|
|
168
|
+
readonly _createSlots: (
|
|
169
|
+
resolve: <N extends keyof D & string>(
|
|
170
|
+
name: N,
|
|
171
|
+
params: FieldsToParams<D[N]>,
|
|
172
|
+
) => Effect.Effect<boolean>,
|
|
173
|
+
) => GuardSlots<D>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Effects schema - returned by Slot.Effects()
|
|
178
|
+
*/
|
|
179
|
+
export interface EffectsSchema<D extends EffectsDef> {
|
|
180
|
+
readonly _tag: "EffectsSchema";
|
|
181
|
+
readonly definitions: D;
|
|
182
|
+
/** Create callable effect slots (used by Machine internally) */
|
|
183
|
+
readonly _createSlots: (
|
|
184
|
+
resolve: <N extends keyof D & string>(
|
|
185
|
+
name: N,
|
|
186
|
+
params: FieldsToParams<D[N]>,
|
|
187
|
+
) => Effect.Effect<void>,
|
|
188
|
+
) => EffectSlots<D>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// Slot Factories
|
|
193
|
+
// ============================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generic slot schema factory. Used internally by Guards() and Effects().
|
|
197
|
+
* @internal
|
|
198
|
+
*/
|
|
199
|
+
const createSlotSchema = <
|
|
200
|
+
Tag extends "GuardsSchema" | "EffectsSchema",
|
|
201
|
+
D extends Record<string, Fields>,
|
|
202
|
+
>(
|
|
203
|
+
tag: Tag,
|
|
204
|
+
slotTag: "GuardSlot" | "EffectSlot",
|
|
205
|
+
definitions: D,
|
|
206
|
+
): {
|
|
207
|
+
readonly _tag: Tag;
|
|
208
|
+
readonly definitions: D;
|
|
209
|
+
readonly _createSlots: (
|
|
210
|
+
resolve: <N extends keyof D & string>(
|
|
211
|
+
name: N,
|
|
212
|
+
params: FieldsToParams<D[N]>,
|
|
213
|
+
) => Effect.Effect<unknown>,
|
|
214
|
+
) => Record<string, unknown>;
|
|
215
|
+
} => ({
|
|
216
|
+
_tag: tag,
|
|
217
|
+
definitions,
|
|
218
|
+
_createSlots: (resolve) => {
|
|
219
|
+
const slots: Record<string, unknown> = {};
|
|
220
|
+
for (const name of Object.keys(definitions)) {
|
|
221
|
+
const slot = (params: unknown) => resolve(name, params as FieldsToParams<D[typeof name]>);
|
|
222
|
+
Object.defineProperty(slot, "_tag", { value: slotTag, enumerable: true });
|
|
223
|
+
Object.defineProperty(slot, "name", { value: name, enumerable: true });
|
|
224
|
+
slots[name] = slot;
|
|
225
|
+
}
|
|
226
|
+
return slots;
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create a guards schema with parameterized guard definitions.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* const MyGuards = Slot.Guards({
|
|
236
|
+
* canRetry: { max: Schema.Number },
|
|
237
|
+
* isValid: {},
|
|
238
|
+
* })
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export const Guards = <D extends GuardsDef>(definitions: D): GuardsSchema<D> =>
|
|
242
|
+
createSlotSchema("GuardsSchema", "GuardSlot", definitions) as GuardsSchema<D>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create an effects schema with parameterized effect definitions.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* const MyEffects = Slot.Effects({
|
|
250
|
+
* fetchData: { url: Schema.String },
|
|
251
|
+
* notify: { message: Schema.String },
|
|
252
|
+
* })
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export const Effects = <D extends EffectsDef>(definitions: D): EffectsSchema<D> =>
|
|
256
|
+
createSlotSchema("EffectsSchema", "EffectSlot", definitions) as EffectsSchema<D>;
|
|
257
|
+
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// Type extraction helpers
|
|
260
|
+
// ============================================================================
|
|
261
|
+
|
|
262
|
+
/** Extract guard definition type from GuardsSchema */
|
|
263
|
+
export type GuardsDefOf<G> = G extends GuardsSchema<infer D> ? D : never;
|
|
264
|
+
|
|
265
|
+
/** Extract effect definition type from EffectsSchema */
|
|
266
|
+
export type EffectsDefOf<E> = E extends EffectsSchema<infer D> ? D : never;
|
|
267
|
+
|
|
268
|
+
/** Extract guard slots type from GuardsSchema */
|
|
269
|
+
export type GuardSlotsOf<G> = G extends GuardsSchema<infer D> ? GuardSlots<D> : never;
|
|
270
|
+
|
|
271
|
+
/** Extract effect slots type from EffectsSchema */
|
|
272
|
+
export type EffectSlotsOf<E> = E extends EffectsSchema<infer D> ? EffectSlots<D> : never;
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// Slot namespace export
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
export const Slot = {
|
|
279
|
+
Guards,
|
|
280
|
+
Effects,
|
|
281
|
+
} as const;
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Effect, SubscriptionRef } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { Machine, MachineRef } from "./machine.js";
|
|
4
|
+
import { AssertionError } from "./errors.js";
|
|
5
|
+
import type { GuardsDef, EffectsDef } from "./slot.js";
|
|
6
|
+
import { executeTransition } from "./internal/transition.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Result of simulating events through a machine
|
|
10
|
+
*/
|
|
11
|
+
export interface SimulationResult<S> {
|
|
12
|
+
readonly states: ReadonlyArray<S>;
|
|
13
|
+
readonly finalState: S;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Simulate a sequence of events through a machine without running an actor.
|
|
18
|
+
* Useful for testing state transitions in isolation.
|
|
19
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
20
|
+
* within transition handlers.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const result = yield* simulate(
|
|
25
|
+
* fetcherMachine,
|
|
26
|
+
* [
|
|
27
|
+
* Event.Fetch({ url: "https://example.com" }),
|
|
28
|
+
* Event._Done({ data: { foo: "bar" } })
|
|
29
|
+
* ]
|
|
30
|
+
* )
|
|
31
|
+
*
|
|
32
|
+
* expect(result.finalState._tag).toBe("Success")
|
|
33
|
+
* expect(result.states).toHaveLength(3) // Idle -> Loading -> Success
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export const simulate = <
|
|
37
|
+
S extends { readonly _tag: string },
|
|
38
|
+
E extends { readonly _tag: string },
|
|
39
|
+
R,
|
|
40
|
+
GD extends GuardsDef = Record<string, never>,
|
|
41
|
+
EFD extends EffectsDef = Record<string, never>,
|
|
42
|
+
>(
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
|
|
44
|
+
machine: Machine<S, E, R, any, any, GD, EFD>,
|
|
45
|
+
events: ReadonlyArray<E>,
|
|
46
|
+
): Effect.Effect<SimulationResult<S>, never, R> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
// Create a dummy self for slot accessors
|
|
49
|
+
const dummySelf: MachineRef<E> = {
|
|
50
|
+
send: () => Effect.void,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let currentState = machine.initial;
|
|
54
|
+
const states: S[] = [currentState];
|
|
55
|
+
|
|
56
|
+
for (const event of events) {
|
|
57
|
+
const result = yield* executeTransition(machine, currentState, event, dummySelf);
|
|
58
|
+
|
|
59
|
+
if (!result.transitioned) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
currentState = result.newState;
|
|
64
|
+
states.push(currentState);
|
|
65
|
+
|
|
66
|
+
// Stop if final state
|
|
67
|
+
if (machine.finalStates.has(currentState._tag)) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { states, finalState: currentState };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// AssertionError is exported from errors.ts
|
|
76
|
+
export { AssertionError } from "./errors.js";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Assert that a machine can reach a specific state given a sequence of events
|
|
80
|
+
*/
|
|
81
|
+
export const assertReaches = <
|
|
82
|
+
S extends { readonly _tag: string },
|
|
83
|
+
E extends { readonly _tag: string },
|
|
84
|
+
R,
|
|
85
|
+
GD extends GuardsDef = Record<string, never>,
|
|
86
|
+
EFD extends EffectsDef = Record<string, never>,
|
|
87
|
+
>(
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
|
|
89
|
+
machine: Machine<S, E, R, any, any, GD, EFD>,
|
|
90
|
+
events: ReadonlyArray<E>,
|
|
91
|
+
expectedTag: string,
|
|
92
|
+
): Effect.Effect<S, AssertionError, R> =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const result = yield* simulate(machine, events);
|
|
95
|
+
if (result.finalState._tag !== expectedTag) {
|
|
96
|
+
return yield* new AssertionError({
|
|
97
|
+
message:
|
|
98
|
+
`Expected final state "${expectedTag}" but got "${result.finalState._tag}". ` +
|
|
99
|
+
`States visited: ${result.states.map((s) => s._tag).join(" -> ")}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return result.finalState;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Assert that a machine follows a specific path of state tags
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* yield* assertPath(
|
|
111
|
+
* machine,
|
|
112
|
+
* [Event.Start(), Event.Increment(), Event.Stop()],
|
|
113
|
+
* ["Idle", "Counting", "Counting", "Done"]
|
|
114
|
+
* )
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export const assertPath = <
|
|
118
|
+
S extends { readonly _tag: string },
|
|
119
|
+
E extends { readonly _tag: string },
|
|
120
|
+
R,
|
|
121
|
+
GD extends GuardsDef = Record<string, never>,
|
|
122
|
+
EFD extends EffectsDef = Record<string, never>,
|
|
123
|
+
>(
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
|
|
125
|
+
machine: Machine<S, E, R, any, any, GD, EFD>,
|
|
126
|
+
events: ReadonlyArray<E>,
|
|
127
|
+
expectedPath: ReadonlyArray<string>,
|
|
128
|
+
): Effect.Effect<SimulationResult<S>, AssertionError, R> =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const result = yield* simulate(machine, events);
|
|
131
|
+
const actualPath = result.states.map((s) => s._tag);
|
|
132
|
+
|
|
133
|
+
if (actualPath.length !== expectedPath.length) {
|
|
134
|
+
return yield* new AssertionError({
|
|
135
|
+
message:
|
|
136
|
+
`Path length mismatch. Expected ${expectedPath.length} states but got ${actualPath.length}.\n` +
|
|
137
|
+
`Expected: ${expectedPath.join(" -> ")}\n` +
|
|
138
|
+
`Actual: ${actualPath.join(" -> ")}`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < expectedPath.length; i++) {
|
|
143
|
+
if (actualPath[i] !== expectedPath[i]) {
|
|
144
|
+
return yield* new AssertionError({
|
|
145
|
+
message:
|
|
146
|
+
`Path mismatch at position ${i}. Expected "${expectedPath[i]}" but got "${actualPath[i]}".\n` +
|
|
147
|
+
`Expected: ${expectedPath.join(" -> ")}\n` +
|
|
148
|
+
`Actual: ${actualPath.join(" -> ")}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Assert that a machine never reaches a specific state given a sequence of events
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* // Verify error handling doesn't reach crash state
|
|
162
|
+
* yield* assertNeverReaches(
|
|
163
|
+
* machine,
|
|
164
|
+
* [Event.Error(), Event.Retry(), Event.Success()],
|
|
165
|
+
* "Crashed"
|
|
166
|
+
* )
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export const assertNeverReaches = <
|
|
170
|
+
S extends { readonly _tag: string },
|
|
171
|
+
E extends { readonly _tag: string },
|
|
172
|
+
R,
|
|
173
|
+
GD extends GuardsDef = Record<string, never>,
|
|
174
|
+
EFD extends EffectsDef = Record<string, never>,
|
|
175
|
+
>(
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
|
|
177
|
+
machine: Machine<S, E, R, any, any, GD, EFD>,
|
|
178
|
+
events: ReadonlyArray<E>,
|
|
179
|
+
forbiddenTag: string,
|
|
180
|
+
): Effect.Effect<SimulationResult<S>, AssertionError, R> =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const result = yield* simulate(machine, events);
|
|
183
|
+
|
|
184
|
+
const visitedIndex = result.states.findIndex((s) => s._tag === forbiddenTag);
|
|
185
|
+
if (visitedIndex !== -1) {
|
|
186
|
+
return yield* new AssertionError({
|
|
187
|
+
message:
|
|
188
|
+
`Machine reached forbidden state "${forbiddenTag}" at position ${visitedIndex}.\n` +
|
|
189
|
+
`States visited: ${result.states.map((s) => s._tag).join(" -> ")}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create a controllable test harness for a machine
|
|
198
|
+
*/
|
|
199
|
+
export interface TestHarness<S, E, R> {
|
|
200
|
+
readonly state: SubscriptionRef.SubscriptionRef<S>;
|
|
201
|
+
readonly send: (event: E) => Effect.Effect<S, never, R>;
|
|
202
|
+
readonly getState: Effect.Effect<S>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Options for creating a test harness
|
|
207
|
+
*/
|
|
208
|
+
export interface TestHarnessOptions<S, E> {
|
|
209
|
+
/**
|
|
210
|
+
* Called after each transition with the previous state, event, and new state.
|
|
211
|
+
* Useful for logging or spying on transitions.
|
|
212
|
+
*/
|
|
213
|
+
readonly onTransition?: (from: S, event: E, to: S) => void;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create a test harness for step-by-step testing.
|
|
218
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
219
|
+
* within transition handlers.
|
|
220
|
+
*
|
|
221
|
+
* @example Basic usage
|
|
222
|
+
* ```ts
|
|
223
|
+
* const harness = yield* createTestHarness(machine)
|
|
224
|
+
* yield* harness.send(Event.Start())
|
|
225
|
+
* const state = yield* harness.getState
|
|
226
|
+
* ```
|
|
227
|
+
*
|
|
228
|
+
* @example With transition observer
|
|
229
|
+
* ```ts
|
|
230
|
+
* const transitions: Array<{ from: string; event: string; to: string }> = []
|
|
231
|
+
* const harness = yield* createTestHarness(machine, {
|
|
232
|
+
* onTransition: (from, event, to) =>
|
|
233
|
+
* transitions.push({ from: from._tag, event: event._tag, to: to._tag })
|
|
234
|
+
* })
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export const createTestHarness = <
|
|
238
|
+
S extends { readonly _tag: string },
|
|
239
|
+
E extends { readonly _tag: string },
|
|
240
|
+
R,
|
|
241
|
+
GD extends GuardsDef = Record<string, never>,
|
|
242
|
+
EFD extends EffectsDef = Record<string, never>,
|
|
243
|
+
>(
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
|
|
245
|
+
machine: Machine<S, E, R, any, any, GD, EFD>,
|
|
246
|
+
options?: TestHarnessOptions<S, E>,
|
|
247
|
+
): Effect.Effect<TestHarness<S, E, R>, never, R> =>
|
|
248
|
+
Effect.gen(function* () {
|
|
249
|
+
// Create a dummy self for slot accessors
|
|
250
|
+
const dummySelf: MachineRef<E> = {
|
|
251
|
+
send: () => Effect.void,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const stateRef = yield* SubscriptionRef.make(machine.initial);
|
|
255
|
+
|
|
256
|
+
const send = (event: E): Effect.Effect<S, never, R> =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
const currentState = yield* SubscriptionRef.get(stateRef);
|
|
259
|
+
|
|
260
|
+
const result = yield* executeTransition(machine, currentState, event, dummySelf);
|
|
261
|
+
|
|
262
|
+
if (!result.transitioned) {
|
|
263
|
+
return currentState;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const newState = result.newState;
|
|
267
|
+
yield* SubscriptionRef.set(stateRef, newState);
|
|
268
|
+
|
|
269
|
+
// Call transition observer
|
|
270
|
+
if (options?.onTransition !== undefined) {
|
|
271
|
+
options.onTransition(currentState, event, newState);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return newState;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
state: stateRef,
|
|
279
|
+
send,
|
|
280
|
+
getState: SubscriptionRef.get(stateRef),
|
|
281
|
+
};
|
|
282
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"noUncheckedIndexedAccess": true,
|
|
5
|
+
"noFallthroughCasesInSwitch": true,
|
|
6
|
+
"noImplicitOverride": true,
|
|
7
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
8
|
+
"target": "ESNext",
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"plugins": [
|
|
16
|
+
{
|
|
17
|
+
"name": "@effect/language-service",
|
|
18
|
+
"diagnostics": true,
|
|
19
|
+
"diagnosticsName": true,
|
|
20
|
+
"diagnosticSeverity": {
|
|
21
|
+
// Enable rules that are off by default
|
|
22
|
+
"anyUnknownInErrorContext": "error",
|
|
23
|
+
"deterministicKeys": "warning",
|
|
24
|
+
"importFromBarrel": "warning",
|
|
25
|
+
"instanceOfSchema": "warning",
|
|
26
|
+
"missedPipeableOpportunity": "warning",
|
|
27
|
+
"missingEffectServiceDependency": "warning",
|
|
28
|
+
"schemaUnionOfLiterals": "warning",
|
|
29
|
+
"strictBooleanExpressions": "warning",
|
|
30
|
+
"strictEffectProvide": "warning",
|
|
31
|
+
|
|
32
|
+
// Upgrade suggestions to warnings for stricter enforcement
|
|
33
|
+
"catchAllToMapError": "warning",
|
|
34
|
+
"catchUnfailableEffect": "warning",
|
|
35
|
+
"effectFnOpportunity": "warning",
|
|
36
|
+
"effectMapVoid": "warning",
|
|
37
|
+
"effectSucceedWithVoid": "warning",
|
|
38
|
+
"leakingRequirements": "warning",
|
|
39
|
+
"preferSchemaOverJson": "warning",
|
|
40
|
+
"redundantSchemaTagIdentifier": "warning",
|
|
41
|
+
"returnEffectInGen": "warning",
|
|
42
|
+
"runEffectInsideEffect": "error",
|
|
43
|
+
"schemaStructWithTag": "warning",
|
|
44
|
+
"schemaSyncInEffect": "warning",
|
|
45
|
+
"tryCatchInEffectGen": "warning",
|
|
46
|
+
"unnecessaryEffectGen": "warning",
|
|
47
|
+
"unnecessaryFailYieldableError": "warning",
|
|
48
|
+
"unnecessaryPipe": "warning",
|
|
49
|
+
"unnecessaryPipeChain": "warning"
|
|
50
|
+
},
|
|
51
|
+
"keyPatterns": [
|
|
52
|
+
{
|
|
53
|
+
"target": "service",
|
|
54
|
+
"pattern": "default",
|
|
55
|
+
"skipLeadingPath": ["src/"]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"target": "error",
|
|
59
|
+
"pattern": "default",
|
|
60
|
+
"skipLeadingPath": ["src/"]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"include": ["src", "test"],
|
|
67
|
+
"exclude": ["node_modules"]
|
|
68
|
+
}
|