@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,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blueprint MVP1 End-to-End Tests
|
|
3
|
+
*
|
|
4
|
+
* Exercises the complete PM Blueprint workflow definitions through the
|
|
5
|
+
* player-core engine. This tests the "declarative program" concept:
|
|
6
|
+
*
|
|
7
|
+
* 1. PM-Task workflow: todo → in_progress → done (with XP rewards)
|
|
8
|
+
* 2. PM-Project workflow: draft → active → completed (auto-complete when all tasks done)
|
|
9
|
+
* 3. PM-User-Stats workflow: XP progression with level-up system
|
|
10
|
+
* 4. Experience workflows: domain events → pattern matching → frontend actions
|
|
11
|
+
*
|
|
12
|
+
* These mirror the actual blueprint definitions from the PR.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
+
import { StateMachine } from '../state-machine/interpreter';
|
|
17
|
+
import { EventBus } from '../events/event-bus';
|
|
18
|
+
import { ActionDispatcher } from '../actions/dispatcher';
|
|
19
|
+
import { createEvaluator, WEB_FAILURE_POLICIES, clearExpressionCache } from '../expression';
|
|
20
|
+
import type { PlayerWorkflowDefinition } from '../state-machine/types';
|
|
21
|
+
import type { Evaluator, ExpressionContext } from '../expression/types';
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Blueprint Domain Workflow Definitions (matching backend seeds)
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
const PM_TASK_DEFINITION: PlayerWorkflowDefinition = {
|
|
28
|
+
id: 'pm-task',
|
|
29
|
+
slug: 'pm-task',
|
|
30
|
+
states: [
|
|
31
|
+
{
|
|
32
|
+
name: 'todo',
|
|
33
|
+
type: 'START',
|
|
34
|
+
on_enter: [
|
|
35
|
+
{
|
|
36
|
+
type: 'set_field',
|
|
37
|
+
config: { field: 'status_label', value: 'To Do' },
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'in_progress',
|
|
43
|
+
type: 'REGULAR',
|
|
44
|
+
on_enter: [
|
|
45
|
+
{
|
|
46
|
+
type: 'set_field',
|
|
47
|
+
config: { field: 'status_label', value: 'In Progress' },
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'done',
|
|
53
|
+
type: 'END',
|
|
54
|
+
on_enter: [
|
|
55
|
+
{
|
|
56
|
+
type: 'set_field',
|
|
57
|
+
config: { field: 'status_label', value: 'Done' },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'cancelled',
|
|
63
|
+
type: 'CANCELLED',
|
|
64
|
+
on_enter: [
|
|
65
|
+
{
|
|
66
|
+
type: 'set_field',
|
|
67
|
+
config: { field: 'status_label', value: 'Cancelled' },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
transitions: [
|
|
73
|
+
{ name: 'start', from: ['todo'], to: 'in_progress' },
|
|
74
|
+
{ name: 'complete', from: ['in_progress'], to: 'done' },
|
|
75
|
+
{ name: 'cancel', from: ['todo', 'in_progress'], to: 'cancelled' },
|
|
76
|
+
{ name: 'reopen', from: ['done', 'cancelled'], to: 'todo' },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const PM_PROJECT_DEFINITION: PlayerWorkflowDefinition = {
|
|
81
|
+
id: 'pm-project',
|
|
82
|
+
slug: 'pm-project',
|
|
83
|
+
states: [
|
|
84
|
+
{
|
|
85
|
+
name: 'draft',
|
|
86
|
+
type: 'START',
|
|
87
|
+
on_enter: [
|
|
88
|
+
{ type: 'set_field', config: { field: 'total_tasks', value: 0 } },
|
|
89
|
+
{ type: 'set_field', config: { field: 'completed_tasks', value: 0 } },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'active',
|
|
94
|
+
type: 'REGULAR',
|
|
95
|
+
on_event: [
|
|
96
|
+
{
|
|
97
|
+
match: '*:*:pm-task:transition.completed',
|
|
98
|
+
actions: [
|
|
99
|
+
{
|
|
100
|
+
type: 'set_field',
|
|
101
|
+
config: { field: 'last_task_event', value: 'task_transitioned' },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{ name: 'completed', type: 'END' },
|
|
108
|
+
{ name: 'cancelled', type: 'CANCELLED' },
|
|
109
|
+
],
|
|
110
|
+
transitions: [
|
|
111
|
+
{ name: 'activate', from: ['draft'], to: 'active' },
|
|
112
|
+
{
|
|
113
|
+
name: 'auto_complete',
|
|
114
|
+
from: ['active'],
|
|
115
|
+
to: 'completed',
|
|
116
|
+
auto: true,
|
|
117
|
+
conditions: ['gt(completed_tasks, 0)', 'eq(completed_tasks, total_tasks)'],
|
|
118
|
+
},
|
|
119
|
+
{ name: 'cancel', from: ['draft', 'active'], to: 'cancelled' },
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const PM_PROJECT_EXPERIENCE: PlayerWorkflowDefinition = {
|
|
124
|
+
id: 'pm-project-experience',
|
|
125
|
+
slug: 'pm-project-experience',
|
|
126
|
+
states: [
|
|
127
|
+
{
|
|
128
|
+
name: 'active',
|
|
129
|
+
type: 'START',
|
|
130
|
+
on_event: [
|
|
131
|
+
{
|
|
132
|
+
match: '*:*:pm-task:instance.completed',
|
|
133
|
+
actions: [
|
|
134
|
+
{
|
|
135
|
+
type: 'toast',
|
|
136
|
+
config: { variant: 'success', message: 'Task completed!', duration: 3000 },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
type: 'refresh_query',
|
|
140
|
+
config: { queryKey: ['workflow-instances'] },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
match: '*:*:pm-task:transition.completed',
|
|
146
|
+
conditions: ["eq(event.transition_name, 'start')"],
|
|
147
|
+
actions: [
|
|
148
|
+
{
|
|
149
|
+
type: 'toast',
|
|
150
|
+
config: { variant: 'info', message: 'Task started', duration: 2000 },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
match: '*:*:pm-project:instance.completed',
|
|
156
|
+
actions: [
|
|
157
|
+
{
|
|
158
|
+
type: 'toast',
|
|
159
|
+
config: { variant: 'success', message: 'All tasks done — project completed!' },
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'play_sound',
|
|
163
|
+
config: { src: '/sounds/success.mp3', volume: 0.6 },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
transitions: [],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Tests
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
describe('Blueprint MVP1 E2E: Declarative Program Engine', () => {
|
|
178
|
+
let evaluator: Evaluator;
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
clearExpressionCache();
|
|
182
|
+
evaluator = createEvaluator({
|
|
183
|
+
functions: [],
|
|
184
|
+
failurePolicy: WEB_FAILURE_POLICIES.EVENT_REACTION,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// PM-Task: Full lifecycle
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('PM-Task Workflow', () => {
|
|
193
|
+
it('completes full task lifecycle: todo → in_progress → done', async () => {
|
|
194
|
+
const actionHandler = vi.fn();
|
|
195
|
+
const sm = new StateMachine(PM_TASK_DEFINITION, {
|
|
196
|
+
title: 'Write tests',
|
|
197
|
+
priority: 'high',
|
|
198
|
+
xp_reward: 25,
|
|
199
|
+
}, {
|
|
200
|
+
evaluator,
|
|
201
|
+
actionHandlers: new Map([['set_field', actionHandler]]),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(sm.currentState).toBe('todo');
|
|
205
|
+
expect(sm.status).toBe('ACTIVE');
|
|
206
|
+
|
|
207
|
+
// Start the task
|
|
208
|
+
const r1 = await sm.transition('start');
|
|
209
|
+
expect(r1.success).toBe(true);
|
|
210
|
+
expect(sm.currentState).toBe('in_progress');
|
|
211
|
+
// on_enter set_field should have been called
|
|
212
|
+
expect(actionHandler).toHaveBeenCalled();
|
|
213
|
+
|
|
214
|
+
// Complete the task
|
|
215
|
+
const r2 = await sm.transition('complete');
|
|
216
|
+
expect(r2.success).toBe(true);
|
|
217
|
+
expect(sm.currentState).toBe('done');
|
|
218
|
+
expect(sm.status).toBe('COMPLETED');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('allows cancel from any active state', async () => {
|
|
222
|
+
const sm = new StateMachine(PM_TASK_DEFINITION, {}, {
|
|
223
|
+
evaluator,
|
|
224
|
+
actionHandlers: new Map([['set_field', vi.fn()]]),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Cancel from todo
|
|
228
|
+
const r = await sm.transition('cancel');
|
|
229
|
+
expect(r.success).toBe(true);
|
|
230
|
+
expect(sm.currentState).toBe('cancelled');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('done/cancelled set terminal status blocking further transitions', async () => {
|
|
234
|
+
const sm = new StateMachine(PM_TASK_DEFINITION, {}, {
|
|
235
|
+
evaluator,
|
|
236
|
+
actionHandlers: new Map([['set_field', vi.fn()]]),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await sm.transition('start');
|
|
240
|
+
await sm.transition('complete');
|
|
241
|
+
expect(sm.currentState).toBe('done');
|
|
242
|
+
expect(sm.status).toBe('COMPLETED');
|
|
243
|
+
|
|
244
|
+
// Even though reopen is defined from done→todo, the instance status
|
|
245
|
+
// is COMPLETED so the state machine rejects all transitions
|
|
246
|
+
const r = await sm.transition('reopen');
|
|
247
|
+
expect(r.success).toBe(false);
|
|
248
|
+
expect(r.error).toContain('COMPLETED');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('available transitions are correct per state', () => {
|
|
252
|
+
const sm = new StateMachine(PM_TASK_DEFINITION, {}, {
|
|
253
|
+
evaluator, actionHandlers: new Map([['set_field', vi.fn()]]),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// In todo: can start or cancel
|
|
257
|
+
const todoTransitions = sm.getAvailableTransitions().map(t => t.name);
|
|
258
|
+
expect(todoTransitions).toContain('start');
|
|
259
|
+
expect(todoTransitions).toContain('cancel');
|
|
260
|
+
expect(todoTransitions).not.toContain('complete');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// PM-Project: Auto-completion via conditions
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
describe('PM-Project Workflow', () => {
|
|
269
|
+
it('auto-completes when all tasks are done', async () => {
|
|
270
|
+
const sm = new StateMachine(PM_PROJECT_DEFINITION, {
|
|
271
|
+
name: 'Test Project',
|
|
272
|
+
total_tasks: 3,
|
|
273
|
+
completed_tasks: 0,
|
|
274
|
+
}, {
|
|
275
|
+
evaluator,
|
|
276
|
+
actionHandlers: new Map([['set_field', vi.fn()]]),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Activate project
|
|
280
|
+
const r1 = await sm.transition('activate');
|
|
281
|
+
expect(r1.success).toBe(true);
|
|
282
|
+
expect(sm.currentState).toBe('active');
|
|
283
|
+
|
|
284
|
+
// Simulate completing tasks
|
|
285
|
+
sm.setField('completed_tasks', 1);
|
|
286
|
+
sm.setField('completed_tasks', 2);
|
|
287
|
+
|
|
288
|
+
// Not yet — 2/3 tasks done, auto transition should NOT fire
|
|
289
|
+
// (auto transitions check on explicit transitions, not on setField)
|
|
290
|
+
|
|
291
|
+
// Complete last task
|
|
292
|
+
sm.setField('completed_tasks', 3);
|
|
293
|
+
|
|
294
|
+
// Now trigger a transition check — auto_complete should evaluate
|
|
295
|
+
// In the real app, this would be triggered by a domain event
|
|
296
|
+
// For testing, we verify the conditions are evaluable
|
|
297
|
+
const ctx: ExpressionContext = {
|
|
298
|
+
...sm.stateData,
|
|
299
|
+
state_data: sm.stateData,
|
|
300
|
+
completed_tasks: 3,
|
|
301
|
+
total_tasks: 3,
|
|
302
|
+
};
|
|
303
|
+
const cond1 = evaluator.evaluate('gt(completed_tasks, 0)', ctx);
|
|
304
|
+
const cond2 = evaluator.evaluate('eq(completed_tasks, total_tasks)', ctx);
|
|
305
|
+
expect(cond1.value).toBe(true);
|
|
306
|
+
expect(cond2.value).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('auto-complete does NOT fire when tasks incomplete', () => {
|
|
310
|
+
const ctx: ExpressionContext = {
|
|
311
|
+
completed_tasks: 1,
|
|
312
|
+
total_tasks: 3,
|
|
313
|
+
};
|
|
314
|
+
const cond = evaluator.evaluate('eq(completed_tasks, total_tasks)', ctx);
|
|
315
|
+
expect(cond.value).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Experience Workflow: Domain events → frontend actions
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
describe('PM-Project Experience Workflow', () => {
|
|
324
|
+
it('fires toast when task completes (domain event pattern matching)', async () => {
|
|
325
|
+
const eventBus = new EventBus();
|
|
326
|
+
const dispatcher = new ActionDispatcher();
|
|
327
|
+
const toastHandler = vi.fn();
|
|
328
|
+
const refreshHandler = vi.fn();
|
|
329
|
+
|
|
330
|
+
dispatcher.register('toast', toastHandler);
|
|
331
|
+
dispatcher.register('refresh_query', refreshHandler);
|
|
332
|
+
|
|
333
|
+
const sm = new StateMachine(PM_PROJECT_EXPERIENCE, {}, {
|
|
334
|
+
evaluator,
|
|
335
|
+
actionHandlers: new Map(),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Wire on_event subscriptions
|
|
339
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
340
|
+
for (const sub of stateDef.on_event!) {
|
|
341
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
342
|
+
const ctx: ExpressionContext = {
|
|
343
|
+
...sm.stateData,
|
|
344
|
+
state_data: sm.stateData,
|
|
345
|
+
event: event.payload,
|
|
346
|
+
current_state: sm.currentState,
|
|
347
|
+
status: sm.status,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Check conditions
|
|
351
|
+
if (sub.conditions?.length) {
|
|
352
|
+
for (const condition of sub.conditions) {
|
|
353
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
354
|
+
if (!result.value) return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Simulate: task instance completed (backend emits domain event via WebSocket)
|
|
363
|
+
await eventBus.publish('stakeholder:s1:pm-task:instance.completed', {
|
|
364
|
+
instance_id: 'task-1',
|
|
365
|
+
from_state: 'in_progress',
|
|
366
|
+
to_state: 'done',
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Toast should have fired
|
|
370
|
+
expect(toastHandler).toHaveBeenCalledOnce();
|
|
371
|
+
expect(toastHandler).toHaveBeenCalledWith(
|
|
372
|
+
{ variant: 'success', message: 'Task completed!', duration: 3000 },
|
|
373
|
+
expect.objectContaining({ current_state: 'active' }),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// Refresh query should also fire
|
|
377
|
+
expect(refreshHandler).toHaveBeenCalledOnce();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('fires toast on task start with condition check', async () => {
|
|
381
|
+
const eventBus = new EventBus();
|
|
382
|
+
const dispatcher = new ActionDispatcher();
|
|
383
|
+
const toastHandler = vi.fn();
|
|
384
|
+
|
|
385
|
+
dispatcher.register('toast', toastHandler);
|
|
386
|
+
|
|
387
|
+
const sm = new StateMachine(PM_PROJECT_EXPERIENCE, {}, {
|
|
388
|
+
evaluator, actionHandlers: new Map(),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
392
|
+
for (const sub of stateDef.on_event!) {
|
|
393
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
394
|
+
const ctx: ExpressionContext = {
|
|
395
|
+
...sm.stateData,
|
|
396
|
+
state_data: sm.stateData,
|
|
397
|
+
event: event.payload,
|
|
398
|
+
current_state: sm.currentState,
|
|
399
|
+
status: sm.status,
|
|
400
|
+
};
|
|
401
|
+
if (sub.conditions?.length) {
|
|
402
|
+
for (const condition of sub.conditions) {
|
|
403
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
404
|
+
if (!result.value) return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Simulate: task transition (start)
|
|
412
|
+
await eventBus.publish('stakeholder:s1:pm-task:transition.completed', {
|
|
413
|
+
transition_name: 'start',
|
|
414
|
+
from_state: 'todo',
|
|
415
|
+
to_state: 'in_progress',
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Should get "Task started" toast (condition matches transition_name == 'start')
|
|
419
|
+
expect(toastHandler).toHaveBeenCalledWith(
|
|
420
|
+
{ variant: 'info', message: 'Task started', duration: 2000 },
|
|
421
|
+
expect.objectContaining({ current_state: 'active' }),
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('does NOT fire task-start toast for complete transition', async () => {
|
|
426
|
+
const eventBus = new EventBus();
|
|
427
|
+
const dispatcher = new ActionDispatcher();
|
|
428
|
+
const toastHandler = vi.fn();
|
|
429
|
+
|
|
430
|
+
dispatcher.register('toast', toastHandler);
|
|
431
|
+
|
|
432
|
+
const sm = new StateMachine(PM_PROJECT_EXPERIENCE, {}, {
|
|
433
|
+
evaluator, actionHandlers: new Map(),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
437
|
+
for (const sub of stateDef.on_event!) {
|
|
438
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
439
|
+
const ctx: ExpressionContext = {
|
|
440
|
+
...sm.stateData,
|
|
441
|
+
state_data: sm.stateData,
|
|
442
|
+
event: event.payload,
|
|
443
|
+
current_state: sm.currentState,
|
|
444
|
+
status: sm.status,
|
|
445
|
+
};
|
|
446
|
+
if (sub.conditions?.length) {
|
|
447
|
+
for (const condition of sub.conditions) {
|
|
448
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
449
|
+
if (!result.value) return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Simulate: task transition (complete) — condition is eq(event.transition_name, 'start')
|
|
457
|
+
await eventBus.publish('stakeholder:s1:pm-task:transition.completed', {
|
|
458
|
+
transition_name: 'complete',
|
|
459
|
+
from_state: 'in_progress',
|
|
460
|
+
to_state: 'done',
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Should NOT fire the "Task started" toast (transition_name != 'start')
|
|
464
|
+
expect(toastHandler).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('fires project completion celebration (toast + sound)', async () => {
|
|
468
|
+
const eventBus = new EventBus();
|
|
469
|
+
const dispatcher = new ActionDispatcher();
|
|
470
|
+
const toastHandler = vi.fn();
|
|
471
|
+
const soundHandler = vi.fn();
|
|
472
|
+
|
|
473
|
+
dispatcher.register('toast', toastHandler);
|
|
474
|
+
dispatcher.register('play_sound', soundHandler);
|
|
475
|
+
|
|
476
|
+
const sm = new StateMachine(PM_PROJECT_EXPERIENCE, {}, {
|
|
477
|
+
evaluator, actionHandlers: new Map(),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
481
|
+
for (const sub of stateDef.on_event!) {
|
|
482
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
483
|
+
const ctx: ExpressionContext = {
|
|
484
|
+
...sm.stateData, state_data: sm.stateData,
|
|
485
|
+
event: event.payload, current_state: sm.currentState, status: sm.status,
|
|
486
|
+
};
|
|
487
|
+
if (sub.conditions?.length) {
|
|
488
|
+
for (const condition of sub.conditions) {
|
|
489
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
490
|
+
if (!result.value) return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Simulate: project completed
|
|
498
|
+
await eventBus.publish('stakeholder:s1:pm-project:instance.completed', {
|
|
499
|
+
instance_id: 'project-1',
|
|
500
|
+
from_state: 'active',
|
|
501
|
+
to_state: 'completed',
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(toastHandler).toHaveBeenCalledWith(
|
|
505
|
+
{ variant: 'success', message: 'All tasks done — project completed!' },
|
|
506
|
+
expect.any(Object),
|
|
507
|
+
);
|
|
508
|
+
expect(soundHandler).toHaveBeenCalledWith(
|
|
509
|
+
{ src: '/sounds/success.mp3', volume: 0.6 },
|
|
510
|
+
expect.any(Object),
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// User Stats: XP Progression
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
describe('PM-User-Stats Progression', () => {
|
|
520
|
+
it('evaluates XP level conditions correctly', () => {
|
|
521
|
+
// Level system from blueprint:
|
|
522
|
+
// Level 1: 0 XP, Level 2: 100 XP, Level 3: 300 XP, Level 4: 600 XP
|
|
523
|
+
const cases = [
|
|
524
|
+
{ xp: 0, level: 1, expected_gte_100: false },
|
|
525
|
+
{ xp: 50, level: 1, expected_gte_100: false },
|
|
526
|
+
{ xp: 100, level: 2, expected_gte_100: true },
|
|
527
|
+
{ xp: 300, level: 3, expected_gte_100: true },
|
|
528
|
+
{ xp: 600, level: 4, expected_gte_100: true },
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
for (const c of cases) {
|
|
532
|
+
const ctx: ExpressionContext = {
|
|
533
|
+
total_xp: c.xp,
|
|
534
|
+
current_level: c.level,
|
|
535
|
+
};
|
|
536
|
+
const result = evaluator.evaluate('gte(total_xp, 100)', ctx);
|
|
537
|
+
expect(result.value).toBe(c.expected_gte_100);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('level-up condition evaluation chain works', () => {
|
|
542
|
+
// Test the progressive level conditions
|
|
543
|
+
const ctx: ExpressionContext = {
|
|
544
|
+
total_xp: 350,
|
|
545
|
+
current_level: 3,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// Should be level 3 (300-599 range)
|
|
549
|
+
expect(evaluator.evaluate('gte(total_xp, 300)', ctx).value).toBe(true);
|
|
550
|
+
expect(evaluator.evaluate('gte(total_xp, 600)', ctx).value).toBe(false);
|
|
551
|
+
|
|
552
|
+
// Level-up condition: total_xp >= next_threshold AND current_level < max_level
|
|
553
|
+
expect(evaluator.evaluate('gte(total_xp, 600) && lt(current_level, 8)', ctx).value).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Expression Engine: Blueprint expression patterns
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
describe('Blueprint Expression Patterns', () => {
|
|
562
|
+
it('supports event payload access via dot notation', () => {
|
|
563
|
+
const ctx: ExpressionContext = {
|
|
564
|
+
event: {
|
|
565
|
+
transition_name: 'start',
|
|
566
|
+
from_state: 'todo',
|
|
567
|
+
to_state: 'in_progress',
|
|
568
|
+
trigger: 'user',
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
expect(evaluator.evaluate('event.transition_name', ctx).value).toBe('start');
|
|
573
|
+
expect(evaluator.evaluate('event.from_state', ctx).value).toBe('todo');
|
|
574
|
+
expect(evaluator.evaluate("eq(event.transition_name, 'start')", ctx).value).toBe(true);
|
|
575
|
+
expect(evaluator.evaluate("eq(event.trigger, 'user')", ctx).value).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('supports $ prefix paths for domain references', () => {
|
|
579
|
+
const ctx: ExpressionContext = {
|
|
580
|
+
$instance: {
|
|
581
|
+
fields: { name: 'Test Task' },
|
|
582
|
+
memory: { xp: 25 },
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
expect(evaluator.evaluate('$instance.fields.name', ctx).value).toBe('Test Task');
|
|
587
|
+
expect(evaluator.evaluate('$instance.memory.xp', ctx).value).toBe(25);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('supports complex nested function calls for computed fields', () => {
|
|
591
|
+
const ctx: ExpressionContext = {
|
|
592
|
+
total_tasks: 10,
|
|
593
|
+
completed_tasks: 7,
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// Progress percentage: round(divide(completed_tasks, total_tasks) * 100)
|
|
597
|
+
// But our safe evaluator doesn't support * operator directly
|
|
598
|
+
// Instead: round(multiply(divide(completed_tasks, total_tasks), 100))
|
|
599
|
+
const result = evaluator.evaluate(
|
|
600
|
+
'round(multiply(divide(completed_tasks, total_tasks), 100))',
|
|
601
|
+
ctx,
|
|
602
|
+
);
|
|
603
|
+
expect(result.value).toBe(70);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('ternary expressions for conditional display', () => {
|
|
607
|
+
const ctx: ExpressionContext = { status: 'ACTIVE', priority: 'high' };
|
|
608
|
+
|
|
609
|
+
expect(evaluator.evaluate("status == 'ACTIVE' ? 'Running' : 'Stopped'", ctx).value)
|
|
610
|
+
.toBe('Running');
|
|
611
|
+
expect(evaluator.evaluate("priority == 'high' ? 'urgent' : 'normal'", ctx).value)
|
|
612
|
+
.toBe('urgent');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('template interpolation for dynamic messages', () => {
|
|
616
|
+
const ctx: ExpressionContext = {
|
|
617
|
+
name: 'Alice',
|
|
618
|
+
completed_tasks: 7,
|
|
619
|
+
total_tasks: 10,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const result = evaluator.evaluateTemplate(
|
|
623
|
+
'{{name}} completed {{completed_tasks}} of {{total_tasks}} tasks',
|
|
624
|
+
ctx,
|
|
625
|
+
);
|
|
626
|
+
expect(result.value).toBe('Alice completed 7 of 10 tasks');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('logical operators for multi-condition transitions', () => {
|
|
630
|
+
// Auto-complete: gt(completed_tasks, 0) && eq(completed_tasks, total_tasks)
|
|
631
|
+
const ctx: ExpressionContext = {
|
|
632
|
+
completed_tasks: 5,
|
|
633
|
+
total_tasks: 5,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
expect(evaluator.evaluate(
|
|
637
|
+
'gt(completed_tasks, 0) && eq(completed_tasks, total_tasks)',
|
|
638
|
+
ctx,
|
|
639
|
+
).value).toBe(true);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Full Pipeline: Event → Match → Condition → Action
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
describe('Full Pipeline Integration', () => {
|
|
648
|
+
it('complete blueprint cycle: create project → add tasks → complete tasks → auto-complete', async () => {
|
|
649
|
+
const actionLog: Array<{ type: string; config: Record<string, unknown> }> = [];
|
|
650
|
+
|
|
651
|
+
const eventBus = new EventBus();
|
|
652
|
+
const dispatcher = new ActionDispatcher();
|
|
653
|
+
|
|
654
|
+
// Register all action types
|
|
655
|
+
for (const type of ['toast', 'animate', 'refresh_query', 'play_sound', 'set_field']) {
|
|
656
|
+
dispatcher.register(type, (config: Record<string, unknown>) => {
|
|
657
|
+
actionLog.push({ type, config });
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Create the experience workflow
|
|
662
|
+
const sm = new StateMachine(PM_PROJECT_EXPERIENCE, {}, {
|
|
663
|
+
evaluator, actionHandlers: new Map(),
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Wire subscriptions
|
|
667
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
668
|
+
for (const sub of stateDef.on_event!) {
|
|
669
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
670
|
+
const ctx: ExpressionContext = {
|
|
671
|
+
...sm.stateData, state_data: sm.stateData,
|
|
672
|
+
event: event.payload, current_state: sm.currentState, status: sm.status,
|
|
673
|
+
};
|
|
674
|
+
if (sub.conditions?.length) {
|
|
675
|
+
for (const condition of sub.conditions) {
|
|
676
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
677
|
+
if (!result.value) return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Simulate: Task 1 starts
|
|
685
|
+
await eventBus.publish('stakeholder:s1:pm-task:transition.completed', {
|
|
686
|
+
transition_name: 'start', from_state: 'todo', to_state: 'in_progress',
|
|
687
|
+
});
|
|
688
|
+
expect(actionLog.some(a => a.type === 'toast' && a.config.message === 'Task started')).toBe(true);
|
|
689
|
+
|
|
690
|
+
// Simulate: Task 1 completes
|
|
691
|
+
await eventBus.publish('stakeholder:s1:pm-task:instance.completed', {
|
|
692
|
+
from_state: 'in_progress', to_state: 'done',
|
|
693
|
+
});
|
|
694
|
+
expect(actionLog.some(a => a.type === 'toast' && a.config.message === 'Task completed!')).toBe(true);
|
|
695
|
+
expect(actionLog.some(a => a.type === 'refresh_query')).toBe(true);
|
|
696
|
+
|
|
697
|
+
// Simulate: All tasks done — project auto-completes
|
|
698
|
+
actionLog.length = 0;
|
|
699
|
+
await eventBus.publish('stakeholder:s1:pm-project:instance.completed', {
|
|
700
|
+
from_state: 'active', to_state: 'completed',
|
|
701
|
+
});
|
|
702
|
+
expect(actionLog.some(a => a.type === 'toast' && (a.config.message as string).includes('project completed'))).toBe(true);
|
|
703
|
+
expect(actionLog.some(a => a.type === 'play_sound')).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
});
|