@mmapp/player-core 0.1.0-alpha.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.
Files changed (63) hide show
  1. package/dist/index.d.mts +1436 -0
  2. package/dist/index.d.ts +1436 -0
  3. package/dist/index.js +4828 -0
  4. package/dist/index.mjs +4762 -0
  5. package/package.json +35 -0
  6. package/package.json.backup +35 -0
  7. package/src/__tests__/actions.test.ts +187 -0
  8. package/src/__tests__/blueprint-e2e.test.ts +706 -0
  9. package/src/__tests__/blueprint-test-runner.test.ts +680 -0
  10. package/src/__tests__/core-functions.test.ts +78 -0
  11. package/src/__tests__/dsl-compiler.test.ts +1382 -0
  12. package/src/__tests__/dsl-grammar.test.ts +1682 -0
  13. package/src/__tests__/events.test.ts +200 -0
  14. package/src/__tests__/expression.test.ts +296 -0
  15. package/src/__tests__/failure-policies.test.ts +110 -0
  16. package/src/__tests__/frontend-context.test.ts +182 -0
  17. package/src/__tests__/integration.test.ts +256 -0
  18. package/src/__tests__/security.test.ts +190 -0
  19. package/src/__tests__/state-machine.test.ts +450 -0
  20. package/src/__tests__/testing-engine.test.ts +671 -0
  21. package/src/actions/dispatcher.ts +80 -0
  22. package/src/actions/index.ts +7 -0
  23. package/src/actions/types.ts +25 -0
  24. package/src/dsl/compiler/component-mapper.ts +289 -0
  25. package/src/dsl/compiler/field-mapper.ts +187 -0
  26. package/src/dsl/compiler/index.ts +82 -0
  27. package/src/dsl/compiler/manifest-compiler.ts +76 -0
  28. package/src/dsl/compiler/symbol-table.ts +214 -0
  29. package/src/dsl/compiler/utils.ts +48 -0
  30. package/src/dsl/compiler/view-compiler.ts +286 -0
  31. package/src/dsl/compiler/workflow-compiler.ts +600 -0
  32. package/src/dsl/index.ts +66 -0
  33. package/src/dsl/ir-migration.ts +221 -0
  34. package/src/dsl/ir-types.ts +416 -0
  35. package/src/dsl/lexer.ts +579 -0
  36. package/src/dsl/parser.ts +115 -0
  37. package/src/dsl/types.ts +256 -0
  38. package/src/events/event-bus.ts +68 -0
  39. package/src/events/index.ts +9 -0
  40. package/src/events/pattern-matcher.ts +61 -0
  41. package/src/events/types.ts +27 -0
  42. package/src/expression/evaluator.ts +676 -0
  43. package/src/expression/functions.ts +214 -0
  44. package/src/expression/index.ts +13 -0
  45. package/src/expression/types.ts +64 -0
  46. package/src/index.ts +61 -0
  47. package/src/state-machine/index.ts +16 -0
  48. package/src/state-machine/interpreter.ts +319 -0
  49. package/src/state-machine/types.ts +89 -0
  50. package/src/testing/action-trace.ts +209 -0
  51. package/src/testing/blueprint-test-runner.ts +214 -0
  52. package/src/testing/graph-walker.ts +249 -0
  53. package/src/testing/index.ts +69 -0
  54. package/src/testing/nrt-comparator.ts +199 -0
  55. package/src/testing/nrt-types.ts +230 -0
  56. package/src/testing/test-actions.ts +645 -0
  57. package/src/testing/test-compiler.ts +278 -0
  58. package/src/testing/test-runner.ts +444 -0
  59. package/src/testing/types.ts +231 -0
  60. package/src/validation/definition-validator.ts +812 -0
  61. package/src/validation/index.ts +13 -0
  62. package/tsconfig.json +26 -0
  63. package/vitest.config.ts +8 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Test Compiler — converts TestProgram → PlayerWorkflowDefinition.
3
+ *
4
+ * The test program IS a Blueprint. This compiler takes the ergonomic
5
+ * TestProgram format (scenarios, steps, assertions) and produces a
6
+ * standard PlayerWorkflowDefinition that any Player can execute.
7
+ *
8
+ * The compiled Blueprint uses test-specific action types (__test_*)
9
+ * in on_enter hooks. The Player executes these via registered action
10
+ * handlers — which are platform-specific (in-process, API, cross-platform).
11
+ *
12
+ * Structure of a compiled scenario Blueprint:
13
+ * __setup (START) → on_enter creates instances
14
+ * __step_0 (REGULAR) → on_enter performs action + assertions
15
+ * __step_1 (REGULAR) → ...
16
+ * __pass (END) → test passed
17
+ * __fail (CANCELLED) → test failed
18
+ *
19
+ * Transitions: all named 'next' with different from states.
20
+ * The runner drives by calling sm.transition('next') in a loop.
21
+ * On failure: sm.transition('abort') → __fail.
22
+ */
23
+
24
+ import type {
25
+ PlayerWorkflowDefinition,
26
+ PlayerStateDefinition,
27
+ PlayerTransitionDefinition,
28
+ PlayerAction,
29
+ } from '../state-machine/types';
30
+ import type { TestProgram, TestScenario, TestStep } from './types';
31
+
32
+ /**
33
+ * Compile a single TestScenario into a PlayerWorkflowDefinition (Blueprint).
34
+ *
35
+ * The compiled Blueprint can be:
36
+ * - Stored in the database
37
+ * - Serialized to JSON / .mm file
38
+ * - Executed on any Player (browser, iOS, server)
39
+ * - Generated by an LLM
40
+ */
41
+ export function compileTestScenario(
42
+ scenario: TestScenario,
43
+ definitions: Record<string, unknown>,
44
+ scenarioIndex = 0,
45
+ ): PlayerWorkflowDefinition {
46
+ const states: PlayerStateDefinition[] = [];
47
+ const transitions: PlayerTransitionDefinition[] = [];
48
+ const stepCount = scenario.steps.length;
49
+
50
+ // =========================================================================
51
+ // __idle (START): empty state. StateMachine constructor doesn't fire
52
+ // on_enter for START, so we need a separate setup state.
53
+ // =========================================================================
54
+
55
+ states.push({
56
+ name: '__idle',
57
+ type: 'START',
58
+ });
59
+
60
+ // =========================================================================
61
+ // __setup: create and start all test instances (on_enter fires on transition)
62
+ // =========================================================================
63
+
64
+ const setupActions: PlayerAction[] = [
65
+ {
66
+ type: '__test_init',
67
+ config: {
68
+ scenarioName: scenario.name,
69
+ scenarioIndex,
70
+ definitions: Object.keys(definitions),
71
+ connectEventBuses: scenario.connectEventBuses ?? false,
72
+ },
73
+ },
74
+ ];
75
+
76
+ if (scenario.instances?.length) {
77
+ for (const inst of scenario.instances) {
78
+ setupActions.push({
79
+ type: '__test_create_instance',
80
+ config: {
81
+ testId: inst.id,
82
+ definitionSlug: inst.definitionSlug,
83
+ initialData: inst.initialData ?? {},
84
+ },
85
+ });
86
+ }
87
+ } else {
88
+ // Single-instance: use the first definition
89
+ const firstSlug = Object.keys(definitions)[0];
90
+ if (firstSlug) {
91
+ setupActions.push({
92
+ type: '__test_create_instance',
93
+ config: {
94
+ testId: 'default',
95
+ definitionSlug: firstSlug,
96
+ initialData: {},
97
+ },
98
+ });
99
+ }
100
+ }
101
+
102
+ states.push({
103
+ name: '__setup',
104
+ type: 'REGULAR',
105
+ on_enter: setupActions,
106
+ });
107
+
108
+ // =========================================================================
109
+ // Step states: one per test step
110
+ // =========================================================================
111
+
112
+ for (let i = 0; i < stepCount; i++) {
113
+ const step = scenario.steps[i];
114
+ states.push({
115
+ name: `__step_${i}`,
116
+ type: 'REGULAR',
117
+ on_enter: compileStepActions(step, i),
118
+ });
119
+ }
120
+
121
+ // =========================================================================
122
+ // Terminal states
123
+ // =========================================================================
124
+
125
+ states.push({
126
+ name: '__pass',
127
+ type: 'END',
128
+ on_enter: [
129
+ { type: '__test_report', config: { passed: true } },
130
+ ],
131
+ });
132
+
133
+ states.push({
134
+ name: '__fail',
135
+ type: 'CANCELLED',
136
+ on_enter: [
137
+ { type: '__test_report', config: { passed: false } },
138
+ ],
139
+ });
140
+
141
+ // =========================================================================
142
+ // Transitions: 'next' advances through steps, 'abort' goes to __fail
143
+ // =========================================================================
144
+
145
+ // setup: __idle → __setup (fires on_enter to create instances)
146
+ transitions.push({
147
+ name: 'setup',
148
+ from: ['__idle'],
149
+ to: '__setup',
150
+ });
151
+
152
+ // next: __setup → __step_0 (or __pass if no steps)
153
+ transitions.push({
154
+ name: 'next',
155
+ from: ['__setup'],
156
+ to: stepCount > 0 ? '__step_0' : '__pass',
157
+ });
158
+
159
+ // next: __step_N → __step_N+1 (or __pass for the last step)
160
+ for (let i = 0; i < stepCount; i++) {
161
+ transitions.push({
162
+ name: 'next',
163
+ from: [`__step_${i}`],
164
+ to: i < stepCount - 1 ? `__step_${i + 1}` : '__pass',
165
+ });
166
+ }
167
+
168
+ // abort: any step → __fail
169
+ const abortFromStates = ['__setup', ...Array.from({ length: stepCount }, (_, i) => `__step_${i}`)];
170
+ transitions.push({
171
+ name: 'abort',
172
+ from: abortFromStates,
173
+ to: '__fail',
174
+ });
175
+
176
+ return {
177
+ id: `__test_scenario_${scenarioIndex}`,
178
+ slug: `__test_scenario_${scenarioIndex}`,
179
+ states,
180
+ transitions,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Compile all scenarios in a TestProgram into Blueprints.
186
+ */
187
+ export function compileTestProgram(
188
+ program: TestProgram,
189
+ ): PlayerWorkflowDefinition[] {
190
+ return program.scenarios.map((scenario, i) =>
191
+ compileTestScenario(scenario, program.definitions, i),
192
+ );
193
+ }
194
+
195
+ // =============================================================================
196
+ // Internals
197
+ // =============================================================================
198
+
199
+ function compileStepActions(step: TestStep, stepIndex: number): PlayerAction[] {
200
+ const actions: PlayerAction[] = [];
201
+ const defaultTestId = step.instanceId ?? 'default';
202
+
203
+ // 1. Begin step tracking
204
+ actions.push({
205
+ type: '__test_step_begin',
206
+ config: { stepName: step.name, stepIndex },
207
+ });
208
+
209
+ // 2. Perform the action
210
+ switch (step.action.type) {
211
+ case 'transition':
212
+ actions.push({
213
+ type: '__test_transition',
214
+ config: {
215
+ testId: defaultTestId,
216
+ transitionName: step.action.name,
217
+ data: step.action.data,
218
+ },
219
+ });
220
+ break;
221
+
222
+ case 'set_field':
223
+ actions.push({
224
+ type: '__test_set_field',
225
+ config: {
226
+ testId: defaultTestId,
227
+ field: step.action.field,
228
+ value: step.action.value,
229
+ },
230
+ });
231
+ break;
232
+
233
+ case 'publish_event':
234
+ actions.push({
235
+ type: '__test_publish_event',
236
+ config: {
237
+ topic: step.action.topic,
238
+ payload: step.action.payload ?? {},
239
+ },
240
+ });
241
+ break;
242
+
243
+ case 'assert_only':
244
+ // No action — assertions only
245
+ break;
246
+ }
247
+
248
+ // 3. Settle: wait for cross-instance EventRouter reactions
249
+ actions.push({
250
+ type: '__test_settle',
251
+ config: {},
252
+ });
253
+
254
+ // 4. Assertions
255
+ for (let a = 0; a < step.assertions.length; a++) {
256
+ const assertion = step.assertions[a];
257
+ actions.push({
258
+ type: '__test_assert',
259
+ config: {
260
+ testId: assertion.instanceId ?? defaultTestId,
261
+ target: assertion.target,
262
+ path: assertion.path,
263
+ operator: assertion.operator,
264
+ expected: assertion.expected,
265
+ label: assertion.label,
266
+ assertionIndex: a,
267
+ },
268
+ });
269
+ }
270
+
271
+ // 5. End step tracking
272
+ actions.push({
273
+ type: '__test_step_end',
274
+ config: { stepIndex },
275
+ });
276
+
277
+ return actions;
278
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Test Runner — executes test programs against the player-core engine.
3
+ *
4
+ * Runs test scenarios in-memory using StateMachine + EventBus.
5
+ * No database, no network, no UI — pure state machine execution.
6
+ *
7
+ * Exports:
8
+ * - runTestProgram(): Execute a full test program (all scenarios)
9
+ * - runScenario(): Execute a single scenario
10
+ */
11
+
12
+ import { StateMachine } from '../state-machine/interpreter';
13
+ import { EventBus } from '../events/event-bus';
14
+ import { createEvaluator, WEB_FAILURE_POLICIES } from '../expression';
15
+ import type { Evaluator, ExpressionContext } from '../expression/types';
16
+ import type { PlayerWorkflowDefinition, ActionHandler, PlayerAction } from '../state-machine/types';
17
+ import type {
18
+ TestProgram,
19
+ TestScenario,
20
+ TestStep,
21
+ TestAssertion,
22
+ TestRunnerConfig,
23
+ TestProgramResult,
24
+ ScenarioResult,
25
+ StepResult,
26
+ AssertionResult,
27
+ } from './types';
28
+
29
+ /**
30
+ * Execute a complete test program (all scenarios).
31
+ */
32
+ export async function runTestProgram(
33
+ program: TestProgram,
34
+ config?: TestRunnerConfig,
35
+ ): Promise<TestProgramResult> {
36
+ const start = performance.now();
37
+ const scenarioResults: ScenarioResult[] = [];
38
+ let allPassed = true;
39
+
40
+ for (let i = 0; i < program.scenarios.length; i++) {
41
+ if (config?.abortSignal?.aborted) {
42
+ break;
43
+ }
44
+
45
+ const result = await runScenario(program.scenarios[i], program.definitions, config);
46
+ scenarioResults.push(result);
47
+
48
+ if (!result.passed) allPassed = false;
49
+
50
+ config?.onScenarioComplete?.(result);
51
+ }
52
+
53
+ return {
54
+ passed: allPassed,
55
+ scenarioResults,
56
+ durationMs: performance.now() - start,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Execute a single test scenario.
62
+ */
63
+ export async function runScenario(
64
+ scenario: TestScenario,
65
+ definitions: Record<string, PlayerWorkflowDefinition>,
66
+ config?: TestRunnerConfig,
67
+ ): Promise<ScenarioResult> {
68
+ const start = performance.now();
69
+
70
+ try {
71
+ // Create evaluator
72
+ const evaluator = createEvaluator({
73
+ functions: [],
74
+ failurePolicy: WEB_FAILURE_POLICIES.EVENT_REACTION,
75
+ });
76
+
77
+ // Create shared event bus (for multi-instance wiring)
78
+ const eventBus = scenario.connectEventBuses ? new EventBus() : null;
79
+
80
+ // Create instances
81
+ const instances = new Map<string, StateMachine>();
82
+
83
+ if (scenario.instances?.length) {
84
+ // Multi-instance scenario
85
+ for (const inst of scenario.instances) {
86
+ const def = definitions[inst.definitionSlug];
87
+ if (!def) {
88
+ return {
89
+ scenarioName: scenario.name,
90
+ passed: false,
91
+ stepResults: [],
92
+ durationMs: performance.now() - start,
93
+ error: `Definition not found: "${inst.definitionSlug}"`,
94
+ };
95
+ }
96
+
97
+ const sm = createStateMachine(def, inst.initialData ?? {}, evaluator, config);
98
+ instances.set(inst.id, sm);
99
+
100
+ // Wire on_event subscriptions to shared event bus
101
+ if (eventBus) {
102
+ wireOnEventSubscriptions(sm, eventBus, evaluator);
103
+ }
104
+ }
105
+ } else {
106
+ // Single-instance scenario — find the first definition
107
+ const defKeys = Object.keys(definitions);
108
+ if (defKeys.length === 0) {
109
+ return {
110
+ scenarioName: scenario.name,
111
+ passed: false,
112
+ stepResults: [],
113
+ durationMs: performance.now() - start,
114
+ error: 'No definitions provided',
115
+ };
116
+ }
117
+ const def = definitions[defKeys[0]];
118
+ const sm = createStateMachine(def, {}, evaluator, config);
119
+ instances.set('default', sm);
120
+
121
+ if (eventBus) {
122
+ wireOnEventSubscriptions(sm, eventBus, evaluator);
123
+ }
124
+ }
125
+
126
+ // Execute steps
127
+ const stepResults: StepResult[] = [];
128
+ let allPassed = true;
129
+
130
+ for (let i = 0; i < scenario.steps.length; i++) {
131
+ if (config?.abortSignal?.aborted) {
132
+ break;
133
+ }
134
+
135
+ const step = scenario.steps[i];
136
+ const stepResult = await executeStep(step, instances, eventBus, evaluator);
137
+ stepResults.push(stepResult);
138
+
139
+ if (!stepResult.passed) allPassed = false;
140
+
141
+ config?.onStepComplete?.(0, stepResult);
142
+ }
143
+
144
+ return {
145
+ scenarioName: scenario.name,
146
+ passed: allPassed,
147
+ stepResults,
148
+ durationMs: performance.now() - start,
149
+ };
150
+ } catch (error) {
151
+ return {
152
+ scenarioName: scenario.name,
153
+ passed: false,
154
+ stepResults: [],
155
+ durationMs: performance.now() - start,
156
+ error: error instanceof Error ? error.message : String(error),
157
+ };
158
+ }
159
+ }
160
+
161
+ // =============================================================================
162
+ // Internal helpers
163
+ // =============================================================================
164
+
165
+ function createStateMachine(
166
+ def: PlayerWorkflowDefinition,
167
+ initialData: Record<string, unknown>,
168
+ evaluator: Evaluator,
169
+ config?: TestRunnerConfig,
170
+ ): StateMachine {
171
+ const actionHandlers = new Map<string, ActionHandler>();
172
+
173
+ // Built-in set_field handler — acts on the StateMachine directly
174
+ // We'll set it up after creating the SM via a closure
175
+ let smRef: StateMachine | null = null;
176
+
177
+ actionHandlers.set('set_field', (action: PlayerAction) => {
178
+ if (smRef && typeof action.config.field === 'string') {
179
+ smRef.setField(action.config.field, action.config.value);
180
+ }
181
+ });
182
+
183
+ actionHandlers.set('set_memory', (action: PlayerAction) => {
184
+ if (smRef && typeof action.config.key === 'string') {
185
+ smRef.setMemory(action.config.key, action.config.value);
186
+ }
187
+ });
188
+
189
+ // Register custom action handlers from config
190
+ if (config?.actionHandlers) {
191
+ for (const [type, handler] of Object.entries(config.actionHandlers)) {
192
+ actionHandlers.set(type, (action: PlayerAction, ctx: ExpressionContext) => handler(action.config, ctx));
193
+ }
194
+ }
195
+
196
+ const sm = new StateMachine(def, initialData, { evaluator, actionHandlers });
197
+ smRef = sm;
198
+
199
+ return sm;
200
+ }
201
+
202
+ function wireOnEventSubscriptions(
203
+ sm: StateMachine,
204
+ eventBus: EventBus,
205
+ evaluator: Evaluator,
206
+ ): void {
207
+ const unsubs: Array<() => void> = [];
208
+
209
+ function subscribeCurrentState(): void {
210
+ // Clear previous subscriptions
211
+ for (const unsub of unsubs) unsub();
212
+ unsubs.length = 0;
213
+
214
+ const stateDef = sm.getCurrentStateDefinition();
215
+ if (!stateDef?.on_event?.length) return;
216
+
217
+ for (const sub of stateDef.on_event) {
218
+ const unsub = eventBus.subscribe(sub.match, async (event) => {
219
+ const ctx: ExpressionContext = {
220
+ ...sm.stateData,
221
+ state_data: sm.stateData,
222
+ event: event.payload,
223
+ current_state: sm.currentState,
224
+ status: sm.status,
225
+ };
226
+
227
+ // Check conditions
228
+ if (sub.conditions?.length) {
229
+ for (const condition of sub.conditions) {
230
+ const result = evaluator.evaluate<boolean>(condition, ctx);
231
+ if (!result.value) return;
232
+ }
233
+ }
234
+
235
+ // Execute actions — directly invoke handlers on the state machine
236
+ for (const action of sub.actions) {
237
+ if (action.type === 'set_field' && typeof action.config.field === 'string') {
238
+ sm.setField(action.config.field, action.config.value);
239
+ } else if (action.type === 'set_memory' && typeof action.config.key === 'string') {
240
+ sm.setMemory(action.config.key, action.config.value);
241
+ }
242
+ // Other action types (toast, play_sound, etc.) are no-ops in test mode
243
+ }
244
+ });
245
+ unsubs.push(unsub);
246
+ }
247
+ }
248
+
249
+ // Subscribe for initial state
250
+ subscribeCurrentState();
251
+
252
+ // Re-subscribe when state changes
253
+ sm.on((event) => {
254
+ if (event.type === 'state_enter') {
255
+ subscribeCurrentState();
256
+ }
257
+ });
258
+ }
259
+
260
+ async function executeStep(
261
+ step: TestStep,
262
+ instances: Map<string, StateMachine>,
263
+ eventBus: EventBus | null,
264
+ evaluator: Evaluator,
265
+ ): Promise<StepResult> {
266
+ const stepStart = performance.now();
267
+
268
+ try {
269
+ // Resolve target instance
270
+ const instanceId = step.instanceId ?? 'default';
271
+ const sm = instances.get(instanceId);
272
+
273
+ // Perform action
274
+ if (step.action.type !== 'assert_only') {
275
+ if (!sm && step.action.type !== 'publish_event') {
276
+ return {
277
+ stepName: step.name,
278
+ passed: false,
279
+ assertionResults: [],
280
+ durationMs: performance.now() - stepStart,
281
+ error: `Instance not found: "${instanceId}"`,
282
+ };
283
+ }
284
+
285
+ switch (step.action.type) {
286
+ case 'transition': {
287
+ const result = await sm!.transition(step.action.name, step.action.data);
288
+ if (!result.success) {
289
+ return {
290
+ stepName: step.name,
291
+ passed: false,
292
+ assertionResults: [],
293
+ durationMs: performance.now() - stepStart,
294
+ error: `Transition failed: ${result.error}`,
295
+ };
296
+ }
297
+ break;
298
+ }
299
+ case 'set_field': {
300
+ sm!.setField(step.action.field, step.action.value);
301
+ break;
302
+ }
303
+ case 'publish_event': {
304
+ if (eventBus) {
305
+ await eventBus.publish(step.action.topic, step.action.payload ?? {});
306
+ }
307
+ break;
308
+ }
309
+ }
310
+ }
311
+
312
+ // Evaluate assertions
313
+ const assertionResults = evaluateAssertions(step.assertions, instances, evaluator);
314
+ const passed = assertionResults.every(r => r.passed);
315
+
316
+ return {
317
+ stepName: step.name,
318
+ passed,
319
+ assertionResults,
320
+ durationMs: performance.now() - stepStart,
321
+ };
322
+ } catch (error) {
323
+ return {
324
+ stepName: step.name,
325
+ passed: false,
326
+ assertionResults: [],
327
+ durationMs: performance.now() - stepStart,
328
+ error: error instanceof Error ? error.message : String(error),
329
+ };
330
+ }
331
+ }
332
+
333
+ function evaluateAssertions(
334
+ assertions: TestAssertion[],
335
+ instances: Map<string, StateMachine>,
336
+ _evaluator: Evaluator,
337
+ ): AssertionResult[] {
338
+ return assertions.map(assertion => {
339
+ try {
340
+ const instanceId = assertion.instanceId ?? 'default';
341
+ const sm = instances.get(instanceId);
342
+ if (!sm) {
343
+ return {
344
+ passed: false,
345
+ assertion,
346
+ actual: undefined,
347
+ error: `Instance not found: "${instanceId}"`,
348
+ };
349
+ }
350
+
351
+ const actual = getAssertionTarget(assertion, sm);
352
+ const passed = compareValues(actual, assertion.operator, assertion.expected);
353
+
354
+ return { passed, assertion, actual };
355
+ } catch (error) {
356
+ return {
357
+ passed: false,
358
+ assertion,
359
+ actual: undefined,
360
+ error: error instanceof Error ? error.message : String(error),
361
+ };
362
+ }
363
+ });
364
+ }
365
+
366
+ function getAssertionTarget(assertion: TestAssertion, sm: StateMachine): unknown {
367
+ switch (assertion.target) {
368
+ case 'state':
369
+ return sm.currentState;
370
+ case 'status':
371
+ return sm.status;
372
+ case 'state_data': {
373
+ if (!assertion.path) return sm.stateData;
374
+ return getNestedValue(sm.stateData, assertion.path);
375
+ }
376
+ case 'available_transitions':
377
+ return sm.getAvailableTransitions().map(t => t.name);
378
+ default:
379
+ throw new Error(`Unknown assertion target: "${assertion.target}"`);
380
+ }
381
+ }
382
+
383
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
384
+ const parts = path.split('.');
385
+ let current: unknown = obj;
386
+ for (const part of parts) {
387
+ if (current == null || typeof current !== 'object') return undefined;
388
+ current = (current as Record<string, unknown>)[part];
389
+ }
390
+ return current;
391
+ }
392
+
393
+ function compareValues(actual: unknown, operator: TestAssertion['operator'], expected: unknown): boolean {
394
+ switch (operator) {
395
+ case 'eq':
396
+ return deepEqual(actual, expected);
397
+ case 'neq':
398
+ return !deepEqual(actual, expected);
399
+ case 'gt':
400
+ return typeof actual === 'number' && typeof expected === 'number' && actual > expected;
401
+ case 'gte':
402
+ return typeof actual === 'number' && typeof expected === 'number' && actual >= expected;
403
+ case 'lt':
404
+ return typeof actual === 'number' && typeof expected === 'number' && actual < expected;
405
+ case 'lte':
406
+ return typeof actual === 'number' && typeof expected === 'number' && actual <= expected;
407
+ case 'contains': {
408
+ if (Array.isArray(actual)) return actual.includes(expected);
409
+ if (typeof actual === 'string' && typeof expected === 'string') return actual.includes(expected);
410
+ return false;
411
+ }
412
+ case 'truthy':
413
+ return Boolean(actual);
414
+ case 'falsy':
415
+ return !actual;
416
+ default:
417
+ return false;
418
+ }
419
+ }
420
+
421
+ function deepEqual(a: unknown, b: unknown): boolean {
422
+ if (a === b) return true;
423
+ if (a == null || b == null) return false;
424
+ if (typeof a !== typeof b) return false;
425
+
426
+ if (Array.isArray(a) && Array.isArray(b)) {
427
+ if (a.length !== b.length) return false;
428
+ return a.every((v, i) => deepEqual(v, b[i]));
429
+ }
430
+
431
+ if (typeof a === 'object' && typeof b === 'object') {
432
+ const keysA = Object.keys(a as Record<string, unknown>);
433
+ const keysB = Object.keys(b as Record<string, unknown>);
434
+ if (keysA.length !== keysB.length) return false;
435
+ return keysA.every(k =>
436
+ deepEqual(
437
+ (a as Record<string, unknown>)[k],
438
+ (b as Record<string, unknown>)[k],
439
+ ),
440
+ );
441
+ }
442
+
443
+ return false;
444
+ }