@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,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
+ });