@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.
- package/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- 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
|
+
}
|