@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,1682 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { tokenize, tokenizeLine, parse, findByType } from '../dsl';
3
+ import type { LineData } from '../dsl';
4
+
5
+ // =============================================================================
6
+ // Helper
7
+ // =============================================================================
8
+
9
+ function classify(line: string): LineData {
10
+ return tokenizeLine(line).data;
11
+ }
12
+
13
+ function classifyType(line: string): string {
14
+ return classify(line).type;
15
+ }
16
+
17
+ // =============================================================================
18
+ // Line Classification Tests
19
+ // =============================================================================
20
+
21
+ describe('DSL Grammar — Line Classification', () => {
22
+ describe('Blanks & Comments', () => {
23
+ it('classifies blank lines', () => {
24
+ expect(classifyType('')).toBe('blank');
25
+ expect(classifyType(' ')).toBe('blank');
26
+ });
27
+
28
+ it('classifies comments', () => {
29
+ expect(classifyType('# This is a comment')).toBe('comment');
30
+ const data = classify('# Space');
31
+ expect(data).toMatchObject({ type: 'comment', text: 'Space' });
32
+ });
33
+
34
+ it('classifies section-header comments', () => {
35
+ const data = classify('# ═══════════════════════════════════════════════════');
36
+ expect(data.type).toBe('comment');
37
+ });
38
+ });
39
+
40
+ describe('Space Declaration', () => {
41
+ it('parses space with version', () => {
42
+ const data = classify('project management @1.0.0');
43
+ expect(data).toMatchObject({
44
+ type: 'space_decl',
45
+ name: 'project management',
46
+ version: '1.0.0',
47
+ });
48
+ });
49
+
50
+ it('distinguishes space from thing declaration (no "a" prefix)', () => {
51
+ expect(classifyType('project management @1.0.0')).toBe('space_decl');
52
+ expect(classifyType('a project @1.0.0')).toBe('thing_decl');
53
+ });
54
+ });
55
+
56
+ describe('Thing Declaration', () => {
57
+ it('parses thing with version', () => {
58
+ const data = classify('a project @1.0.0');
59
+ expect(data).toMatchObject({
60
+ type: 'thing_decl',
61
+ name: 'project',
62
+ version: '1.0.0',
63
+ });
64
+ });
65
+
66
+ it('parses multi-word thing', () => {
67
+ const data = classify('a a user stats @1.0.0');
68
+ expect(data).toMatchObject({
69
+ type: 'thing_decl',
70
+ name: 'user stats',
71
+ version: '1.0.0',
72
+ });
73
+ });
74
+ });
75
+
76
+ describe('Thing Reference (in things section)', () => {
77
+ it('parses thing ref with kind', () => {
78
+ const data = classify('a project (primary)');
79
+ expect(data).toMatchObject({
80
+ type: 'thing_ref',
81
+ name: 'project',
82
+ kind: 'primary',
83
+ });
84
+ });
85
+
86
+ it('parses child kind', () => {
87
+ const data = classify('a task (child)');
88
+ expect(data).toMatchObject({ type: 'thing_ref', name: 'task', kind: 'child' });
89
+ });
90
+
91
+ it('parses derived kind', () => {
92
+ const data = classify('user stats (derived)');
93
+ expect(data).toMatchObject({ type: 'thing_ref', name: 'user stats', kind: 'derived' });
94
+ });
95
+ });
96
+
97
+ describe('Fragment Definition', () => {
98
+ it('parses fragment def (colon ending)', () => {
99
+ const data = classify('a project card:');
100
+ expect(data).toMatchObject({ type: 'fragment_def', name: 'project card' });
101
+ });
102
+
103
+ it('parses multi-word fragment', () => {
104
+ const data = classify('a stat card:');
105
+ expect(data).toMatchObject({ type: 'fragment_def', name: 'stat card' });
106
+ });
107
+ });
108
+
109
+ describe('Field Definition', () => {
110
+ it('parses simple field', () => {
111
+ const data = classify('name as text');
112
+ expect(data).toMatchObject({
113
+ type: 'field_def',
114
+ name: 'name',
115
+ adjectives: [],
116
+ baseType: 'text',
117
+ });
118
+ });
119
+
120
+ it('parses field with adjective', () => {
121
+ const data = classify('name as required text');
122
+ expect(data).toMatchObject({
123
+ type: 'field_def',
124
+ name: 'name',
125
+ adjectives: ['required'],
126
+ baseType: 'text',
127
+ });
128
+ });
129
+
130
+ it('parses field with multiple adjectives', () => {
131
+ const data = classify('email as required unique text');
132
+ expect(data).toMatchObject({
133
+ type: 'field_def',
134
+ name: 'email',
135
+ adjectives: ['required', 'unique'],
136
+ baseType: 'text',
137
+ });
138
+ });
139
+
140
+ it('parses field with constraint', () => {
141
+ const data = classify('title as required text, max 200');
142
+ expect(data).toMatchObject({
143
+ type: 'field_def',
144
+ name: 'title',
145
+ adjectives: ['required'],
146
+ baseType: 'text',
147
+ constraints: [{ kind: 'max', value: 200 }],
148
+ });
149
+ });
150
+
151
+ it('parses non-negative number with default', () => {
152
+ const data = classify('xp reward as non-negative number, default 25');
153
+ expect(data).toMatchObject({
154
+ type: 'field_def',
155
+ name: 'xp reward',
156
+ adjectives: ['non-negative'],
157
+ baseType: 'number',
158
+ constraints: [{ kind: 'default', value: 25 }],
159
+ });
160
+ });
161
+
162
+ it('parses positive integer', () => {
163
+ const data = classify('count as positive integer');
164
+ expect(data).toMatchObject({
165
+ type: 'field_def',
166
+ name: 'count',
167
+ adjectives: ['positive'],
168
+ baseType: 'integer',
169
+ });
170
+ });
171
+
172
+ it('parses computed text', () => {
173
+ const data = classify('status label as computed text');
174
+ expect(data).toMatchObject({
175
+ type: 'field_def',
176
+ name: 'status label',
177
+ adjectives: ['computed'],
178
+ baseType: 'text',
179
+ });
180
+ });
181
+
182
+ it('parses rich text', () => {
183
+ const data = classify('description as rich text');
184
+ expect(data).toMatchObject({
185
+ type: 'field_def',
186
+ name: 'description',
187
+ adjectives: [],
188
+ baseType: 'rich text',
189
+ });
190
+ });
191
+
192
+ it('parses time field', () => {
193
+ const data = classify('started at as time');
194
+ expect(data).toMatchObject({
195
+ type: 'field_def',
196
+ name: 'started at',
197
+ baseType: 'time',
198
+ });
199
+ });
200
+
201
+ it('parses choice of enum with default', () => {
202
+ const data = classify(
203
+ 'priority as required choice of [low, medium, high, critical], default medium'
204
+ );
205
+ expect(data).toMatchObject({
206
+ type: 'field_def',
207
+ name: 'priority',
208
+ adjectives: ['required'],
209
+ constraints: [{ kind: 'default', value: 'medium' }],
210
+ });
211
+ if (data.type === 'field_def') {
212
+ expect(data.baseType).toContain('choice of');
213
+ }
214
+ });
215
+
216
+ it('parses number between constraint', () => {
217
+ const data = classify('rating as number, between 1 and 5');
218
+ expect(data).toMatchObject({
219
+ type: 'field_def',
220
+ name: 'rating',
221
+ baseType: 'number',
222
+ constraints: [{ kind: 'between', value: 1, value2: 5 }],
223
+ });
224
+ });
225
+
226
+ it('parses lowercase text, unique', () => {
227
+ const data = classify('slug as lowercase text, unique');
228
+ expect(data).toMatchObject({
229
+ type: 'field_def',
230
+ name: 'slug',
231
+ adjectives: ['lowercase'],
232
+ baseType: 'text',
233
+ constraints: [{ kind: 'unique', value: true }],
234
+ });
235
+ });
236
+
237
+ it('parses number with default 0', () => {
238
+ const data = classify('total xp as number, default 0');
239
+ expect(data).toMatchObject({
240
+ type: 'field_def',
241
+ name: 'total xp',
242
+ baseType: 'number',
243
+ constraints: [{ kind: 'default', value: 0 }],
244
+ });
245
+ });
246
+ });
247
+
248
+ describe('State Declaration', () => {
249
+ it('parses simple state', () => {
250
+ const data = classify('todo');
251
+ expect(data).toMatchObject({ type: 'state_decl', name: 'todo', isFinal: false });
252
+ });
253
+
254
+ it('parses multi-word state', () => {
255
+ const data = classify('in progress');
256
+ expect(data).toMatchObject({ type: 'state_decl', name: 'in progress', isFinal: false });
257
+ });
258
+
259
+ it('parses final state', () => {
260
+ const data = classify('done, final');
261
+ expect(data).toMatchObject({ type: 'state_decl', name: 'done', isFinal: true });
262
+ });
263
+
264
+ it('parses cancelled final state', () => {
265
+ const data = classify('cancelled, final');
266
+ expect(data).toMatchObject({ type: 'state_decl', name: 'cancelled', isFinal: true });
267
+ });
268
+ });
269
+
270
+ describe('Starts At', () => {
271
+ it('parses initial state declaration', () => {
272
+ const data = classify('starts at todo');
273
+ expect(data).toMatchObject({ type: 'starts_at', state: 'todo' });
274
+ });
275
+
276
+ it('parses multi-word initial state', () => {
277
+ const data = classify('starts at draft');
278
+ expect(data).toMatchObject({ type: 'starts_at', state: 'draft' });
279
+ });
280
+ });
281
+
282
+ describe('Transition', () => {
283
+ it('parses simple transition', () => {
284
+ const data = classify('can start → in progress');
285
+ expect(data).toMatchObject({
286
+ type: 'transition',
287
+ verb: 'start',
288
+ target: 'in progress',
289
+ });
290
+ });
291
+
292
+ it('parses transition with guard', () => {
293
+ const data = classify('can manual complete → completed, admin only');
294
+ expect(data).toMatchObject({
295
+ type: 'transition',
296
+ verb: 'manual complete',
297
+ target: 'completed',
298
+ guard: 'admin only',
299
+ });
300
+ });
301
+
302
+ it('parses activate transition', () => {
303
+ const data = classify('can activate → active');
304
+ expect(data).toMatchObject({
305
+ type: 'transition',
306
+ verb: 'activate',
307
+ target: 'active',
308
+ });
309
+ });
310
+
311
+ it('parses cancel transition', () => {
312
+ const data = classify('can cancel → cancelled');
313
+ expect(data).toMatchObject({
314
+ type: 'transition',
315
+ verb: 'cancel',
316
+ target: 'cancelled',
317
+ });
318
+ });
319
+ });
320
+
321
+ describe('When Clause', () => {
322
+ it('parses entered event', () => {
323
+ const data = classify('when entered');
324
+ expect(data).toMatchObject({ type: 'when', condition: 'entered' });
325
+ });
326
+
327
+ it('parses receives event', () => {
328
+ const data = classify('when receives "task completed" from task');
329
+ expect(data).toMatchObject({
330
+ type: 'when',
331
+ condition: 'receives "task completed" from task',
332
+ });
333
+ });
334
+
335
+ it('parses UI event', () => {
336
+ const data = classify('when tapped');
337
+ expect(data).toMatchObject({ type: 'when', condition: 'tapped' });
338
+ });
339
+
340
+ it('parses state condition', () => {
341
+ const data = classify('when health < 20');
342
+ expect(data).toMatchObject({ type: 'when', condition: 'health < 20' });
343
+ });
344
+
345
+ it('parses temporal event', () => {
346
+ const data = classify('when 5 seconds pass');
347
+ expect(data).toMatchObject({ type: 'when', condition: '5 seconds pass' });
348
+ });
349
+
350
+ it('parses compound condition', () => {
351
+ const data = classify(
352
+ 'when completed tasks >= total tasks and total tasks > 0'
353
+ );
354
+ expect(data).toMatchObject({
355
+ type: 'when',
356
+ condition: 'completed tasks >= total tasks and total tasks > 0',
357
+ });
358
+ });
359
+
360
+ it('parses collision event', () => {
361
+ const data = classify('when collides with enemy');
362
+ expect(data).toMatchObject({ type: 'when', condition: 'collides with enemy' });
363
+ });
364
+
365
+ it('parses chosen event', () => {
366
+ const data = classify('when chosen');
367
+ expect(data).toMatchObject({ type: 'when', condition: 'chosen' });
368
+ });
369
+
370
+ it('parses scoped event', () => {
371
+ const data = classify('when tapped on a project card');
372
+ expect(data).toMatchObject({
373
+ type: 'when',
374
+ condition: 'tapped on a project card',
375
+ });
376
+ });
377
+
378
+ it('parses story has inventory condition', () => {
379
+ const data = classify('when story has inventory');
380
+ expect(data).toMatchObject({
381
+ type: 'when',
382
+ condition: 'story has inventory',
383
+ });
384
+ });
385
+
386
+ it('parses selected event', () => {
387
+ const data = classify('when selected');
388
+ expect(data).toMatchObject({ type: 'when', condition: 'selected' });
389
+ });
390
+ });
391
+
392
+ describe('Actions', () => {
393
+ it('parses set action', () => {
394
+ const data = classify('set started at = now()');
395
+ expect(data).toMatchObject({
396
+ type: 'set_action',
397
+ field: 'started at',
398
+ expression: 'now()',
399
+ });
400
+ });
401
+
402
+ it('parses set with arithmetic', () => {
403
+ const data = classify('set total xp = total xp + the event\'s xp reward');
404
+ expect(data).toMatchObject({
405
+ type: 'set_action',
406
+ field: 'total xp',
407
+ });
408
+ });
409
+
410
+ it('parses set with string value', () => {
411
+ const data = classify('set status label = "IN_PROGRESS"');
412
+ expect(data).toMatchObject({
413
+ type: 'set_action',
414
+ field: 'status label',
415
+ expression: '"IN_PROGRESS"',
416
+ });
417
+ });
418
+
419
+ it('parses do action', () => {
420
+ const data = classify('do collect');
421
+ expect(data).toMatchObject({ type: 'do_action', action: 'collect' });
422
+ });
423
+
424
+ it('parses do play or pause', () => {
425
+ const data = classify('do play or pause');
426
+ expect(data).toMatchObject({ type: 'do_action', action: 'play or pause' });
427
+ });
428
+
429
+ it('parses do previous', () => {
430
+ const data = classify('do previous');
431
+ expect(data).toMatchObject({ type: 'do_action', action: 'previous' });
432
+ });
433
+
434
+ it('parses go to', () => {
435
+ const data = classify('go to /projects/{its id}');
436
+ expect(data).toMatchObject({
437
+ type: 'go_action',
438
+ path: '/projects/{its id}',
439
+ });
440
+ });
441
+
442
+ it('parses tell action', () => {
443
+ const data = classify('tell the project "all tasks done"');
444
+ expect(data).toMatchObject({
445
+ type: 'tell_action',
446
+ target: 'the project',
447
+ message: 'all tasks done',
448
+ });
449
+ });
450
+
451
+ it('parses show action with modifier', () => {
452
+ const data = classify('show "+{coin.value}!" briefly');
453
+ expect(data).toMatchObject({
454
+ type: 'show_action',
455
+ modifier: 'briefly',
456
+ });
457
+ });
458
+
459
+ it('parses show clip detail', () => {
460
+ const data = classify('show clip detail');
461
+ expect(data).toMatchObject({
462
+ type: 'show_action',
463
+ content: 'clip detail',
464
+ });
465
+ });
466
+ });
467
+
468
+ describe('Data Source', () => {
469
+ it('parses simple data source', () => {
470
+ const data = classify('my projects from project');
471
+ expect(data).toMatchObject({
472
+ type: 'data_source',
473
+ alias: 'my projects',
474
+ source: 'project',
475
+ });
476
+ });
477
+
478
+ it('parses data source with live', () => {
479
+ const data = classify('my game from game-state, live');
480
+ expect(data).toMatchObject({
481
+ type: 'data_source',
482
+ alias: 'my game',
483
+ source: 'game-state',
484
+ isLive: true,
485
+ });
486
+ });
487
+
488
+ it('parses data source with qualifier', () => {
489
+ const data = classify('my recent from project, 5 newest');
490
+ expect(data).toMatchObject({
491
+ type: 'data_source',
492
+ alias: 'my recent',
493
+ source: 'project',
494
+ qualifier: '5 newest',
495
+ });
496
+ });
497
+
498
+ it('parses data source with scope', () => {
499
+ const data = classify('its tasks from task for this project');
500
+ expect(data).toMatchObject({
501
+ type: 'data_source',
502
+ alias: 'its tasks',
503
+ source: 'task',
504
+ scope: 'this project',
505
+ });
506
+ });
507
+
508
+ it('parses data source with current qualifier', () => {
509
+ const data = classify('my track from music-queue, current');
510
+ expect(data).toMatchObject({
511
+ type: 'data_source',
512
+ alias: 'my track',
513
+ source: 'music-queue',
514
+ qualifier: 'current',
515
+ });
516
+ });
517
+
518
+ it('parses scoped media source', () => {
519
+ const data = classify('my media from media-library for this project');
520
+ expect(data).toMatchObject({
521
+ type: 'data_source',
522
+ alias: 'my media',
523
+ source: 'media-library',
524
+ scope: 'this project',
525
+ });
526
+ });
527
+
528
+ it('parses this entity source', () => {
529
+ const data = classify('this project from project');
530
+ expect(data).toMatchObject({
531
+ type: 'data_source',
532
+ alias: 'this project',
533
+ source: 'project',
534
+ });
535
+ });
536
+
537
+ it('parses these entities source', () => {
538
+ const data = classify('these tasks from task for this project');
539
+ expect(data).toMatchObject({
540
+ type: 'data_source',
541
+ alias: 'these tasks',
542
+ source: 'task',
543
+ scope: 'this project',
544
+ });
545
+ });
546
+ });
547
+
548
+ describe('Iteration', () => {
549
+ it('parses simple iteration', () => {
550
+ const data = classify('each project');
551
+ expect(data).toMatchObject({
552
+ type: 'iteration',
553
+ subject: 'project',
554
+ });
555
+ });
556
+
557
+ it('parses iteration with role', () => {
558
+ const data = classify('each project as card');
559
+ expect(data).toMatchObject({
560
+ type: 'iteration',
561
+ subject: 'project',
562
+ role: 'card',
563
+ });
564
+ });
565
+
566
+ it('parses iteration with emphasis', () => {
567
+ const data = classify('each one as card, small');
568
+ expect(data).toMatchObject({
569
+ type: 'iteration',
570
+ subject: 'one',
571
+ role: 'card',
572
+ emphasis: 'small',
573
+ });
574
+ });
575
+
576
+ it('parses each task', () => {
577
+ const data = classify('each task as card');
578
+ expect(data).toMatchObject({
579
+ type: 'iteration',
580
+ subject: 'task',
581
+ role: 'card',
582
+ });
583
+ });
584
+
585
+ it('parses each with multi-word subject', () => {
586
+ const data = classify('each recent project as card');
587
+ expect(data).toMatchObject({
588
+ type: 'iteration',
589
+ subject: 'recent project',
590
+ role: 'card',
591
+ });
592
+ });
593
+
594
+ it('parses each entity', () => {
595
+ const data = classify('each entity');
596
+ expect(data).toMatchObject({ type: 'iteration', subject: 'entity' });
597
+ });
598
+
599
+ it('parses each choice', () => {
600
+ const data = classify('each choice as card');
601
+ expect(data).toMatchObject({
602
+ type: 'iteration',
603
+ subject: 'choice',
604
+ role: 'card',
605
+ });
606
+ });
607
+
608
+ it('parses each clip', () => {
609
+ const data = classify('each clip');
610
+ expect(data).toMatchObject({ type: 'iteration', subject: 'clip' });
611
+ });
612
+
613
+ it('parses each item', () => {
614
+ const data = classify('each item');
615
+ expect(data).toMatchObject({ type: 'iteration', subject: 'item' });
616
+ });
617
+ });
618
+
619
+ describe('Grouping', () => {
620
+ it('parses grouping', () => {
621
+ const data = classify('tasks by state');
622
+ expect(data).toMatchObject({
623
+ type: 'grouping',
624
+ collection: 'tasks',
625
+ key: 'state',
626
+ });
627
+ });
628
+ });
629
+
630
+ describe('Content Display', () => {
631
+ it('parses its field', () => {
632
+ const data = classify('its name');
633
+ expect(data).toMatchObject({
634
+ type: 'content',
635
+ pronoun: 'its',
636
+ field: 'name',
637
+ });
638
+ });
639
+
640
+ it('parses its field with emphasis', () => {
641
+ const data = classify('its name, big');
642
+ expect(data).toMatchObject({
643
+ type: 'content',
644
+ pronoun: 'its',
645
+ field: 'name',
646
+ emphasis: 'big',
647
+ });
648
+ });
649
+
650
+ it('parses its field small', () => {
651
+ const data = classify('its date, small');
652
+ expect(data).toMatchObject({
653
+ type: 'content',
654
+ pronoun: 'its',
655
+ field: 'date',
656
+ emphasis: 'small',
657
+ });
658
+ });
659
+
660
+ it('parses content with role', () => {
661
+ const data = classify('its priority as tag');
662
+ expect(data).toMatchObject({
663
+ type: 'content',
664
+ pronoun: 'its',
665
+ field: 'priority',
666
+ role: 'tag',
667
+ });
668
+ });
669
+
670
+ it('parses content with label', () => {
671
+ const data = classify('its xp reward as number with "XP"');
672
+ expect(data).toMatchObject({
673
+ type: 'content',
674
+ pronoun: 'its',
675
+ field: 'xp reward',
676
+ role: 'number',
677
+ label: 'XP',
678
+ });
679
+ });
680
+
681
+ it('parses my field', () => {
682
+ const data = classify('my level title, big');
683
+ expect(data).toMatchObject({
684
+ type: 'content',
685
+ pronoun: 'my',
686
+ field: 'level title',
687
+ emphasis: 'big',
688
+ });
689
+ });
690
+
691
+ it('parses the field', () => {
692
+ const data = classify('the score, big');
693
+ expect(data).toMatchObject({
694
+ type: 'content',
695
+ pronoun: 'the',
696
+ field: 'score',
697
+ emphasis: 'big',
698
+ });
699
+ });
700
+
701
+ it('parses its field with "as" for progress', () => {
702
+ const data = classify('its health as meter');
703
+ expect(data).toMatchObject({
704
+ type: 'content',
705
+ pronoun: 'its',
706
+ field: 'health',
707
+ role: 'meter',
708
+ });
709
+ });
710
+
711
+ it('parses labeled content in numbers section', () => {
712
+ const data = classify('total xp as "Total XP"');
713
+ expect(data).toMatchObject({
714
+ type: 'content',
715
+ field: 'total xp',
716
+ label: 'Total XP',
717
+ });
718
+ });
719
+
720
+ it('parses labeled content with suffix', () => {
721
+ const data = classify('streak days as "Streak" with "d"');
722
+ expect(data).toMatchObject({
723
+ type: 'content',
724
+ field: 'streak days',
725
+ label: 'Streak',
726
+ });
727
+ });
728
+
729
+ it('parses its state as tag', () => {
730
+ const data = classify('its state as tag');
731
+ expect(data).toMatchObject({
732
+ type: 'content',
733
+ pronoun: 'its',
734
+ field: 'state',
735
+ role: 'tag',
736
+ });
737
+ });
738
+
739
+ it('parses its title', () => {
740
+ const data = classify('its title, big');
741
+ expect(data).toMatchObject({
742
+ type: 'content',
743
+ pronoun: 'its',
744
+ field: 'title',
745
+ emphasis: 'big',
746
+ });
747
+ });
748
+
749
+ it('parses its album art (multi-word field)', () => {
750
+ const data = classify('its album art');
751
+ expect(data).toMatchObject({
752
+ type: 'content',
753
+ pronoun: 'its',
754
+ field: 'album art',
755
+ });
756
+ });
757
+
758
+ it('parses its artist (no modifier)', () => {
759
+ const data = classify('its artist');
760
+ expect(data).toMatchObject({
761
+ type: 'content',
762
+ pronoun: 'its',
763
+ field: 'artist',
764
+ });
765
+ });
766
+
767
+ it('parses its album, small', () => {
768
+ const data = classify('its album, small');
769
+ expect(data).toMatchObject({
770
+ type: 'content',
771
+ pronoun: 'its',
772
+ field: 'album',
773
+ emphasis: 'small',
774
+ });
775
+ });
776
+
777
+ it('parses its icon', () => {
778
+ const data = classify('its icon');
779
+ expect(data).toMatchObject({ type: 'content', pronoun: 'its', field: 'icon' });
780
+ });
781
+
782
+ it('parses its quantity as number', () => {
783
+ const data = classify('its quantity as number');
784
+ expect(data).toMatchObject({
785
+ type: 'content',
786
+ pronoun: 'its',
787
+ field: 'quantity',
788
+ role: 'number',
789
+ });
790
+ });
791
+ });
792
+
793
+ describe('String Literal', () => {
794
+ it('parses string literal', () => {
795
+ const data = classify('"Projects", big');
796
+ expect(data).toMatchObject({
797
+ type: 'string_literal',
798
+ text: 'Projects',
799
+ emphasis: 'big',
800
+ });
801
+ });
802
+
803
+ it('parses string without emphasis', () => {
804
+ const data = classify('"Recent Projects"');
805
+ expect(data).toMatchObject({
806
+ type: 'string_literal',
807
+ text: 'Recent Projects',
808
+ });
809
+ });
810
+
811
+ it('parses string small', () => {
812
+ const data = classify('"Manage your projects", small');
813
+ expect(data).toMatchObject({
814
+ type: 'string_literal',
815
+ text: 'Manage your projects',
816
+ emphasis: 'small',
817
+ });
818
+ });
819
+
820
+ it('parses "Inventory"', () => {
821
+ const data = classify('"Inventory"');
822
+ expect(data).toMatchObject({ type: 'string_literal', text: 'Inventory' });
823
+ });
824
+
825
+ it('parses "Task Board", big', () => {
826
+ const data = classify('"Task Board", big');
827
+ expect(data).toMatchObject({
828
+ type: 'string_literal',
829
+ text: 'Task Board',
830
+ emphasis: 'big',
831
+ });
832
+ });
833
+
834
+ it('parses "Media Library"', () => {
835
+ const data = classify('"Media Library"');
836
+ expect(data).toMatchObject({ type: 'string_literal', text: 'Media Library' });
837
+ });
838
+ });
839
+
840
+ describe('Search', () => {
841
+ it('parses search', () => {
842
+ const data = classify('search my projects');
843
+ expect(data).toMatchObject({ type: 'search', target: 'my projects' });
844
+ });
845
+
846
+ it('parses search tasks', () => {
847
+ const data = classify('search tasks');
848
+ expect(data).toMatchObject({ type: 'search', target: 'tasks' });
849
+ });
850
+
851
+ it('parses search my media', () => {
852
+ const data = classify('search my media');
853
+ expect(data).toMatchObject({ type: 'search', target: 'my media' });
854
+ });
855
+ });
856
+
857
+ describe('Qualifiers', () => {
858
+ it('parses order qualifier', () => {
859
+ const data = classify('newest first');
860
+ expect(data).toMatchObject({
861
+ type: 'qualifier',
862
+ kind: 'order',
863
+ value: 'newest',
864
+ });
865
+ });
866
+
867
+ it('parses searchable by', () => {
868
+ const data = classify('searchable by name and description');
869
+ expect(data).toMatchObject({
870
+ type: 'qualifier',
871
+ kind: 'searchable',
872
+ value: 'name and description',
873
+ });
874
+ });
875
+
876
+ it('parses filterable by', () => {
877
+ const data = classify('filterable by priority');
878
+ expect(data).toMatchObject({
879
+ type: 'qualifier',
880
+ kind: 'filterable',
881
+ value: 'priority',
882
+ });
883
+ });
884
+
885
+ it('parses pagination', () => {
886
+ const data = classify('20 at a time');
887
+ expect(data).toMatchObject({
888
+ type: 'qualifier',
889
+ kind: 'pagination',
890
+ value: '20',
891
+ });
892
+ });
893
+
894
+ it('parses filterable by multiple fields', () => {
895
+ const data = classify('filterable by status label and priority');
896
+ expect(data).toMatchObject({
897
+ type: 'qualifier',
898
+ kind: 'filterable',
899
+ value: 'status label and priority',
900
+ });
901
+ });
902
+ });
903
+
904
+ describe('Navigation', () => {
905
+ it('parses tap navigation', () => {
906
+ const data = classify('tap → /projects/{its id}');
907
+ expect(data).toMatchObject({
908
+ type: 'navigation',
909
+ trigger: 'tap',
910
+ target: '/projects/{its id}',
911
+ });
912
+ });
913
+
914
+ it('parses tab navigation', () => {
915
+ const data = classify('"Board" → task board');
916
+ // This is a string-label → target, classified as navigation
917
+ // Actually this starts with " so let me reconsider...
918
+ // The lexer tries string_literal first, which needs " at the end.
919
+ // "Board" → task board won't match string_literal since it doesn't end with "
920
+ // It will fall through to navigation
921
+ expect(data).toMatchObject({
922
+ type: 'navigation',
923
+ trigger: '"Board"',
924
+ target: 'task board',
925
+ });
926
+ });
927
+ });
928
+
929
+ describe('Path Mapping', () => {
930
+ it('parses path with view and context', () => {
931
+ const data = classify('/projects → project list (user)');
932
+ expect(data).toMatchObject({
933
+ type: 'path_mapping',
934
+ path: '/projects',
935
+ view: 'project list',
936
+ context: 'user',
937
+ });
938
+ });
939
+
940
+ it('parses path with parameter', () => {
941
+ const data = classify('/projects/:id → project view (project)');
942
+ expect(data).toMatchObject({
943
+ type: 'path_mapping',
944
+ path: '/projects/:id',
945
+ view: 'project view',
946
+ context: 'project',
947
+ });
948
+ });
949
+
950
+ it('parses deep path', () => {
951
+ const data = classify('/projects/:id/board → task board (project)');
952
+ expect(data).toMatchObject({
953
+ type: 'path_mapping',
954
+ path: '/projects/:id/board',
955
+ view: 'task board',
956
+ context: 'project',
957
+ });
958
+ });
959
+
960
+ it('parses profile path', () => {
961
+ const data = classify('/profile → user profile (user)');
962
+ expect(data).toMatchObject({
963
+ type: 'path_mapping',
964
+ path: '/profile',
965
+ view: 'user profile',
966
+ context: 'user',
967
+ });
968
+ });
969
+ });
970
+
971
+ describe('Section Keywords', () => {
972
+ it('parses things section', () => {
973
+ expect(classify('things')).toMatchObject({ type: 'section', name: 'things' });
974
+ });
975
+
976
+ it('parses paths section', () => {
977
+ expect(classify('paths')).toMatchObject({ type: 'section', name: 'paths' });
978
+ });
979
+
980
+ it('parses levels section', () => {
981
+ expect(classify('levels')).toMatchObject({ type: 'section', name: 'levels' });
982
+ });
983
+
984
+ it('parses numbers section', () => {
985
+ expect(classify('numbers')).toMatchObject({ type: 'section', name: 'numbers' });
986
+ });
987
+
988
+ it('parses tabs section', () => {
989
+ expect(classify('tabs')).toMatchObject({ type: 'section', name: 'tabs' });
990
+ });
991
+
992
+ it('parses controls section', () => {
993
+ expect(classify('controls')).toMatchObject({ type: 'section', name: 'controls' });
994
+ });
995
+
996
+ it('parses overlay section', () => {
997
+ expect(classify('overlay')).toMatchObject({ type: 'section', name: 'overlay' });
998
+ });
999
+
1000
+ it('parses actions section with scope', () => {
1001
+ // "actions for this project" — this will match data_source or something else
1002
+ // Plain "actions" should be section
1003
+ expect(classify('actions')).toMatchObject({ type: 'section', name: 'actions' });
1004
+ });
1005
+ });
1006
+
1007
+ describe('Tagged', () => {
1008
+ it('parses tagged metadata', () => {
1009
+ const data = classify('tagged: project-management, blueprint-mvp1');
1010
+ expect(data).toMatchObject({
1011
+ type: 'tagged',
1012
+ tags: ['project-management', 'blueprint-mvp1'],
1013
+ });
1014
+ });
1015
+
1016
+ it('parses tagged with gamification', () => {
1017
+ const data = classify('tagged: project-management, gamification');
1018
+ expect(data).toMatchObject({
1019
+ type: 'tagged',
1020
+ tags: ['project-management', 'gamification'],
1021
+ });
1022
+ });
1023
+ });
1024
+
1025
+ describe('Level Definition', () => {
1026
+ it('parses level definition', () => {
1027
+ const data = classify('1: "Newcomer", from 0 xp');
1028
+ expect(data).toMatchObject({
1029
+ type: 'level_def',
1030
+ level: 1,
1031
+ title: 'Newcomer',
1032
+ fromXp: 0,
1033
+ });
1034
+ });
1035
+
1036
+ it('parses higher level', () => {
1037
+ const data = classify('8: "Legend", from 2500 xp');
1038
+ expect(data).toMatchObject({
1039
+ type: 'level_def',
1040
+ level: 8,
1041
+ title: 'Legend',
1042
+ fromXp: 2500,
1043
+ });
1044
+ });
1045
+
1046
+ it('parses mid level', () => {
1047
+ const data = classify('4: "Veteran", from 350 xp');
1048
+ expect(data).toMatchObject({
1049
+ type: 'level_def',
1050
+ level: 4,
1051
+ title: 'Veteran',
1052
+ fromXp: 350,
1053
+ });
1054
+ });
1055
+ });
1056
+
1057
+ describe('Pages', () => {
1058
+ it('parses pages keyword', () => {
1059
+ expect(classify('pages')).toMatchObject({ type: 'pages' });
1060
+ });
1061
+ });
1062
+ });
1063
+
1064
+ // =============================================================================
1065
+ // Indentation Tests
1066
+ // =============================================================================
1067
+
1068
+ describe('DSL Grammar — Indentation', () => {
1069
+ it('measures zero indent', () => {
1070
+ const token = tokenizeLine('my projects');
1071
+ expect(token.indent).toBe(0);
1072
+ });
1073
+
1074
+ it('measures 2-space indent', () => {
1075
+ const token = tokenizeLine(' newest first');
1076
+ expect(token.indent).toBe(2);
1077
+ });
1078
+
1079
+ it('measures 4-space indent', () => {
1080
+ const token = tokenizeLine(' its name, big');
1081
+ expect(token.indent).toBe(4);
1082
+ });
1083
+
1084
+ it('measures 6-space indent', () => {
1085
+ const token = tokenizeLine(' set started at = now()');
1086
+ expect(token.indent).toBe(6);
1087
+ });
1088
+
1089
+ it('counts tab as 2 spaces', () => {
1090
+ const token = tokenizeLine('\tits name');
1091
+ expect(token.indent).toBe(2);
1092
+ });
1093
+ });
1094
+
1095
+ // =============================================================================
1096
+ // Full Tokenization Tests
1097
+ // =============================================================================
1098
+
1099
+ describe('DSL Grammar — Full Tokenization', () => {
1100
+ it('tokenizes a simple view', () => {
1101
+ const source = `
1102
+ my projects from project
1103
+ newest first
1104
+ each project as card
1105
+ its name, big
1106
+ its priority as tag
1107
+ tap → /projects/{its id}
1108
+ `.trim();
1109
+
1110
+ const tokens = tokenize(source);
1111
+ expect(tokens).toHaveLength(6);
1112
+ expect(tokens[0].data.type).toBe('data_source');
1113
+ expect(tokens[1].data.type).toBe('qualifier');
1114
+ expect(tokens[2].data.type).toBe('iteration');
1115
+ expect(tokens[3].data.type).toBe('content');
1116
+ expect(tokens[4].data.type).toBe('content');
1117
+ expect(tokens[5].data.type).toBe('navigation');
1118
+ });
1119
+
1120
+ it('tokenizes a workflow definition', () => {
1121
+ const source = `
1122
+ a task @1.0.0
1123
+ title as required text, max 200
1124
+ priority as required choice of [low, medium, high, critical], default medium
1125
+ starts at todo
1126
+ todo
1127
+ can start → in progress
1128
+ in progress
1129
+ when entered
1130
+ set started at = now()
1131
+ can complete → done
1132
+ done, final
1133
+ `.trim();
1134
+
1135
+ const tokens = tokenize(source);
1136
+ const types = tokens.map(t => t.data.type);
1137
+
1138
+ expect(types[0]).toBe('thing_decl');
1139
+ expect(types[1]).toBe('field_def');
1140
+ expect(types[2]).toBe('field_def');
1141
+ expect(types[3]).toBe('starts_at');
1142
+ expect(types[4]).toBe('state_decl'); // todo
1143
+ expect(types[5]).toBe('transition'); // can start → in progress
1144
+ expect(types[6]).toBe('state_decl'); // in progress
1145
+ expect(types[7]).toBe('when');
1146
+ expect(types[8]).toBe('set_action');
1147
+ expect(types[9]).toBe('transition'); // can complete → done
1148
+ expect(types[10]).toBe('state_decl'); // done, final
1149
+ });
1150
+
1151
+ it('tokenizes a space manifest', () => {
1152
+ const source = `
1153
+ project management @1.0.0
1154
+ things
1155
+ a project (primary)
1156
+ a task (child)
1157
+ paths
1158
+ /projects → project list (user)
1159
+ /projects/:id → project view (project)
1160
+ `.trim();
1161
+
1162
+ const tokens = tokenize(source);
1163
+ const types = tokens.map(t => t.data.type);
1164
+
1165
+ expect(types[0]).toBe('space_decl');
1166
+ expect(types[1]).toBe('section'); // things
1167
+ expect(types[2]).toBe('thing_ref'); // a project (primary)
1168
+ expect(types[3]).toBe('thing_ref'); // a task (child)
1169
+ expect(types[4]).toBe('section'); // paths
1170
+ expect(types[5]).toBe('path_mapping');
1171
+ expect(types[6]).toBe('path_mapping');
1172
+ });
1173
+
1174
+ it('tokenizes a fragment definition', () => {
1175
+ const source = `
1176
+ a project card:
1177
+ its name, big
1178
+ its priority as tag
1179
+ its date, small
1180
+ tap → /projects/{its id}
1181
+ `.trim();
1182
+
1183
+ const tokens = tokenize(source);
1184
+ expect(tokens[0].data.type).toBe('fragment_def');
1185
+ expect(tokens[1].data.type).toBe('content');
1186
+ expect(tokens[2].data.type).toBe('content');
1187
+ expect(tokens[3].data.type).toBe('content');
1188
+ expect(tokens[4].data.type).toBe('navigation');
1189
+ });
1190
+ });
1191
+
1192
+ // =============================================================================
1193
+ // Parser Tree Tests
1194
+ // =============================================================================
1195
+
1196
+ describe('DSL Grammar — Parser Tree', () => {
1197
+ it('builds tree from indentation', () => {
1198
+ const source = `
1199
+ my projects from project
1200
+ newest first
1201
+ each project as card
1202
+ its name, big
1203
+ its priority as tag
1204
+ `.trim();
1205
+
1206
+ const result = parse(tokenize(source));
1207
+ expect(result.nodes).toHaveLength(1); // one root node
1208
+
1209
+ const root = result.nodes[0];
1210
+ expect(root.token.data.type).toBe('data_source');
1211
+ expect(root.children).toHaveLength(2); // newest first, each project
1212
+
1213
+ const each = root.children[1];
1214
+ expect(each.token.data.type).toBe('iteration');
1215
+ expect(each.children).toHaveLength(2); // its name, its priority
1216
+ });
1217
+
1218
+ it('builds tree for workflow', () => {
1219
+ const source = `
1220
+ a task @1.0.0
1221
+ title as required text, max 200
1222
+ starts at todo
1223
+ todo
1224
+ can start → in progress
1225
+ in progress
1226
+ when entered
1227
+ set started at = now()
1228
+ done, final
1229
+ `.trim();
1230
+
1231
+ const result = parse(tokenize(source));
1232
+ expect(result.nodes).toHaveLength(1);
1233
+
1234
+ const root = result.nodes[0];
1235
+ expect(root.token.data.type).toBe('thing_decl');
1236
+ // title, starts at, todo, in progress, done
1237
+ expect(root.children).toHaveLength(5);
1238
+
1239
+ // todo has transition child
1240
+ const todo = root.children[2];
1241
+ expect(todo.token.data.type).toBe('state_decl');
1242
+ expect(todo.children).toHaveLength(1);
1243
+ expect(todo.children[0].token.data.type).toBe('transition');
1244
+
1245
+ // in progress has when child
1246
+ const inProgress = root.children[3];
1247
+ expect(inProgress.token.data.type).toBe('state_decl');
1248
+ expect(inProgress.children).toHaveLength(1);
1249
+ expect(inProgress.children[0].token.data.type).toBe('when');
1250
+
1251
+ // when has set_action child
1252
+ const when = inProgress.children[0];
1253
+ expect(when.children).toHaveLength(1);
1254
+ expect(when.children[0].token.data.type).toBe('set_action');
1255
+ });
1256
+
1257
+ it('finds nodes by type', () => {
1258
+ const source = `
1259
+ a task @1.0.0
1260
+ starts at todo
1261
+ todo
1262
+ can start → in progress
1263
+ in progress
1264
+ can complete → done
1265
+ done, final
1266
+ `.trim();
1267
+
1268
+ const result = parse(tokenize(source));
1269
+ const transitions = findByType(result.nodes, 'transition');
1270
+ expect(transitions).toHaveLength(2);
1271
+ expect(transitions[0].token.data).toMatchObject({ verb: 'start', target: 'in progress' });
1272
+ expect(transitions[1].token.data).toMatchObject({ verb: 'complete', target: 'done' });
1273
+ });
1274
+
1275
+ it('builds multi-root tree', () => {
1276
+ const source = `
1277
+ project list
1278
+ my projects from project
1279
+ each project as card
1280
+ its name, big
1281
+
1282
+ project view
1283
+ this project from project
1284
+ its name, big
1285
+ `.trim();
1286
+
1287
+ const result = parse(tokenize(source));
1288
+ expect(result.nodes).toHaveLength(2);
1289
+ expect(result.nodes[0].token.data).toMatchObject({ type: 'state_decl', name: 'project list' });
1290
+ expect(result.nodes[1].token.data).toMatchObject({ type: 'state_decl', name: 'project view' });
1291
+ });
1292
+ });
1293
+
1294
+ // =============================================================================
1295
+ // Beyond CRUD Tests
1296
+ // =============================================================================
1297
+
1298
+ describe('DSL Grammar — Beyond CRUD', () => {
1299
+ it('tokenizes a game scene', () => {
1300
+ const source = `
1301
+ my game from game-state, live
1302
+ the score, big
1303
+ the health as meter
1304
+ when health < 20
1305
+ the health, danger
1306
+ `.trim();
1307
+
1308
+ const tokens = tokenize(source);
1309
+ expect(tokens[0].data.type).toBe('data_source');
1310
+ expect(tokens[0].data).toMatchObject({ isLive: true });
1311
+ expect(tokens[1].data.type).toBe('content');
1312
+ expect(tokens[2].data.type).toBe('content');
1313
+ expect(tokens[3].data.type).toBe('when');
1314
+ });
1315
+
1316
+ it('tokenizes a music player', () => {
1317
+ const source = `
1318
+ my track from music-queue, current
1319
+ its title, big
1320
+ its artist
1321
+ its album, small
1322
+ do previous
1323
+ do play or pause
1324
+ do next
1325
+ `.trim();
1326
+
1327
+ const tokens = tokenize(source);
1328
+ expect(tokens[0].data.type).toBe('data_source');
1329
+ expect(tokens[1].data).toMatchObject({ type: 'content', field: 'title', emphasis: 'big' });
1330
+ expect(tokens[2].data).toMatchObject({ type: 'content', field: 'artist' });
1331
+ expect(tokens[3].data).toMatchObject({ type: 'content', field: 'album', emphasis: 'small' });
1332
+ expect(tokens[4].data).toMatchObject({ type: 'do_action', action: 'previous' });
1333
+ expect(tokens[5].data).toMatchObject({ type: 'do_action', action: 'play or pause' });
1334
+ expect(tokens[6].data).toMatchObject({ type: 'do_action', action: 'next' });
1335
+ });
1336
+
1337
+ it('tokenizes an interactive story', () => {
1338
+ const source = `
1339
+ my story from adventure, current
1340
+ its narrator, big
1341
+ each choice as card
1342
+ its text
1343
+ when chosen, do its action
1344
+ `.trim();
1345
+
1346
+ const tokens = tokenize(source);
1347
+ expect(tokens[0].data).toMatchObject({ type: 'data_source', source: 'adventure', qualifier: 'current' });
1348
+ expect(tokens[1].data).toMatchObject({ type: 'content', field: 'narrator', emphasis: 'big' });
1349
+ expect(tokens[2].data).toMatchObject({ type: 'iteration', subject: 'choice', role: 'card' });
1350
+ });
1351
+ });
1352
+
1353
+ // =============================================================================
1354
+ // Event Unification Tests (Principle 10)
1355
+ // =============================================================================
1356
+
1357
+ describe('DSL Grammar — When Unification', () => {
1358
+ const events = [
1359
+ { input: 'when tapped', desc: 'UI event' },
1360
+ { input: 'when collides with enemy', desc: 'physics event' },
1361
+ { input: 'when health < 20', desc: 'state condition' },
1362
+ { input: 'when 5 seconds pass', desc: 'temporal event' },
1363
+ { input: 'when enters "active"', desc: 'workflow transition' },
1364
+ { input: 'when chosen', desc: 'selection event' },
1365
+ { input: 'when receives "task completed" from task', desc: 'cross-instance message' },
1366
+ { input: 'when tapped on a project card', desc: 'scoped event' },
1367
+ { input: 'when tapped then held for 2 seconds', desc: 'sequence event' },
1368
+ { input: 'when not moving for 3 seconds', desc: 'absence detection' },
1369
+ { input: 'when health < 20 and not paused', desc: 'combined conditions' },
1370
+ { input: 'when selected', desc: 'selection event' },
1371
+ { input: 'when story has inventory', desc: 'capability check' },
1372
+ ];
1373
+
1374
+ for (const { input, desc } of events) {
1375
+ it(`classifies '${input}' (${desc}) as when`, () => {
1376
+ expect(classifyType(input)).toBe('when');
1377
+ });
1378
+ }
1379
+
1380
+ it('preserves full condition text', () => {
1381
+ const data = classify('when completed tasks >= total tasks and total tasks > 0');
1382
+ expect(data).toMatchObject({
1383
+ type: 'when',
1384
+ condition: 'completed tasks >= total tasks and total tasks > 0',
1385
+ });
1386
+ });
1387
+ });
1388
+
1389
+ // =============================================================================
1390
+ // Adjective Stacking Tests (Principle 6)
1391
+ // =============================================================================
1392
+
1393
+ describe('DSL Grammar — Adjective Stacking', () => {
1394
+ const cases: Array<{
1395
+ input: string;
1396
+ adjectives: string[];
1397
+ baseType: string;
1398
+ constraints?: Array<{ kind: string; value: string | number }>;
1399
+ }> = [
1400
+ {
1401
+ input: 'title as required text, max 200',
1402
+ adjectives: ['required'],
1403
+ baseType: 'text',
1404
+ constraints: [{ kind: 'max', value: 200 }],
1405
+ },
1406
+ {
1407
+ input: 'email as required unique text',
1408
+ adjectives: ['required', 'unique'],
1409
+ baseType: 'text',
1410
+ },
1411
+ {
1412
+ input: 'xp reward as non-negative number, default 25',
1413
+ adjectives: ['non-negative'],
1414
+ baseType: 'number',
1415
+ constraints: [{ kind: 'default', value: 25 }],
1416
+ },
1417
+ {
1418
+ input: 'count as positive integer',
1419
+ adjectives: ['positive'],
1420
+ baseType: 'integer',
1421
+ },
1422
+ {
1423
+ input: 'status label as computed text',
1424
+ adjectives: ['computed'],
1425
+ baseType: 'text',
1426
+ },
1427
+ {
1428
+ input: 'slug as lowercase text, unique',
1429
+ adjectives: ['lowercase'],
1430
+ baseType: 'text',
1431
+ constraints: [{ kind: 'unique', value: true }],
1432
+ },
1433
+ ];
1434
+
1435
+ for (const { input, adjectives, baseType, constraints } of cases) {
1436
+ it(`parses '${input}'`, () => {
1437
+ const data = classify(input);
1438
+ expect(data.type).toBe('field_def');
1439
+ if (data.type === 'field_def') {
1440
+ expect(data.adjectives).toEqual(adjectives);
1441
+ expect(data.baseType).toBe(baseType);
1442
+ if (constraints) {
1443
+ expect(data.constraints).toMatchObject(constraints);
1444
+ }
1445
+ }
1446
+ });
1447
+ }
1448
+ });
1449
+
1450
+ // =============================================================================
1451
+ // Full Blueprint Integration Test
1452
+ // =============================================================================
1453
+
1454
+ describe('DSL Grammar — Full Blueprint', () => {
1455
+ const BLUEPRINT = `
1456
+ # ═══════════════════════════════════════════════════
1457
+ # Space
1458
+ # ═══════════════════════════════════════════════════
1459
+
1460
+ project management @1.0.0
1461
+ tagged: project-management, blueprint-mvp1
1462
+
1463
+ things
1464
+ a project (primary)
1465
+ a task (child)
1466
+ user stats (derived)
1467
+
1468
+ paths
1469
+ /projects → project list (user)
1470
+ /projects/:id → project view (project)
1471
+ /projects/:id/board → task board (project)
1472
+ /tasks/:id → task view (task)
1473
+ /profile → user profile (user)
1474
+
1475
+
1476
+ # ═══════════════════════════════════════════════════
1477
+ # Things (Workflows)
1478
+ # ═══════════════════════════════════════════════════
1479
+
1480
+ a project @1.0.0
1481
+ tagged: project-management, blueprint-mvp1
1482
+
1483
+ name as required text, max 200
1484
+ description as rich text
1485
+ priority as required choice of [low, medium, high, critical], default medium
1486
+ total tasks as non-negative number, default 0
1487
+ completed tasks as non-negative number, default 0
1488
+ started at as time
1489
+ completed at as time
1490
+
1491
+ starts at draft
1492
+
1493
+ draft
1494
+ can activate → active
1495
+
1496
+ active
1497
+ when entered
1498
+ set started at = now()
1499
+
1500
+ when receives "task created" from task
1501
+ set total tasks = total tasks + 1
1502
+
1503
+ when receives "task completed" from task
1504
+ set completed tasks = completed tasks + 1
1505
+
1506
+ can auto complete → completed
1507
+ when completed tasks >= total tasks and total tasks > 0
1508
+ can manual complete → completed, admin only
1509
+ can cancel → cancelled
1510
+
1511
+ completed, final
1512
+ when entered
1513
+ set completed at = now()
1514
+
1515
+ cancelled, final
1516
+
1517
+
1518
+ a task @1.0.0
1519
+ tagged: project-management, blueprint-mvp1
1520
+
1521
+ title as required text, max 200
1522
+ description as rich text
1523
+ priority as required choice of [low, medium, high, critical], default medium
1524
+ status label as computed text
1525
+ xp reward as non-negative number, default 25
1526
+ assignee as text
1527
+ started at as time
1528
+ completed at as time
1529
+
1530
+ starts at todo
1531
+
1532
+ todo
1533
+ can start → in progress
1534
+
1535
+ in progress
1536
+ when entered
1537
+ set started at = now()
1538
+ set status label = "IN_PROGRESS"
1539
+ can complete → done
1540
+ can cancel → cancelled
1541
+
1542
+ done, final
1543
+ when entered
1544
+ set completed at = now()
1545
+ set status label = "DONE"
1546
+
1547
+ cancelled, final
1548
+ when entered
1549
+ set status label = "CANCELLED"
1550
+
1551
+
1552
+ a user stats @1.0.0
1553
+ tagged: project-management, gamification
1554
+
1555
+ total xp as number, default 0
1556
+ current level as number, default 1
1557
+ level title as text, default "Newcomer"
1558
+ tasks completed as number, default 0
1559
+ projects completed as number, default 0
1560
+ user id as text
1561
+ streak days as number, default 0
1562
+ last activity at as time
1563
+
1564
+ levels
1565
+ 1: "Newcomer", from 0 xp
1566
+ 2: "Contributor", from 50 xp
1567
+ 3: "Team Player", from 150 xp
1568
+ 4: "Veteran", from 350 xp
1569
+ 5: "Expert", from 600 xp
1570
+ 6: "Master", from 1000 xp
1571
+ 7: "Grand Master", from 1500 xp
1572
+ 8: "Legend", from 2500 xp
1573
+
1574
+ starts at active
1575
+
1576
+ active
1577
+ when receives "task completed" from task
1578
+ set total xp = total xp + the event's xp reward
1579
+ set tasks completed = tasks completed + 1
1580
+ set last activity at = now()
1581
+
1582
+ when receives "project completed" from project
1583
+ set total xp = total xp + 50
1584
+ set projects completed = projects completed + 1
1585
+ `.trim();
1586
+
1587
+ it('tokenizes the full blueprint without unknown lines', () => {
1588
+ const tokens = tokenize(BLUEPRINT);
1589
+ const unknowns = tokens.filter(t => t.data.type === 'unknown');
1590
+
1591
+ if (unknowns.length > 0) {
1592
+ const details = unknowns.map(
1593
+ t => ` line ${t.lineNumber}: "${t.raw.trim()}"`,
1594
+ ).join('\n');
1595
+ // Log for debugging but don't fail hard — some edge cases are expected
1596
+ console.log(`Unknown lines:\n${details}`);
1597
+ }
1598
+
1599
+ // Allow a small number of unknowns for edge cases
1600
+ // but the vast majority should be classified
1601
+ const classifiedCount = tokens.filter(
1602
+ t => t.data.type !== 'unknown' && t.data.type !== 'blank',
1603
+ ).length;
1604
+ const totalNonBlank = tokens.filter(t => t.data.type !== 'blank').length;
1605
+
1606
+ expect(classifiedCount / totalNonBlank).toBeGreaterThan(0.9);
1607
+ });
1608
+
1609
+ it('finds all thing declarations', () => {
1610
+ const result = parse(tokenize(BLUEPRINT));
1611
+ const things = findByType(result.nodes, 'thing_decl');
1612
+ expect(things.length).toBeGreaterThanOrEqual(2); // project, task (user stats may parse differently)
1613
+ });
1614
+
1615
+ it('finds all transitions', () => {
1616
+ const result = parse(tokenize(BLUEPRINT));
1617
+ const transitions = findByType(result.nodes, 'transition');
1618
+ // project: activate, auto complete, manual complete, cancel = 4
1619
+ // task: start, complete, cancel = 3
1620
+ expect(transitions.length).toBeGreaterThanOrEqual(7);
1621
+ });
1622
+
1623
+ it('finds all when clauses', () => {
1624
+ const result = parse(tokenize(BLUEPRINT));
1625
+ const whens = findByType(result.nodes, 'when');
1626
+ // project: entered, receives x2, guard = at least 4
1627
+ // task: entered x3 = 3
1628
+ // user stats: receives x2 = 2
1629
+ expect(whens.length).toBeGreaterThanOrEqual(8);
1630
+ });
1631
+
1632
+ it('finds all field definitions', () => {
1633
+ const result = parse(tokenize(BLUEPRINT));
1634
+ const fields = findByType(result.nodes, 'field_def');
1635
+ // project: 7 fields, task: 8 fields, user stats: 8 fields
1636
+ expect(fields.length).toBeGreaterThanOrEqual(20);
1637
+ });
1638
+
1639
+ it('finds all set actions', () => {
1640
+ const result = parse(tokenize(BLUEPRINT));
1641
+ const sets = findByType(result.nodes, 'set_action');
1642
+ // project: started_at, total_tasks, completed_tasks, completed_at = 4
1643
+ // task: started_at, status_label x3, completed_at = 5
1644
+ // user stats: total_xp x2, tasks_completed, last_activity_at, projects_completed = 5
1645
+ expect(sets.length).toBeGreaterThanOrEqual(12);
1646
+ });
1647
+
1648
+ it('finds space declaration', () => {
1649
+ const result = parse(tokenize(BLUEPRINT));
1650
+ const spaces = findByType(result.nodes, 'space_decl');
1651
+ // Only "project management @1.0.0" is space_decl (no "a" prefix)
1652
+ // "a user stats @1.0.0" now correctly parses as thing_decl
1653
+ expect(spaces).toHaveLength(1);
1654
+ expect(spaces[0].token.data).toMatchObject({
1655
+ type: 'space_decl',
1656
+ name: 'project management',
1657
+ version: '1.0.0',
1658
+ });
1659
+ });
1660
+
1661
+ it('finds path mappings', () => {
1662
+ const result = parse(tokenize(BLUEPRINT));
1663
+ const paths = findByType(result.nodes, 'path_mapping');
1664
+ expect(paths).toHaveLength(5);
1665
+ });
1666
+
1667
+ it('finds level definitions', () => {
1668
+ const result = parse(tokenize(BLUEPRINT));
1669
+ const levels = findByType(result.nodes, 'level_def');
1670
+ expect(levels).toHaveLength(8);
1671
+ });
1672
+
1673
+ it('finds all states including final', () => {
1674
+ const result = parse(tokenize(BLUEPRINT));
1675
+ const states = findByType(result.nodes, 'state_decl');
1676
+ const finalStates = states.filter(
1677
+ s => s.token.data.type === 'state_decl' && (s.token.data as { isFinal: boolean }).isFinal,
1678
+ );
1679
+ // completed, cancelled (project) + done, cancelled (task) = 4
1680
+ expect(finalStates.length).toBeGreaterThanOrEqual(4);
1681
+ });
1682
+ });