@mmapp/player-core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,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
|
+
});
|