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