@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,1382 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compile, tokenize, parse } from '../dsl';
3
+ import type {
4
+ CompilationResult,
5
+ IRWorkflowDefinition,
6
+ IRFieldDefinition,
7
+ IRExperienceNode,
8
+ } from '../dsl';
9
+ import { mapField } from '../dsl/compiler/field-mapper';
10
+ import {
11
+ mapContent,
12
+ mapStringLiteral,
13
+ mapIteration,
14
+ mapSection,
15
+ mapSearch,
16
+ mapNavigation,
17
+ mapPages,
18
+ } from '../dsl/compiler/component-mapper';
19
+ import { slugify, snakeCase, generateId, generateActionId } from '../dsl/compiler/utils';
20
+ import { transformExpression } from '../dsl/compiler/workflow-compiler';
21
+ import type { FieldDefData, ContentData, StringLiteralData, IterationData } from '../dsl';
22
+
23
+ // =============================================================================
24
+ // Helpers
25
+ // =============================================================================
26
+
27
+ function compileSource(source: string): CompilationResult {
28
+ return compile(source.trim());
29
+ }
30
+
31
+ function findWorkflow(result: CompilationResult, slug: string): IRWorkflowDefinition | undefined {
32
+ return result.workflows.find(w => w.slug === slug);
33
+ }
34
+
35
+ // =============================================================================
36
+ // Section 1: Utility Functions
37
+ // =============================================================================
38
+
39
+ describe('DSL Compiler — Utilities', () => {
40
+ it('slugifies names', () => {
41
+ expect(slugify('project management')).toBe('project-management');
42
+ expect(slugify('user stats')).toBe('user-stats');
43
+ expect(slugify('Task Board')).toBe('task-board');
44
+ });
45
+
46
+ it('snake_cases names', () => {
47
+ expect(snakeCase('total tasks')).toBe('total_tasks');
48
+ expect(snakeCase('started at')).toBe('started_at');
49
+ expect(snakeCase('xp reward')).toBe('xp_reward');
50
+ });
51
+
52
+ it('generates deterministic IDs', () => {
53
+ expect(generateId('project', 'draft')).toBe('project--draft');
54
+ expect(generateId('task', 'in progress')).toBe('task--in-progress');
55
+ });
56
+
57
+ it('generates action IDs', () => {
58
+ expect(generateActionId('active-on-enter', 0)).toBe('active-on-enter-0');
59
+ expect(generateActionId('draft-on-enter', 2)).toBe('draft-on-enter-2');
60
+ });
61
+ });
62
+
63
+ // =============================================================================
64
+ // Section 2: Field Mapping
65
+ // =============================================================================
66
+
67
+ describe('DSL Compiler — Field Mapping', () => {
68
+ function makeField(overrides: Partial<FieldDefData> = {}): FieldDefData {
69
+ return {
70
+ name: 'test field',
71
+ adjectives: [],
72
+ baseType: 'text',
73
+ constraints: [],
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ it('maps text field', () => {
79
+ const result = mapField(makeField({ baseType: 'text' }));
80
+ expect(result.type).toBe('text');
81
+ expect(result.name).toBe('test_field');
82
+ });
83
+
84
+ it('maps rich text field', () => {
85
+ const result = mapField(makeField({ baseType: 'rich text' }));
86
+ expect(result.type).toBe('rich_text');
87
+ });
88
+
89
+ it('maps number field', () => {
90
+ const result = mapField(makeField({ baseType: 'number' }));
91
+ expect(result.type).toBe('number');
92
+ });
93
+
94
+ it('maps integer field → number with validation rule', () => {
95
+ const result = mapField(makeField({ baseType: 'integer' }));
96
+ expect(result.type).toBe('number');
97
+ expect(result.validation?.rules).toEqual([
98
+ {
99
+ expression: 'eq(round($value), $value)',
100
+ message: 'Must be a whole number',
101
+ severity: 'error',
102
+ },
103
+ ]);
104
+ });
105
+
106
+ it('maps time field → datetime', () => {
107
+ const result = mapField(makeField({ baseType: 'time' }));
108
+ expect(result.type).toBe('datetime');
109
+ });
110
+
111
+ it('maps choice field → select with options', () => {
112
+ const result = mapField(makeField({
113
+ baseType: 'choice of [low, medium, high, critical]',
114
+ }));
115
+ expect(result.type).toBe('select');
116
+ expect(result.validation?.options).toEqual(['low', 'medium', 'high', 'critical']);
117
+ });
118
+
119
+ it('maps required adjective', () => {
120
+ const result = mapField(makeField({ adjectives: ['required'] }));
121
+ expect(result.required).toBe(true);
122
+ });
123
+
124
+ it('maps optional adjective', () => {
125
+ const result = mapField(makeField({ adjectives: ['optional'] }));
126
+ expect(result.required).toBe(false);
127
+ });
128
+
129
+ it('maps non-negative adjective → validation.min: 0', () => {
130
+ const result = mapField(makeField({
131
+ baseType: 'number',
132
+ adjectives: ['non-negative'],
133
+ }));
134
+ expect(result.validation?.min).toBe(0);
135
+ });
136
+
137
+ it('maps positive adjective → validation.min: 1', () => {
138
+ const result = mapField(makeField({
139
+ baseType: 'number',
140
+ adjectives: ['positive'],
141
+ }));
142
+ expect(result.validation?.min).toBe(1);
143
+ });
144
+
145
+ it('maps computed adjective', () => {
146
+ const result = mapField(makeField({ adjectives: ['computed'] }));
147
+ expect(result.computed).toBe('');
148
+ });
149
+
150
+ it('maps max constraint on text → maxLength', () => {
151
+ const result = mapField(makeField({
152
+ baseType: 'text',
153
+ constraints: [{ kind: 'max', value: 200 }],
154
+ }));
155
+ expect(result.validation?.maxLength).toBe(200);
156
+ });
157
+
158
+ it('maps max constraint on number → max', () => {
159
+ const result = mapField(makeField({
160
+ baseType: 'number',
161
+ constraints: [{ kind: 'max', value: 100 }],
162
+ }));
163
+ expect(result.validation?.max).toBe(100);
164
+ });
165
+
166
+ it('maps default constraint → default_value', () => {
167
+ const result = mapField(makeField({
168
+ constraints: [{ kind: 'default', value: 'hello' }],
169
+ }));
170
+ expect(result.default_value).toBe('hello');
171
+ });
172
+
173
+ it('maps numeric default', () => {
174
+ const result = mapField(makeField({
175
+ baseType: 'number',
176
+ constraints: [{ kind: 'default', value: 0 }],
177
+ }));
178
+ expect(result.default_value).toBe(0);
179
+ });
180
+
181
+ it('maps between constraint → min + max', () => {
182
+ const result = mapField(makeField({
183
+ baseType: 'number',
184
+ constraints: [{ kind: 'between', value: 1, value2: 5 }],
185
+ }));
186
+ expect(result.validation?.min).toBe(1);
187
+ expect(result.validation?.max).toBe(5);
188
+ });
189
+
190
+ it('maps combined: required text, max 200', () => {
191
+ const result = mapField(makeField({
192
+ adjectives: ['required'],
193
+ baseType: 'text',
194
+ constraints: [{ kind: 'max', value: 200 }],
195
+ }));
196
+ expect(result.required).toBe(true);
197
+ expect(result.type).toBe('text');
198
+ expect(result.validation?.maxLength).toBe(200);
199
+ });
200
+
201
+ it('maps combined: non-negative number, default 0', () => {
202
+ const result = mapField(makeField({
203
+ adjectives: ['non-negative'],
204
+ baseType: 'number',
205
+ constraints: [{ kind: 'default', value: 0 }],
206
+ }));
207
+ expect(result.validation?.min).toBe(0);
208
+ expect(result.default_value).toBe(0);
209
+ });
210
+
211
+ it('maps choice with required + default', () => {
212
+ const result = mapField(makeField({
213
+ adjectives: ['required'],
214
+ baseType: 'choice of [low, medium, high, critical]',
215
+ constraints: [{ kind: 'default', value: 'medium' }],
216
+ }));
217
+ expect(result.type).toBe('select');
218
+ expect(result.required).toBe(true);
219
+ expect(result.default_value).toBe('medium');
220
+ expect(result.validation?.options).toEqual(['low', 'medium', 'high', 'critical']);
221
+ });
222
+ });
223
+
224
+ // =============================================================================
225
+ // Section 3: Component Mapping
226
+ // =============================================================================
227
+
228
+ describe('DSL Compiler — Component Mapping', () => {
229
+ it('maps content with big emphasis → Text heading', () => {
230
+ const node = mapContent(
231
+ { pronoun: 'its', field: 'name', emphasis: 'big' } as ContentData,
232
+ false, 'root', 0,
233
+ );
234
+ expect(node.component).toBe('Text');
235
+ expect(node.config).toEqual({ variant: 'heading' });
236
+ expect(node.bindings?.value).toBe('$instance.state_data.name');
237
+ });
238
+
239
+ it('maps content with small emphasis → Text caption', () => {
240
+ const node = mapContent(
241
+ { pronoun: 'its', field: 'date', emphasis: 'small' } as ContentData,
242
+ false, 'root', 0,
243
+ );
244
+ expect(node.component).toBe('Text');
245
+ expect(node.config).toEqual({ variant: 'caption' });
246
+ });
247
+
248
+ it('maps content with tag role → Badge', () => {
249
+ const node = mapContent(
250
+ { pronoun: 'its', field: 'priority', role: 'tag' } as ContentData,
251
+ false, 'root', 0,
252
+ );
253
+ expect(node.component).toBe('Badge');
254
+ });
255
+
256
+ it('maps content with progress role → ProgressTracker', () => {
257
+ const node = mapContent(
258
+ { pronoun: 'its', field: 'health', role: 'progress' } as ContentData,
259
+ false, 'root', 0,
260
+ );
261
+ expect(node.component).toBe('ProgressTracker');
262
+ });
263
+
264
+ it('maps content with label → Text with label config', () => {
265
+ const node = mapContent(
266
+ { field: 'total xp', label: 'Total XP' } as ContentData,
267
+ false, 'root', 0,
268
+ );
269
+ expect(node.component).toBe('Text');
270
+ expect(node.config?.label).toBe('Total XP');
271
+ });
272
+
273
+ it('maps content inside each → $item binding scope', () => {
274
+ const node = mapContent(
275
+ { pronoun: 'its', field: 'name' } as ContentData,
276
+ true, 'root', 0,
277
+ );
278
+ expect(node.bindings?.value).toBe('$item.state_data.name');
279
+ });
280
+
281
+ it('maps content for "state" field → $instance.current_state', () => {
282
+ const node = mapContent(
283
+ { pronoun: 'its', field: 'state', role: 'tag' } as ContentData,
284
+ false, 'root', 0,
285
+ );
286
+ expect(node.bindings?.value).toBe('$instance.current_state');
287
+ });
288
+
289
+ it('maps string literal → Text', () => {
290
+ const node = mapStringLiteral(
291
+ { text: 'Projects', emphasis: 'big' } as StringLiteralData,
292
+ 'root', 0,
293
+ );
294
+ expect(node.component).toBe('Text');
295
+ expect(node.bindings?.value).toBe('"Projects"');
296
+ expect(node.config).toEqual({ variant: 'heading' });
297
+ });
298
+
299
+ it('maps iteration as card → Each wrapping Card', () => {
300
+ const children = [{ id: 'child-1', component: 'Text' }];
301
+ const node = mapIteration(
302
+ { subject: 'project', role: 'card' } as IterationData,
303
+ children, 'root', 0,
304
+ );
305
+ expect(node.component).toBe('Each');
306
+ expect(node.children?.[0].component).toBe('Card');
307
+ expect(node.children?.[0].children).toEqual(children);
308
+ });
309
+
310
+ it('maps search → SearchInput', () => {
311
+ const node = mapSearch({ target: 'my projects' }, 'root', 0);
312
+ expect(node.component).toBe('SearchInput');
313
+ expect(node.config?.target).toBe('my projects');
314
+ });
315
+
316
+ it('maps pages → Pagination', () => {
317
+ const node = mapPages('root', 0);
318
+ expect(node.component).toBe('Pagination');
319
+ });
320
+
321
+ it('maps numbers section → MetricsGrid', () => {
322
+ const node = mapSection({ name: 'numbers' }, [], 'root');
323
+ expect(node.component).toBe('MetricsGrid');
324
+ });
325
+
326
+ it('maps tabs section → TabbedLayout', () => {
327
+ const node = mapSection({ name: 'tabs' }, [], 'root');
328
+ expect(node.component).toBe('TabbedLayout');
329
+ });
330
+
331
+ it('maps actions section → TransitionActions', () => {
332
+ const node = mapSection({ name: 'actions' }, [], 'root');
333
+ expect(node.component).toBe('TransitionActions');
334
+ });
335
+
336
+ it('maps navigation → Link with onClick binding', () => {
337
+ const node = mapNavigation(
338
+ { trigger: 'tap', target: '/projects/{its id}' },
339
+ false, 'root', 0,
340
+ );
341
+ expect(node.component).toBe('Link');
342
+ expect(node.bindings?.onClick).toContain('$action.navigate');
343
+ });
344
+
345
+ it('maps navigation inside each → $item scope', () => {
346
+ const node = mapNavigation(
347
+ { trigger: 'tap', target: '/projects/{its id}' },
348
+ true, 'root', 0,
349
+ );
350
+ expect(node.bindings?.onClick).toContain('$item.id');
351
+ });
352
+ });
353
+
354
+ // =============================================================================
355
+ // Section 4: Workflow Compilation
356
+ // =============================================================================
357
+
358
+ describe('DSL Compiler — Workflow Compilation', () => {
359
+ it('compiles minimal workflow', () => {
360
+ const result = compileSource(`
361
+ a todo @1.0.0
362
+ starts at open
363
+ open
364
+ closed, final
365
+ `);
366
+ expect(result.errors).toHaveLength(0);
367
+ const wf = findWorkflow(result, 'todo');
368
+ expect(wf).toBeDefined();
369
+ expect(wf!.slug).toBe('todo');
370
+ expect(wf!.version).toBe('1.0.0');
371
+ expect(wf!.states).toHaveLength(2);
372
+ });
373
+
374
+ it('generates correct slug from thing name', () => {
375
+ const result = compileSource(`
376
+ a user stats @1.0.0
377
+ starts at active
378
+ active
379
+ `);
380
+ const wf = findWorkflow(result, 'user-stats');
381
+ expect(wf).toBeDefined();
382
+ expect(wf!.name).toBe('user stats');
383
+ });
384
+
385
+ it('passes through version', () => {
386
+ const result = compileSource(`
387
+ a task @2.1.0
388
+ starts at open
389
+ open
390
+ `);
391
+ expect(findWorkflow(result, 'task')!.version).toBe('2.1.0');
392
+ });
393
+
394
+ it('compiles fields via field mapper', () => {
395
+ const result = compileSource(`
396
+ a project @1.0.0
397
+ name as required text, max 200
398
+ count as non-negative number, default 0
399
+ starts at draft
400
+ draft
401
+ `);
402
+ const wf = findWorkflow(result, 'project')!;
403
+ expect(wf.fields).toHaveLength(2);
404
+ expect(wf.fields[0]).toMatchObject({
405
+ name: 'name',
406
+ type: 'text',
407
+ required: true,
408
+ validation: { maxLength: 200 },
409
+ });
410
+ expect(wf.fields[1]).toMatchObject({
411
+ name: 'count',
412
+ type: 'number',
413
+ default_value: 0,
414
+ validation: { min: 0 },
415
+ });
416
+ });
417
+
418
+ it('resolves state type: starts_at target → START', () => {
419
+ const result = compileSource(`
420
+ a task @1.0.0
421
+ starts at draft
422
+ draft
423
+ done, final
424
+ `);
425
+ const wf = findWorkflow(result, 'task')!;
426
+ const draft = wf.states.find(s => s.name === 'draft');
427
+ expect(draft!.type).toBe('START');
428
+ });
429
+
430
+ it('resolves state type: final → END', () => {
431
+ const result = compileSource(`
432
+ a task @1.0.0
433
+ starts at open
434
+ open
435
+ done, final
436
+ `);
437
+ const done = findWorkflow(result, 'task')!.states.find(s => s.name === 'done');
438
+ expect(done!.type).toBe('END');
439
+ });
440
+
441
+ it('resolves state type: cancelled + final → CANCELLED', () => {
442
+ const result = compileSource(`
443
+ a task @1.0.0
444
+ starts at open
445
+ open
446
+ cancelled, final
447
+ `);
448
+ const cancelled = findWorkflow(result, 'task')!.states.find(s => s.name === 'cancelled');
449
+ expect(cancelled!.type).toBe('CANCELLED');
450
+ });
451
+
452
+ it('resolves state type: regular (no markers)', () => {
453
+ const result = compileSource(`
454
+ a task @1.0.0
455
+ starts at open
456
+ open
457
+ in progress
458
+ done, final
459
+ `);
460
+ const inProgress = findWorkflow(result, 'task')!.states.find(s => s.name === 'in progress');
461
+ expect(inProgress!.type).toBe('REGULAR');
462
+ });
463
+
464
+ it('compiles on_enter: set action → set_field', () => {
465
+ const result = compileSource(`
466
+ a task @1.0.0
467
+ started at as time
468
+ starts at open
469
+ open
470
+ when entered
471
+ set started at = now()
472
+ done, final
473
+ `);
474
+ const open = findWorkflow(result, 'task')!.states.find(s => s.name === 'open');
475
+ expect(open!.on_enter).toHaveLength(1);
476
+ expect(open!.on_enter[0]).toMatchObject({
477
+ type: 'set_field',
478
+ mode: 'auto',
479
+ config: {
480
+ field: 'started_at',
481
+ expression: 'now()',
482
+ },
483
+ });
484
+ });
485
+
486
+ it('compiles on_enter: multiple set actions', () => {
487
+ const result = compileSource(`
488
+ a task @1.0.0
489
+ started at as time
490
+ status label as text
491
+ starts at open
492
+ open
493
+ when entered
494
+ set started at = now()
495
+ set status label = "ACTIVE"
496
+ done, final
497
+ `);
498
+ const open = findWorkflow(result, 'task')!.states.find(s => s.name === 'open');
499
+ expect(open!.on_enter).toHaveLength(2);
500
+ expect(open!.on_enter[0].config.field).toBe('started_at');
501
+ expect(open!.on_enter[1].config.field).toBe('status_label');
502
+ expect(open!.on_enter[1].config.expression).toBe('"ACTIVE"');
503
+ });
504
+
505
+ it('compiles on_event: receives event subscription', () => {
506
+ const result = compileSource(`
507
+ a project @1.0.0
508
+ total tasks as number, default 0
509
+ starts at active
510
+ active
511
+ when receives "task created" from task
512
+ set total tasks = total tasks + 1
513
+ `);
514
+ const active = findWorkflow(result, 'project')!.states.find(s => s.name === 'active');
515
+ expect(active!.on_event).toHaveLength(1);
516
+ expect(active!.on_event![0].match).toContain('task');
517
+ expect(active!.on_event![0].match).toContain('instance.task.created');
518
+ expect(active!.on_event![0].actions).toHaveLength(1);
519
+ expect(active!.on_event![0].actions[0].type).toBe('set_field');
520
+ expect(active!.on_event![0].actions[0].field).toBe('total_tasks');
521
+ });
522
+
523
+ it('compiles on_event: set action with $event references', () => {
524
+ const result = compileSource(`
525
+ a stats @1.0.0
526
+ total xp as number, default 0
527
+ starts at active
528
+ active
529
+ when receives "task completed" from task
530
+ set total xp = total xp + the event's xp reward
531
+ `);
532
+ const active = findWorkflow(result, 'stats')!.states.find(s => s.name === 'active');
533
+ const action = active!.on_event![0].actions[0];
534
+ expect(action.expression).toContain('$event.state_data.xp_reward');
535
+ });
536
+
537
+ it('compiles transition: basic verb → target', () => {
538
+ const result = compileSource(`
539
+ a task @1.0.0
540
+ starts at open
541
+ open
542
+ can close → closed
543
+ closed, final
544
+ `);
545
+ const wf = findWorkflow(result, 'task')!;
546
+ expect(wf.transitions).toHaveLength(1);
547
+ expect(wf.transitions[0]).toMatchObject({
548
+ name: 'close',
549
+ from: ['open'],
550
+ to: 'closed',
551
+ });
552
+ });
553
+
554
+ it('compiles transition: guard → roles', () => {
555
+ const result = compileSource(`
556
+ a task @1.0.0
557
+ starts at open
558
+ open
559
+ can approve → approved, admin only
560
+ approved, final
561
+ `);
562
+ const transition = findWorkflow(result, 'task')!.transitions[0];
563
+ expect(transition.roles).toEqual(['admin']);
564
+ });
565
+
566
+ it('compiles transition: auto prefix → auto: true', () => {
567
+ const result = compileSource(`
568
+ a project @1.0.0
569
+ starts at draft
570
+ draft
571
+ can auto complete → done
572
+ done, final
573
+ `);
574
+ const transition = findWorkflow(result, 'project')!.transitions[0];
575
+ expect(transition.auto).toBe(true);
576
+ expect(transition.name).toBe('auto-complete');
577
+ });
578
+
579
+ it('compiles transition: with when condition', () => {
580
+ const result = compileSource(`
581
+ a project @1.0.0
582
+ count as number
583
+ starts at active
584
+ active
585
+ can finish → done
586
+ when count > 0
587
+ done, final
588
+ `);
589
+ const transition = findWorkflow(result, 'project')!.transitions[0];
590
+ expect(transition.conditions).toBeDefined();
591
+ expect(transition.conditions).toHaveLength(1);
592
+ expect(transition.conditions![0]).toMatchObject({
593
+ field: 'state_data.count',
594
+ operator: 'gt',
595
+ value: 0,
596
+ });
597
+ });
598
+
599
+ it('compiles transition: compound condition (and)', () => {
600
+ const result = compileSource(`
601
+ a project @1.0.0
602
+ completed tasks as number
603
+ total tasks as number
604
+ starts at active
605
+ active
606
+ can auto complete → done
607
+ when completed tasks >= total tasks and total tasks > 0
608
+ done, final
609
+ `);
610
+ const transition = findWorkflow(result, 'project')!.transitions[0];
611
+ expect(transition.conditions).toHaveLength(1);
612
+ const cond = transition.conditions![0];
613
+ expect(cond.AND).toBeDefined();
614
+ expect(cond.AND).toHaveLength(2);
615
+ expect(cond.AND![0]).toMatchObject({
616
+ field: 'state_data.completed_tasks',
617
+ operator: 'gte',
618
+ });
619
+ expect(cond.AND![1]).toMatchObject({
620
+ field: 'state_data.total_tasks',
621
+ operator: 'gt',
622
+ value: 0,
623
+ });
624
+ });
625
+
626
+ it('compiles multiple transitions from one state', () => {
627
+ const result = compileSource(`
628
+ a task @1.0.0
629
+ starts at open
630
+ open
631
+ can complete → done
632
+ can cancel → cancelled
633
+ done, final
634
+ cancelled, final
635
+ `);
636
+ const wf = findWorkflow(result, 'task')!;
637
+ expect(wf.transitions).toHaveLength(2);
638
+ expect(wf.transitions[0].name).toBe('complete');
639
+ expect(wf.transitions[1].name).toBe('cancel');
640
+ });
641
+
642
+ it('collects roles from guards', () => {
643
+ const result = compileSource(`
644
+ a task @1.0.0
645
+ starts at open
646
+ open
647
+ can approve → done, admin only
648
+ can reject → cancelled, admin only
649
+ done, final
650
+ cancelled, final
651
+ `);
652
+ const wf = findWorkflow(result, 'task')!;
653
+ expect(wf.roles).toHaveLength(1);
654
+ expect(wf.roles[0].name).toBe('admin');
655
+ });
656
+
657
+ it('compiles tags', () => {
658
+ const result = compileSource(`
659
+ a task @1.0.0
660
+ tagged: project-management, mvp
661
+ starts at open
662
+ open
663
+ `);
664
+ const wf = findWorkflow(result, 'task')!;
665
+ expect(wf.tags).toEqual([
666
+ { tag_name: 'project-management' },
667
+ { tag_name: 'mvp' },
668
+ ]);
669
+ });
670
+
671
+ it('compiles levels → metadata.levels', () => {
672
+ const result = compileSource(`
673
+ a stats @1.0.0
674
+ starts at active
675
+ levels
676
+ 1: "Newcomer", from 0 xp
677
+ 2: "Veteran", from 100 xp
678
+ active
679
+ `);
680
+ const wf = findWorkflow(result, 'stats')!;
681
+ expect(wf.metadata?.levels).toEqual([
682
+ { level: 1, title: 'Newcomer', fromXp: 0 },
683
+ { level: 2, title: 'Veteran', fromXp: 100 },
684
+ ]);
685
+ });
686
+
687
+ it('reports error for missing starts_at', () => {
688
+ const result = compileSource(`
689
+ a task @1.0.0
690
+ open
691
+ closed, final
692
+ `);
693
+ expect(result.errors.length).toBeGreaterThan(0);
694
+ expect(result.errors[0].code).toBe('MISSING_STARTS_AT');
695
+ });
696
+
697
+ it('warns for transition to unknown state', () => {
698
+ const result = compileSource(`
699
+ a task @1.0.0
700
+ starts at open
701
+ open
702
+ can go → nonexistent
703
+ `);
704
+ expect(result.warnings.length).toBeGreaterThan(0);
705
+ expect(result.warnings[0].code).toBe('UNKNOWN_TARGET_STATE');
706
+ });
707
+ });
708
+
709
+ // =============================================================================
710
+ // Section 5: Expression Transformation
711
+ // =============================================================================
712
+
713
+ describe('DSL Compiler — Expression Transformation', () => {
714
+ const fields = new Set(['total tasks', 'completed tasks', 'total xp', 'xp reward', 'started at']);
715
+
716
+ it('transforms field ref → state_data.snake', () => {
717
+ expect(transformExpression('total tasks', fields)).toBe('state_data.total_tasks');
718
+ });
719
+
720
+ it('transforms "the event\'s X" → $event.state_data.snake', () => {
721
+ expect(transformExpression("the event's xp reward", fields))
722
+ .toBe('$event.state_data.xp_reward');
723
+ });
724
+
725
+ it('transforms arithmetic + → add()', () => {
726
+ expect(transformExpression('total tasks + 1', fields))
727
+ .toBe('add(state_data.total_tasks, 1)');
728
+ });
729
+
730
+ it('transforms arithmetic with event ref', () => {
731
+ expect(transformExpression("total xp + the event's xp reward", fields))
732
+ .toBe('add(state_data.total_xp, $event.state_data.xp_reward)');
733
+ });
734
+
735
+ it('transforms now() passthrough', () => {
736
+ expect(transformExpression('now()', fields)).toBe('now()');
737
+ });
738
+
739
+ it('transforms string literal passthrough', () => {
740
+ expect(transformExpression('"IN_PROGRESS"', fields)).toBe('"IN_PROGRESS"');
741
+ });
742
+
743
+ it('transforms numeric addition', () => {
744
+ expect(transformExpression('total xp + 50', fields))
745
+ .toBe('add(state_data.total_xp, 50)');
746
+ });
747
+ });
748
+
749
+ // =============================================================================
750
+ // Section 6: View Compilation
751
+ // =============================================================================
752
+
753
+ describe('DSL Compiler — View Compilation', () => {
754
+ it('compiles a fragment', () => {
755
+ const result = compileSource(`
756
+ a project card:
757
+ its name, big
758
+ its priority as tag
759
+ `);
760
+ expect(result.experiences).toHaveLength(1);
761
+ expect(result.experiences[0].slug).toBe('project-card');
762
+ expect(result.experiences[0].view_definition.children).toBeDefined();
763
+ });
764
+
765
+ it('compiles a view with data source', () => {
766
+ const result = compileSource(`
767
+ project list
768
+ my projects from project
769
+ newest first
770
+ 20 at a time
771
+ `);
772
+ expect(result.experiences).toHaveLength(1);
773
+ const exp = result.experiences[0];
774
+ expect(exp.slug).toBe('project-list');
775
+ expect(exp.view_definition.dataSources).toBeDefined();
776
+
777
+ const ds = exp.view_definition.dataSources![0];
778
+ expect(ds.type).toBe('workflow');
779
+ if (ds.type === 'workflow') {
780
+ expect(ds.name).toBe('projects');
781
+ expect(ds.slug).toBe('project');
782
+ expect(ds.query).toBe('list');
783
+ expect(ds.sort).toBe('created_at:desc');
784
+ expect(ds.paginated).toBe(true);
785
+ expect(ds.pageSize).toBe(20);
786
+ }
787
+ });
788
+
789
+ it('compiles data source: this X → latest query', () => {
790
+ const result = compileSource(`
791
+ task view
792
+ this task from task
793
+ `);
794
+ const ds = result.experiences[0].view_definition.dataSources![0];
795
+ if (ds.type === 'workflow') {
796
+ expect(ds.query).toBe('latest');
797
+ expect(ds.name).toBe('task');
798
+ }
799
+ });
800
+
801
+ it('compiles data source: scope → parentInstanceId', () => {
802
+ const result = compileSource(`
803
+ project view
804
+ its tasks from task for this project
805
+ `);
806
+ const ds = result.experiences[0].view_definition.dataSources![0];
807
+ if (ds.type === 'workflow') {
808
+ expect(ds.parentInstanceId).toBe('{{ parent_instance_id }}');
809
+ }
810
+ });
811
+
812
+ it('compiles data source: searchable by → searchFields', () => {
813
+ const result = compileSource(`
814
+ project list
815
+ my projects from project
816
+ searchable by name and description
817
+ `);
818
+ const ds = result.experiences[0].view_definition.dataSources![0];
819
+ if (ds.type === 'workflow') {
820
+ expect(ds.searchFields).toEqual(['name', 'description']);
821
+ }
822
+ });
823
+
824
+ it('compiles data source: filterable by → facets', () => {
825
+ const result = compileSource(`
826
+ project list
827
+ my projects from project
828
+ filterable by priority
829
+ `);
830
+ const ds = result.experiences[0].view_definition.dataSources![0];
831
+ if (ds.type === 'workflow') {
832
+ expect(ds.facets).toEqual(['priority']);
833
+ }
834
+ });
835
+
836
+ it('compiles content nodes in view', () => {
837
+ const result = compileSource(`
838
+ task view
839
+ this task from task
840
+ its title, big
841
+ its state as tag
842
+ `);
843
+ const children = result.experiences[0].view_definition.children!;
844
+ expect(children.length).toBeGreaterThanOrEqual(2);
845
+ // First should be Text heading
846
+ const titleNode = children.find(c => c.config?.variant === 'heading');
847
+ expect(titleNode?.component).toBe('Text');
848
+ // Second should be Badge
849
+ const tagNode = children.find(c => c.component === 'Badge');
850
+ expect(tagNode).toBeDefined();
851
+ });
852
+
853
+ it('compiles iteration in view', () => {
854
+ const result = compileSource(`
855
+ project list
856
+ my projects from project
857
+ each project as card
858
+ its name, big
859
+ its priority as tag
860
+ `);
861
+ const children = result.experiences[0].view_definition.children!;
862
+ const eachNode = children.find(c => c.component === 'Each');
863
+ expect(eachNode).toBeDefined();
864
+ expect(eachNode!.children![0].component).toBe('Card');
865
+ });
866
+
867
+ it('compiles iteration children with $item binding scope', () => {
868
+ const result = compileSource(`
869
+ project list
870
+ my projects from project
871
+ each project as card
872
+ its name, big
873
+ `);
874
+ const eachNode = result.experiences[0].view_definition.children!
875
+ .find(c => c.component === 'Each')!;
876
+ const card = eachNode.children![0];
877
+ const nameNode = card.children![0];
878
+ expect(nameNode.bindings?.value).toBe('$item.state_data.name');
879
+ });
880
+
881
+ it('compiles string literals', () => {
882
+ const result = compileSource(`
883
+ project list
884
+ my projects from project
885
+ "Projects", big
886
+ "Manage your projects", small
887
+ `);
888
+ const children = result.experiences[0].view_definition.children!;
889
+ const heading = children.find(
890
+ c => c.component === 'Text' && c.config?.variant === 'heading',
891
+ );
892
+ expect(heading?.bindings?.value).toBe('"Projects"');
893
+ });
894
+
895
+ it('compiles search node', () => {
896
+ const result = compileSource(`
897
+ project list
898
+ my projects from project
899
+ search my projects
900
+ `);
901
+ const children = result.experiences[0].view_definition.children!;
902
+ const searchNode = children.find(c => c.component === 'SearchInput');
903
+ expect(searchNode).toBeDefined();
904
+ });
905
+
906
+ it('compiles pages node', () => {
907
+ const result = compileSource(`
908
+ project list
909
+ my projects from project
910
+ pages
911
+ `);
912
+ const children = result.experiences[0].view_definition.children!;
913
+ const pagesNode = children.find(c => c.component === 'Pagination');
914
+ expect(pagesNode).toBeDefined();
915
+ });
916
+
917
+ it('compiles numbers section → MetricsGrid', () => {
918
+ const result = compileSource(`
919
+ task view
920
+ this task from task
921
+ numbers
922
+ its xp as "XP Reward"
923
+ its started at as "Started"
924
+ `);
925
+ const children = result.experiences[0].view_definition.children!;
926
+ const metricsNode = children.find(c => c.component === 'MetricsGrid');
927
+ expect(metricsNode).toBeDefined();
928
+ expect(metricsNode!.children!.length).toBeGreaterThanOrEqual(2);
929
+ });
930
+
931
+ it('compiles tabs section → TabbedLayout', () => {
932
+ const result = compileSource(`
933
+ project view
934
+ this project from project
935
+ tabs
936
+ "Board" → task board
937
+ `);
938
+ const children = result.experiences[0].view_definition.children!;
939
+ const tabsNode = children.find(c => c.component === 'TabbedLayout');
940
+ expect(tabsNode).toBeDefined();
941
+ });
942
+
943
+ it('compiles navigation in view', () => {
944
+ const result = compileSource(`
945
+ project list
946
+ my projects from project
947
+ each project as card
948
+ its name, big
949
+ tap → /projects/{its id}
950
+ `);
951
+ const eachNode = result.experiences[0].view_definition.children!
952
+ .find(c => c.component === 'Each')!;
953
+ const card = eachNode.children![0];
954
+ const navNode = card.children!.find(c => c.component === 'Link');
955
+ expect(navNode).toBeDefined();
956
+ expect(navNode!.bindings!.onClick).toContain('$action.navigate');
957
+ });
958
+
959
+ it('compiles grouping in view', () => {
960
+ const result = compileSource(`
961
+ task board
962
+ these tasks from task for this project
963
+ tasks by state
964
+ `);
965
+ const children = result.experiences[0].view_definition.children!;
966
+ const groupNode = children.find(c => c.config?.groupBy === 'state');
967
+ expect(groupNode).toBeDefined();
968
+ });
969
+
970
+ it('infers stack layout for multiple siblings', () => {
971
+ const result = compileSource(`
972
+ task view
973
+ this task from task
974
+ its title, big
975
+ its state as tag
976
+ its priority as tag
977
+ `);
978
+ expect(result.experiences[0].view_definition.layout).toBe('stack');
979
+ });
980
+
981
+ it('tracks workflow slugs from data sources', () => {
982
+ const result = compileSource(`
983
+ project view
984
+ this project from project
985
+ its tasks from task for this project
986
+ `);
987
+ expect(result.experiences[0].workflows).toContain('project');
988
+ expect(result.experiences[0].workflows).toContain('task');
989
+ });
990
+ });
991
+
992
+ // =============================================================================
993
+ // Section 7: Manifest Compilation
994
+ // =============================================================================
995
+
996
+ describe('DSL Compiler — Manifest Compilation', () => {
997
+ it('compiles space with thing refs → manifest.workflows', () => {
998
+ const result = compileSource(`
999
+ project management @1.0.0
1000
+ things
1001
+ a project (primary)
1002
+ a task (child)
1003
+ user stats (derived)
1004
+ `);
1005
+ expect(result.manifest).toBeDefined();
1006
+ expect(result.manifest!.workflows).toHaveLength(3);
1007
+ expect(result.manifest!.workflows).toContainEqual({ slug: 'project', role: 'primary' });
1008
+ expect(result.manifest!.workflows).toContainEqual({ slug: 'task', role: 'child' });
1009
+ expect(result.manifest!.workflows).toContainEqual({ slug: 'user-stats', role: 'derived' });
1010
+ });
1011
+
1012
+ it('compiles experience_id from space name', () => {
1013
+ const result = compileSource(`
1014
+ project management @1.0.0
1015
+ things
1016
+ a project (primary)
1017
+ `);
1018
+ expect(result.manifest!.experience_id).toBe('project-management');
1019
+ });
1020
+
1021
+ it('compiles paths → routes with node slugs', () => {
1022
+ const result = compileSource(`
1023
+ project management @1.0.0
1024
+ paths
1025
+ /projects → project list (user)
1026
+ /projects/:id → project view (project)
1027
+ `);
1028
+ expect(result.manifest!.routes).toHaveLength(2);
1029
+ expect(result.manifest!.routes![0]).toMatchObject({
1030
+ path: '/projects',
1031
+ node: 'project-list',
1032
+ entityType: 'user',
1033
+ entityIdSource: 'user',
1034
+ });
1035
+ expect(result.manifest!.routes![1]).toMatchObject({
1036
+ path: '/projects/:id',
1037
+ node: 'project-view',
1038
+ entityType: 'project',
1039
+ entityIdSource: 'param',
1040
+ });
1041
+ });
1042
+
1043
+ it('sets entityIdSource: user for user context', () => {
1044
+ const result = compileSource(`
1045
+ project management @1.0.0
1046
+ paths
1047
+ /profile → user profile (user)
1048
+ `);
1049
+ expect(result.manifest!.routes![0].entityIdSource).toBe('user');
1050
+ });
1051
+
1052
+ it('sets entityIdSource: param for path with :id', () => {
1053
+ const result = compileSource(`
1054
+ project management @1.0.0
1055
+ paths
1056
+ /tasks/:id → task view (task)
1057
+ `);
1058
+ expect(result.manifest!.routes![0].entityIdSource).toBe('param');
1059
+ });
1060
+
1061
+ it('produces no manifest without space', () => {
1062
+ const result = compileSource(`
1063
+ a task @1.0.0
1064
+ starts at open
1065
+ open
1066
+ `);
1067
+ expect(result.manifest).toBeUndefined();
1068
+ });
1069
+
1070
+ it('handles space with no paths', () => {
1071
+ const result = compileSource(`
1072
+ project management @1.0.0
1073
+ things
1074
+ a project (primary)
1075
+ `);
1076
+ expect(result.manifest).toBeDefined();
1077
+ // routes may be undefined or empty
1078
+ });
1079
+
1080
+ it('handles space with no things', () => {
1081
+ const result = compileSource(`
1082
+ project management @1.0.0
1083
+ paths
1084
+ /projects → project list (user)
1085
+ `);
1086
+ expect(result.manifest).toBeDefined();
1087
+ expect(result.manifest!.workflows).toHaveLength(0);
1088
+ });
1089
+ });
1090
+
1091
+ // =============================================================================
1092
+ // Section 8: Full Blueprint E2E
1093
+ // =============================================================================
1094
+
1095
+ describe('DSL Compiler — Full Blueprint E2E', () => {
1096
+ const BLUEPRINT = `
1097
+ # ═══════════════════════════════════════════════════
1098
+ # Space
1099
+ # ═══════════════════════════════════════════════════
1100
+
1101
+ project management @1.0.0
1102
+ tagged: project-management, blueprint-mvp1
1103
+
1104
+ things
1105
+ a project (primary)
1106
+ a task (child)
1107
+ user stats (derived)
1108
+
1109
+ paths
1110
+ /projects → project list (user)
1111
+ /projects/:id → project view (project)
1112
+ /projects/:id/board → task board (project)
1113
+ /tasks/:id → task view (task)
1114
+ /profile → user profile (user)
1115
+
1116
+
1117
+ # ═══════════════════════════════════════════════════
1118
+ # Things (Workflows)
1119
+ # ═══════════════════════════════════════════════════
1120
+
1121
+ a project @1.0.0
1122
+ tagged: project-management, blueprint-mvp1
1123
+
1124
+ name as required text, max 200
1125
+ description as rich text
1126
+ priority as required choice of [low, medium, high, critical], default medium
1127
+ total tasks as non-negative number, default 0
1128
+ completed tasks as non-negative number, default 0
1129
+ started at as time
1130
+ completed at as time
1131
+
1132
+ starts at draft
1133
+
1134
+ draft
1135
+ can activate → active
1136
+
1137
+ active
1138
+ when entered
1139
+ set started at = now()
1140
+
1141
+ when receives "task created" from task
1142
+ set total tasks = total tasks + 1
1143
+
1144
+ when receives "task completed" from task
1145
+ set completed tasks = completed tasks + 1
1146
+
1147
+ can auto complete → completed
1148
+ when completed tasks >= total tasks and total tasks > 0
1149
+ can manual complete → completed, admin only
1150
+ can cancel → cancelled
1151
+
1152
+ completed, final
1153
+ when entered
1154
+ set completed at = now()
1155
+
1156
+ cancelled, final
1157
+
1158
+
1159
+ a task @1.0.0
1160
+ tagged: project-management, blueprint-mvp1
1161
+
1162
+ title as required text, max 200
1163
+ description as rich text
1164
+ priority as required choice of [low, medium, high, critical], default medium
1165
+ status label as computed text
1166
+ xp reward as non-negative number, default 25
1167
+ assignee as text
1168
+ started at as time
1169
+ completed at as time
1170
+
1171
+ starts at todo
1172
+
1173
+ todo
1174
+ can start → in progress
1175
+
1176
+ in progress
1177
+ when entered
1178
+ set started at = now()
1179
+ set status label = "IN_PROGRESS"
1180
+ can complete → done
1181
+ can cancel → cancelled
1182
+
1183
+ done, final
1184
+ when entered
1185
+ set completed at = now()
1186
+ set status label = "DONE"
1187
+
1188
+ cancelled, final
1189
+ when entered
1190
+ set status label = "CANCELLED"
1191
+
1192
+
1193
+ a user stats @1.0.0
1194
+ tagged: project-management, gamification
1195
+
1196
+ total xp as number, default 0
1197
+ current level as number, default 1
1198
+ level title as text, default "Newcomer"
1199
+ tasks completed as number, default 0
1200
+ projects completed as number, default 0
1201
+ user id as text
1202
+ streak days as number, default 0
1203
+ last activity at as time
1204
+
1205
+ levels
1206
+ 1: "Newcomer", from 0 xp
1207
+ 2: "Contributor", from 50 xp
1208
+ 3: "Team Player", from 150 xp
1209
+ 4: "Veteran", from 350 xp
1210
+ 5: "Expert", from 600 xp
1211
+ 6: "Master", from 1000 xp
1212
+ 7: "Grand Master", from 1500 xp
1213
+ 8: "Legend", from 2500 xp
1214
+
1215
+ starts at active
1216
+
1217
+ active
1218
+ when receives "task completed" from task
1219
+ set total xp = total xp + the event's xp reward
1220
+ set tasks completed = tasks completed + 1
1221
+ set last activity at = now()
1222
+
1223
+ when receives "project completed" from project
1224
+ set total xp = total xp + 50
1225
+ set projects completed = projects completed + 1
1226
+ `.trim();
1227
+
1228
+ let result: CompilationResult;
1229
+
1230
+ // Compile once for all E2E tests
1231
+ beforeAll(() => {
1232
+ result = compile(BLUEPRINT);
1233
+ });
1234
+
1235
+ it('compiles without errors', () => {
1236
+ expect(result.errors).toHaveLength(0);
1237
+ });
1238
+
1239
+ it('produces 3 workflows', () => {
1240
+ expect(result.workflows).toHaveLength(3);
1241
+ const slugs = result.workflows.map(w => w.slug).sort();
1242
+ expect(slugs).toEqual(['project', 'task', 'user-stats']);
1243
+ });
1244
+
1245
+ it('project workflow: 4 states with correct types', () => {
1246
+ const wf = findWorkflow(result, 'project')!;
1247
+ expect(wf.states).toHaveLength(4);
1248
+ expect(wf.states.find(s => s.name === 'draft')!.type).toBe('START');
1249
+ expect(wf.states.find(s => s.name === 'active')!.type).toBe('REGULAR');
1250
+ expect(wf.states.find(s => s.name === 'completed')!.type).toBe('END');
1251
+ expect(wf.states.find(s => s.name === 'cancelled')!.type).toBe('CANCELLED');
1252
+ });
1253
+
1254
+ it('project workflow: 7 fields correctly typed', () => {
1255
+ const wf = findWorkflow(result, 'project')!;
1256
+ expect(wf.fields).toHaveLength(7);
1257
+
1258
+ const name = wf.fields.find(f => f.name === 'name')!;
1259
+ expect(name.type).toBe('text');
1260
+ expect(name.required).toBe(true);
1261
+ expect(name.validation?.maxLength).toBe(200);
1262
+
1263
+ const desc = wf.fields.find(f => f.name === 'description')!;
1264
+ expect(desc.type).toBe('rich_text');
1265
+
1266
+ const priority = wf.fields.find(f => f.name === 'priority')!;
1267
+ expect(priority.type).toBe('select');
1268
+ expect(priority.required).toBe(true);
1269
+ expect(priority.default_value).toBe('medium');
1270
+ expect(priority.validation?.options).toEqual(['low', 'medium', 'high', 'critical']);
1271
+
1272
+ const totalTasks = wf.fields.find(f => f.name === 'total_tasks')!;
1273
+ expect(totalTasks.type).toBe('number');
1274
+ expect(totalTasks.default_value).toBe(0);
1275
+ expect(totalTasks.validation?.min).toBe(0);
1276
+
1277
+ const startedAt = wf.fields.find(f => f.name === 'started_at')!;
1278
+ expect(startedAt.type).toBe('datetime');
1279
+ });
1280
+
1281
+ it('project workflow: transitions with guards and auto', () => {
1282
+ const wf = findWorkflow(result, 'project')!;
1283
+ // activate, auto complete, manual complete, cancel
1284
+ expect(wf.transitions.length).toBeGreaterThanOrEqual(4);
1285
+
1286
+ const autoComplete = wf.transitions.find(t => t.name === 'auto-complete');
1287
+ expect(autoComplete).toBeDefined();
1288
+ expect(autoComplete!.auto).toBe(true);
1289
+ expect(autoComplete!.conditions).toBeDefined();
1290
+
1291
+ const manualComplete = wf.transitions.find(t => t.name === 'manual-complete');
1292
+ expect(manualComplete).toBeDefined();
1293
+ expect(manualComplete!.roles).toEqual(['admin']);
1294
+ });
1295
+
1296
+ it('project workflow: on_enter and on_event actions', () => {
1297
+ const wf = findWorkflow(result, 'project')!;
1298
+
1299
+ // active state should have on_enter and on_event
1300
+ const active = wf.states.find(s => s.name === 'active')!;
1301
+ expect(active.on_enter.length).toBeGreaterThanOrEqual(1);
1302
+ expect(active.on_enter[0].config.field).toBe('started_at');
1303
+ expect(active.on_event).toBeDefined();
1304
+ expect(active.on_event!.length).toBeGreaterThanOrEqual(2);
1305
+
1306
+ // completed state should have on_enter
1307
+ const completed = wf.states.find(s => s.name === 'completed')!;
1308
+ expect(completed.on_enter.length).toBeGreaterThanOrEqual(1);
1309
+ expect(completed.on_enter[0].config.field).toBe('completed_at');
1310
+ });
1311
+
1312
+ it('task workflow: states and transitions', () => {
1313
+ const wf = findWorkflow(result, 'task')!;
1314
+ expect(wf.states).toHaveLength(4); // todo, in progress, done, cancelled
1315
+ expect(wf.states.find(s => s.name === 'todo')!.type).toBe('START');
1316
+ expect(wf.states.find(s => s.name === 'in progress')!.type).toBe('REGULAR');
1317
+ expect(wf.states.find(s => s.name === 'done')!.type).toBe('END');
1318
+
1319
+ // start, complete, cancel transitions
1320
+ expect(wf.transitions.length).toBeGreaterThanOrEqual(3);
1321
+ });
1322
+
1323
+ it('task workflow: 8 fields', () => {
1324
+ const wf = findWorkflow(result, 'task')!;
1325
+ expect(wf.fields).toHaveLength(8);
1326
+
1327
+ const statusLabel = wf.fields.find(f => f.name === 'status_label')!;
1328
+ expect(statusLabel.computed).toBe('');
1329
+
1330
+ const xpReward = wf.fields.find(f => f.name === 'xp_reward')!;
1331
+ expect(xpReward.type).toBe('number');
1332
+ expect(xpReward.default_value).toBe(25);
1333
+ expect(xpReward.validation?.min).toBe(0);
1334
+ });
1335
+
1336
+ it('user-stats workflow: on_event subscriptions', () => {
1337
+ const wf = findWorkflow(result, 'user-stats')!;
1338
+ const active = wf.states.find(s => s.name === 'active')!;
1339
+ expect(active.on_event).toBeDefined();
1340
+ expect(active.on_event!.length).toBeGreaterThanOrEqual(2);
1341
+
1342
+ // First subscription: task completed
1343
+ const taskSub = active.on_event!.find(e => e.match.includes('task'));
1344
+ expect(taskSub).toBeDefined();
1345
+ expect(taskSub!.actions.length).toBeGreaterThanOrEqual(3);
1346
+
1347
+ // Check that total_xp expression references $event
1348
+ const xpAction = taskSub!.actions.find(a => a.field === 'total_xp');
1349
+ expect(xpAction?.expression).toContain('$event.state_data.xp_reward');
1350
+ });
1351
+
1352
+ it('user-stats workflow: levels in metadata', () => {
1353
+ const wf = findWorkflow(result, 'user-stats')!;
1354
+ expect(wf.metadata?.levels).toBeDefined();
1355
+ const levels = wf.metadata!.levels as Array<{ level: number; title: string; fromXp: number }>;
1356
+ expect(levels).toHaveLength(8);
1357
+ expect(levels[0]).toEqual({ level: 1, title: 'Newcomer', fromXp: 0 });
1358
+ expect(levels[7]).toEqual({ level: 8, title: 'Legend', fromXp: 2500 });
1359
+ });
1360
+
1361
+ it('manifest: 3 workflow entries and 5 routes', () => {
1362
+ expect(result.manifest).toBeDefined();
1363
+ expect(result.manifest!.workflows).toHaveLength(3);
1364
+ expect(result.manifest!.routes).toHaveLength(5);
1365
+ expect(result.manifest!.experience_id).toBe('project-management');
1366
+
1367
+ // Verify route structure
1368
+ const projectsRoute = result.manifest!.routes!.find(r => r.path === '/projects');
1369
+ expect(projectsRoute).toMatchObject({
1370
+ node: 'project-list',
1371
+ entityType: 'user',
1372
+ entityIdSource: 'user',
1373
+ });
1374
+
1375
+ const projectViewRoute = result.manifest!.routes!.find(r => r.path === '/projects/:id');
1376
+ expect(projectViewRoute).toMatchObject({
1377
+ node: 'project-view',
1378
+ entityType: 'project',
1379
+ entityIdSource: 'param',
1380
+ });
1381
+ });
1382
+ });