@lokascript/framework 2.0.0 → 2.2.0
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/aot/index.d.ts +1 -0
- package/dist/aot/index.d.ts.map +1 -1
- package/dist/aot/registry-bridge.d.ts +27 -0
- package/dist/aot/registry-bridge.d.ts.map +1 -0
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/dispatcher.d.ts +22 -0
- package/dist/api/dispatcher.d.ts.map +1 -1
- package/dist/api/domain-registry.d.ts +41 -0
- package/dist/api/domain-registry.d.ts.map +1 -1
- package/dist/api/index.js +1438 -21
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +247 -10
- package/dist/core/index.js.map +1 -1
- package/dist/core/pattern-matching/index.js +74 -10
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
- package/dist/core/tokenization/index.js +133 -0
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/core/tokenization/morphology/base-normalizer.d.ts +94 -0
- package/dist/core/tokenization/morphology/base-normalizer.d.ts.map +1 -0
- package/dist/core/tokenization/morphology/index.d.ts +3 -1
- package/dist/core/tokenization/morphology/index.d.ts.map +1 -1
- package/dist/core/types.d.ts +81 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +40 -0
- package/dist/core/types.js.map +1 -1
- package/dist/feedback/confidence-gate.d.ts +28 -0
- package/dist/feedback/confidence-gate.d.ts.map +1 -0
- package/dist/feedback/feedback-formatter.d.ts +21 -0
- package/dist/feedback/feedback-formatter.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +11 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/pattern-tracker.d.ts +47 -0
- package/dist/feedback/pattern-tracker.d.ts.map +1 -0
- package/dist/feedback/types.d.ts +113 -0
- package/dist/feedback/types.d.ts.map +1 -0
- package/dist/generation/index.js +2 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/index.cjs +3264 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3223 -132
- package/dist/index.js.map +1 -1
- package/dist/ir/explicit-parser.d.ts +86 -0
- package/dist/ir/explicit-parser.d.ts.map +1 -0
- package/dist/ir/explicit-renderer.d.ts +34 -0
- package/dist/ir/explicit-renderer.d.ts.map +1 -0
- package/dist/ir/from-interchange.d.ts +51 -0
- package/dist/ir/from-interchange.d.ts.map +1 -0
- package/dist/ir/index.d.ts +31 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +1934 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/json-schema.d.ts +40 -0
- package/dist/ir/json-schema.d.ts.map +1 -0
- package/dist/ir/protocol-json.d.ts +73 -0
- package/dist/ir/protocol-json.d.ts.map +1 -0
- package/dist/ir/references.d.ts +19 -0
- package/dist/ir/references.d.ts.map +1 -0
- package/dist/ir/to-runtime-ast.d.ts +53 -0
- package/dist/ir/to-runtime-ast.d.ts.map +1 -0
- package/dist/ir/types.d.ts +151 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/parsing/index.js +74 -10
- package/dist/parsing/index.js.map +1 -1
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/prompt-generator.d.ts +49 -0
- package/dist/prompts/prompt-generator.d.ts.map +1 -0
- package/dist/prompts/types.d.ts +64 -0
- package/dist/prompts/types.d.ts.map +1 -0
- package/dist/schema/command-schema.d.ts +17 -0
- package/dist/schema/command-schema.d.ts.map +1 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/index.js +8673 -8260
- package/dist/testing/index.js.map +1 -1
- package/dist/training/index.d.ts +11 -0
- package/dist/training/index.d.ts.map +1 -0
- package/dist/training/jsonl-writer.d.ts +42 -0
- package/dist/training/jsonl-writer.d.ts.map +1 -0
- package/dist/training/schema-synthesizer.d.ts +20 -0
- package/dist/training/schema-synthesizer.d.ts.map +1 -0
- package/dist/training/types.d.ts +73 -0
- package/dist/training/types.d.ts.map +1 -0
- package/package.json +5 -1
- package/src/aot/index.ts +1 -0
- package/src/aot/registry-bridge.test.ts +104 -0
- package/src/aot/registry-bridge.ts +66 -0
- package/src/api/create-dsl.test.ts +306 -0
- package/src/api/create-dsl.ts +21 -0
- package/src/api/dispatcher.test.ts +66 -0
- package/src/api/dispatcher.ts +77 -9
- package/src/api/domain-registry.ts +120 -4
- package/src/core/pattern-matching/pattern-matcher.test.ts +131 -0
- package/src/core/pattern-matching/pattern-matcher.ts +102 -19
- package/src/core/tokenization/morphology/base-normalizer.ts +249 -0
- package/src/core/tokenization/morphology/index.ts +3 -1
- package/src/core/types.ts +147 -2
- package/src/feedback/confidence-gate.test.ts +111 -0
- package/src/feedback/confidence-gate.ts +70 -0
- package/src/feedback/feedback-formatter.test.ts +198 -0
- package/src/feedback/feedback-formatter.ts +298 -0
- package/src/feedback/index.ts +24 -0
- package/src/feedback/pattern-tracker.test.ts +198 -0
- package/src/feedback/pattern-tracker.ts +140 -0
- package/src/feedback/types.ts +149 -0
- package/src/generation/pattern-generator.ts +2 -2
- package/src/index.ts +29 -0
- package/src/integration/llm-lse-integration.test.ts +457 -0
- package/src/ir/conformance.test.ts +120 -0
- package/src/ir/explicit-parser.test.ts +612 -0
- package/src/ir/explicit-parser.ts +793 -0
- package/src/ir/explicit-renderer.test.ts +229 -0
- package/src/ir/explicit-renderer.ts +198 -0
- package/src/ir/from-interchange.test.ts +853 -0
- package/src/ir/from-interchange.ts +594 -0
- package/src/ir/index.ts +77 -0
- package/src/ir/json-schema.test.ts +281 -0
- package/src/ir/json-schema.ts +299 -0
- package/src/ir/protocol-json.test.ts +1187 -0
- package/src/ir/protocol-json.ts +751 -0
- package/src/ir/references.test.ts +38 -0
- package/src/ir/references.ts +33 -0
- package/src/ir/round-trip.test.ts +557 -0
- package/src/ir/to-runtime-ast.ts +203 -0
- package/src/ir/types.ts +191 -0
- package/src/prompts/index.ts +15 -0
- package/src/prompts/prompt-generator.test.ts +386 -0
- package/src/prompts/prompt-generator.ts +531 -0
- package/src/prompts/types.ts +89 -0
- package/src/schema/command-schema.ts +19 -0
- package/src/training/index.ts +13 -0
- package/src/training/jsonl-writer.test.ts +138 -0
- package/src/training/jsonl-writer.ts +67 -0
- package/src/training/schema-synthesizer.test.ts +250 -0
- package/src/training/schema-synthesizer.ts +306 -0
- package/src/training/types.ts +103 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
toProtocolJSON,
|
|
4
|
+
fromProtocolJSON,
|
|
5
|
+
validateProtocolJSON,
|
|
6
|
+
toEnvelopeJSON,
|
|
7
|
+
fromEnvelopeJSON,
|
|
8
|
+
isEnvelope,
|
|
9
|
+
} from './protocol-json';
|
|
10
|
+
import {
|
|
11
|
+
createCommandNode,
|
|
12
|
+
createEventHandlerNode,
|
|
13
|
+
createCompoundNode,
|
|
14
|
+
createConditionalNode,
|
|
15
|
+
createLoopNode,
|
|
16
|
+
createTryNode,
|
|
17
|
+
createAsyncNode,
|
|
18
|
+
createMatchNode,
|
|
19
|
+
createSelector,
|
|
20
|
+
createLiteral,
|
|
21
|
+
createReference,
|
|
22
|
+
createExpression,
|
|
23
|
+
createFlag,
|
|
24
|
+
createPropertyPath,
|
|
25
|
+
} from '../core/types';
|
|
26
|
+
import type { CommandSemanticNode, CompoundSemanticNode } from '../core/types';
|
|
27
|
+
import type { ProtocolNodeJSON, LSEEnvelopeJSON } from './types';
|
|
28
|
+
|
|
29
|
+
// Fixture loading helpers
|
|
30
|
+
import { readFileSync } from 'fs';
|
|
31
|
+
import { join } from 'path';
|
|
32
|
+
|
|
33
|
+
const FIXTURE_DIR = join(__dirname, '../../../../protocol/test-fixtures');
|
|
34
|
+
|
|
35
|
+
function loadFixtures<T = Record<string, unknown>>(filename: string): T[] {
|
|
36
|
+
return JSON.parse(readFileSync(join(FIXTURE_DIR, filename), 'utf-8'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// toProtocolJSON
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe('toProtocolJSON', () => {
|
|
44
|
+
describe('command node', () => {
|
|
45
|
+
it('serializes action and roles', () => {
|
|
46
|
+
const node = createCommandNode('toggle', {
|
|
47
|
+
patient: createSelector('.active'),
|
|
48
|
+
});
|
|
49
|
+
const json = toProtocolJSON(node);
|
|
50
|
+
expect(json.kind).toBe('command');
|
|
51
|
+
expect(json.action).toBe('toggle');
|
|
52
|
+
expect(json.roles['patient']).toEqual({ type: 'selector', value: '.active' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('preserves selectorKind on selector values (v1.1)', () => {
|
|
56
|
+
const node = createCommandNode('toggle', {
|
|
57
|
+
patient: createSelector('.active', 'class'),
|
|
58
|
+
destination: createSelector('#button', 'id'),
|
|
59
|
+
});
|
|
60
|
+
const json = toProtocolJSON(node);
|
|
61
|
+
expect(json.roles['patient']).toEqual({
|
|
62
|
+
type: 'selector',
|
|
63
|
+
value: '.active',
|
|
64
|
+
selectorKind: 'class',
|
|
65
|
+
});
|
|
66
|
+
expect(json.roles['destination']).toEqual({
|
|
67
|
+
type: 'selector',
|
|
68
|
+
value: '#button',
|
|
69
|
+
selectorKind: 'id',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('serializes literal values with dataType', () => {
|
|
74
|
+
const node = createCommandNode('wait', {
|
|
75
|
+
duration: createLiteral('500ms', 'duration'),
|
|
76
|
+
});
|
|
77
|
+
const json = toProtocolJSON(node);
|
|
78
|
+
expect(json.roles['duration']).toEqual({
|
|
79
|
+
type: 'literal',
|
|
80
|
+
value: '500ms',
|
|
81
|
+
dataType: 'duration',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('serializes literal values without dataType', () => {
|
|
86
|
+
const node = createCommandNode('fetch', {
|
|
87
|
+
source: createLiteral('/api/users'),
|
|
88
|
+
});
|
|
89
|
+
const json = toProtocolJSON(node);
|
|
90
|
+
expect(json.roles['source']).toEqual({ type: 'literal', value: '/api/users' });
|
|
91
|
+
expect('dataType' in json.roles['source']).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('serializes reference values', () => {
|
|
95
|
+
const node = createCommandNode('remove', {
|
|
96
|
+
patient: createReference('me'),
|
|
97
|
+
});
|
|
98
|
+
const json = toProtocolJSON(node);
|
|
99
|
+
expect(json.roles['patient']).toEqual({ type: 'reference', value: 'me' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('serializes expression values', () => {
|
|
103
|
+
const node = createCommandNode('set', {
|
|
104
|
+
goal: createExpression('x + 1'),
|
|
105
|
+
});
|
|
106
|
+
const json = toProtocolJSON(node);
|
|
107
|
+
expect(json.roles['goal']).toEqual({ type: 'expression', raw: 'x + 1' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('flattens property-path to expression', () => {
|
|
111
|
+
const node = createCommandNode('put', {
|
|
112
|
+
patient: createPropertyPath(createReference('me'), 'value'),
|
|
113
|
+
});
|
|
114
|
+
const json = toProtocolJSON(node);
|
|
115
|
+
expect(json.roles['patient'].type).toBe('expression');
|
|
116
|
+
expect(typeof (json.roles['patient'] as { raw: string }).raw).toBe('string');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('serializes flag values', () => {
|
|
120
|
+
const node = createCommandNode('column', {
|
|
121
|
+
'primary-key': createFlag('primary-key', true),
|
|
122
|
+
nullable: createFlag('nullable', false),
|
|
123
|
+
});
|
|
124
|
+
const json = toProtocolJSON(node);
|
|
125
|
+
expect(json.roles['primary-key']).toEqual({
|
|
126
|
+
type: 'flag',
|
|
127
|
+
name: 'primary-key',
|
|
128
|
+
enabled: true,
|
|
129
|
+
});
|
|
130
|
+
expect(json.roles['nullable']).toEqual({
|
|
131
|
+
type: 'flag',
|
|
132
|
+
name: 'nullable',
|
|
133
|
+
enabled: false,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('event-handler node', () => {
|
|
139
|
+
it('serializes body array recursively', () => {
|
|
140
|
+
const body = createCommandNode('toggle', { patient: createSelector('.active') });
|
|
141
|
+
const node = createEventHandlerNode('on', { event: createLiteral('click', 'string') }, [
|
|
142
|
+
body,
|
|
143
|
+
]);
|
|
144
|
+
const json = toProtocolJSON(node);
|
|
145
|
+
expect(json.kind).toBe('event-handler');
|
|
146
|
+
expect(json.action).toBe('on');
|
|
147
|
+
expect(json.body).toHaveLength(1);
|
|
148
|
+
expect(json.body![0].kind).toBe('command');
|
|
149
|
+
expect(json.body![0].action).toBe('toggle');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('serializes nested event-handler body', () => {
|
|
153
|
+
const inner = createCommandNode('add', { patient: createSelector('.highlight') });
|
|
154
|
+
const outer = createCommandNode('remove', { patient: createSelector('.active') });
|
|
155
|
+
const node = createEventHandlerNode('on', { event: createLiteral('click', 'string') }, [
|
|
156
|
+
inner,
|
|
157
|
+
outer,
|
|
158
|
+
]);
|
|
159
|
+
const json = toProtocolJSON(node);
|
|
160
|
+
expect(json.body).toHaveLength(2);
|
|
161
|
+
expect(json.body![1].action).toBe('remove');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('compound node', () => {
|
|
166
|
+
it('serializes with fixed action "compound" and omitted roles', () => {
|
|
167
|
+
const a = createCommandNode('add', { patient: createSelector('.loading') });
|
|
168
|
+
const b = createCommandNode('fetch', { source: createLiteral('/api', 'string') });
|
|
169
|
+
const node = createCompoundNode([a, b], 'then');
|
|
170
|
+
const json = toProtocolJSON(node);
|
|
171
|
+
expect(json.kind).toBe('compound');
|
|
172
|
+
expect(json.action).toBe('compound');
|
|
173
|
+
expect(json.roles).toBeUndefined();
|
|
174
|
+
expect(json.statements).toHaveLength(2);
|
|
175
|
+
expect(json.chainType).toBe('then');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('serializes statements recursively', () => {
|
|
179
|
+
const node = createCompoundNode(
|
|
180
|
+
[
|
|
181
|
+
createCommandNode('add', { patient: createSelector('.a') }),
|
|
182
|
+
createCommandNode('remove', { patient: createSelector('.b') }),
|
|
183
|
+
],
|
|
184
|
+
'and'
|
|
185
|
+
);
|
|
186
|
+
const json = toProtocolJSON(node);
|
|
187
|
+
expect(json.statements![0].action).toBe('add');
|
|
188
|
+
expect(json.statements![1].action).toBe('remove');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('TS-only node kinds (lossless v1.1 encoding)', () => {
|
|
193
|
+
it('encodes conditional as command with thenBranch/elseBranch', () => {
|
|
194
|
+
const thenBranch = [createCommandNode('toggle', { patient: createSelector('.active') })];
|
|
195
|
+
const elseBranch = [createCommandNode('remove', { patient: createSelector('.active') })];
|
|
196
|
+
const node = createConditionalNode(
|
|
197
|
+
'if',
|
|
198
|
+
{ condition: createExpression('x > 0') },
|
|
199
|
+
thenBranch,
|
|
200
|
+
elseBranch
|
|
201
|
+
);
|
|
202
|
+
const json = toProtocolJSON(node);
|
|
203
|
+
expect(json.kind).toBe('command');
|
|
204
|
+
expect(json.action).toBe('if');
|
|
205
|
+
expect(json.thenBranch).toHaveLength(1);
|
|
206
|
+
expect(json.thenBranch![0].action).toBe('toggle');
|
|
207
|
+
expect(json.elseBranch).toHaveLength(1);
|
|
208
|
+
expect(json.elseBranch![0].action).toBe('remove');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('encodes loop as command with loopVariant/loopBody', () => {
|
|
212
|
+
const body = [createCommandNode('toggle', { patient: createSelector('.active') })];
|
|
213
|
+
const node = createLoopNode(
|
|
214
|
+
'repeat',
|
|
215
|
+
{ count: createLiteral(5, 'number') },
|
|
216
|
+
'times',
|
|
217
|
+
body,
|
|
218
|
+
'item'
|
|
219
|
+
);
|
|
220
|
+
const json = toProtocolJSON(node);
|
|
221
|
+
expect(json.kind).toBe('command');
|
|
222
|
+
expect(json.action).toBe('repeat');
|
|
223
|
+
expect(json.loopVariant).toBe('times');
|
|
224
|
+
expect(json.loopBody).toHaveLength(1);
|
|
225
|
+
expect(json.loopBody![0].action).toBe('toggle');
|
|
226
|
+
expect(json.loopVariable).toBe('item');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// fromProtocolJSON
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe('fromProtocolJSON', () => {
|
|
236
|
+
it('deserializes command node', () => {
|
|
237
|
+
const json = {
|
|
238
|
+
kind: 'command' as const,
|
|
239
|
+
action: 'toggle',
|
|
240
|
+
roles: { patient: { type: 'selector' as const, value: '.active' } },
|
|
241
|
+
};
|
|
242
|
+
const node = fromProtocolJSON(json);
|
|
243
|
+
expect(node.kind).toBe('command');
|
|
244
|
+
expect(node.action).toBe('toggle');
|
|
245
|
+
expect(node.roles.get('patient')).toEqual({ type: 'selector', value: '.active' });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('deserializes event-handler with body', () => {
|
|
249
|
+
const json = {
|
|
250
|
+
kind: 'event-handler' as const,
|
|
251
|
+
action: 'on',
|
|
252
|
+
roles: { event: { type: 'literal' as const, value: 'click', dataType: 'string' as const } },
|
|
253
|
+
body: [
|
|
254
|
+
{
|
|
255
|
+
kind: 'command' as const,
|
|
256
|
+
action: 'toggle',
|
|
257
|
+
roles: { patient: { type: 'selector' as const, value: '.active' } },
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const node = fromProtocolJSON(json);
|
|
262
|
+
expect(node.kind).toBe('event-handler');
|
|
263
|
+
const eh = node as import('../core/types').EventHandlerSemanticNode;
|
|
264
|
+
expect(eh.body).toHaveLength(1);
|
|
265
|
+
expect(eh.body[0].action).toBe('toggle');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('deserializes compound with statements', () => {
|
|
269
|
+
const json = {
|
|
270
|
+
kind: 'compound' as const,
|
|
271
|
+
action: 'compound',
|
|
272
|
+
roles: {},
|
|
273
|
+
statements: [
|
|
274
|
+
{
|
|
275
|
+
kind: 'command' as const,
|
|
276
|
+
action: 'add',
|
|
277
|
+
roles: { patient: { type: 'selector' as const, value: '.loading' } },
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
chainType: 'then' as const,
|
|
281
|
+
};
|
|
282
|
+
const node = fromProtocolJSON(json);
|
|
283
|
+
expect(node.kind).toBe('compound');
|
|
284
|
+
const c = node as import('../core/types').CompoundSemanticNode;
|
|
285
|
+
expect(c.statements).toHaveLength(1);
|
|
286
|
+
expect(c.chainType).toBe('then');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('restores roles as ReadonlyMap', () => {
|
|
290
|
+
const json = {
|
|
291
|
+
kind: 'command' as const,
|
|
292
|
+
action: 'toggle',
|
|
293
|
+
roles: {
|
|
294
|
+
patient: { type: 'selector' as const, value: '.active' },
|
|
295
|
+
destination: { type: 'selector' as const, value: '#button' },
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
const node = fromProtocolJSON(json);
|
|
299
|
+
expect(node.roles).toBeInstanceOf(Map);
|
|
300
|
+
expect(node.roles.size).toBe(2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('restores all value types', () => {
|
|
304
|
+
const json = {
|
|
305
|
+
kind: 'command' as const,
|
|
306
|
+
action: 'test',
|
|
307
|
+
roles: {
|
|
308
|
+
sel: { type: 'selector' as const, value: '.active' },
|
|
309
|
+
lit: { type: 'literal' as const, value: 42, dataType: 'number' as const },
|
|
310
|
+
ref: { type: 'reference' as const, value: 'me' },
|
|
311
|
+
expr: { type: 'expression' as const, raw: 'x + 1' },
|
|
312
|
+
flag: { type: 'flag' as const, name: 'primary-key', enabled: true },
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const node = fromProtocolJSON(json);
|
|
316
|
+
expect(node.roles.get('sel')).toEqual({ type: 'selector', value: '.active' });
|
|
317
|
+
expect(node.roles.get('lit')).toEqual({ type: 'literal', value: 42, dataType: 'number' });
|
|
318
|
+
expect(node.roles.get('ref')).toEqual({ type: 'reference', value: 'me' });
|
|
319
|
+
expect(node.roles.get('expr')).toEqual({ type: 'expression', raw: 'x + 1' });
|
|
320
|
+
expect(node.roles.get('flag')).toEqual({ type: 'flag', name: 'primary-key', enabled: true });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('defaults compound chainType to sequential when absent', () => {
|
|
324
|
+
const json = {
|
|
325
|
+
kind: 'compound' as const,
|
|
326
|
+
action: 'compound',
|
|
327
|
+
roles: {},
|
|
328
|
+
statements: [],
|
|
329
|
+
};
|
|
330
|
+
const node = fromProtocolJSON(json);
|
|
331
|
+
const c = node as import('../core/types').CompoundSemanticNode;
|
|
332
|
+
expect(c.chainType).toBe('sequential');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('defaults kind to command when absent', () => {
|
|
336
|
+
const json = {
|
|
337
|
+
action: 'toggle',
|
|
338
|
+
roles: { patient: { type: 'selector' as const, value: '.active' } },
|
|
339
|
+
};
|
|
340
|
+
const node = fromProtocolJSON(json as any);
|
|
341
|
+
expect(node.kind).toBe('command');
|
|
342
|
+
expect(node.action).toBe('toggle');
|
|
343
|
+
expect(node.roles.get('patient')).toEqual({ type: 'selector', value: '.active' });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('deserializes trigger sugar into event handler', () => {
|
|
347
|
+
const json = {
|
|
348
|
+
action: 'toggle',
|
|
349
|
+
roles: { patient: { type: 'selector' as const, value: '.active' } },
|
|
350
|
+
trigger: { event: 'click' },
|
|
351
|
+
};
|
|
352
|
+
const node = fromProtocolJSON(json as any);
|
|
353
|
+
expect(node.kind).toBe('event-handler');
|
|
354
|
+
expect(node.action).toBe('on');
|
|
355
|
+
const eh = node as import('../core/types').EventHandlerSemanticNode;
|
|
356
|
+
expect(eh.body).toHaveLength(1);
|
|
357
|
+
expect(eh.body[0].action).toBe('toggle');
|
|
358
|
+
expect(eh.body[0].roles.get('patient')).toEqual({ type: 'selector', value: '.active' });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('accepts expression value with value instead of raw', () => {
|
|
362
|
+
const json = {
|
|
363
|
+
kind: 'command' as const,
|
|
364
|
+
action: 'set',
|
|
365
|
+
roles: { goal: { type: 'expression' as const, value: 'x + 1' } },
|
|
366
|
+
};
|
|
367
|
+
const node = fromProtocolJSON(json as any);
|
|
368
|
+
expect(node.roles.get('goal')).toEqual({ type: 'expression', raw: 'x + 1' });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('accepts flag value shorthand', () => {
|
|
372
|
+
const json = {
|
|
373
|
+
kind: 'command' as const,
|
|
374
|
+
action: 'column',
|
|
375
|
+
roles: { 'primary-key': { type: 'flag' as const, value: true } },
|
|
376
|
+
};
|
|
377
|
+
const node = fromProtocolJSON(json as any);
|
|
378
|
+
expect(node.roles.get('primary-key')).toEqual({ type: 'flag', name: 'true', enabled: true });
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Round-trip
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
describe('round-trip', () => {
|
|
387
|
+
it('command node round-trips', () => {
|
|
388
|
+
const original = createCommandNode('toggle', {
|
|
389
|
+
patient: createSelector('.active'),
|
|
390
|
+
destination: createSelector('#button'),
|
|
391
|
+
});
|
|
392
|
+
const restored = fromProtocolJSON(toProtocolJSON(original));
|
|
393
|
+
expect(restored.kind).toBe('command');
|
|
394
|
+
expect(restored.action).toBe('toggle');
|
|
395
|
+
expect(restored.roles.get('patient')).toEqual({ type: 'selector', value: '.active' });
|
|
396
|
+
expect(restored.roles.get('destination')).toEqual({ type: 'selector', value: '#button' });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('event-handler node round-trips', () => {
|
|
400
|
+
const original = createEventHandlerNode('on', { event: createLiteral('click', 'string') }, [
|
|
401
|
+
createCommandNode('toggle', { patient: createSelector('.active') }),
|
|
402
|
+
]);
|
|
403
|
+
const restored = fromProtocolJSON(toProtocolJSON(original));
|
|
404
|
+
expect(restored.kind).toBe('event-handler');
|
|
405
|
+
const eh = restored as import('../core/types').EventHandlerSemanticNode;
|
|
406
|
+
expect(eh.body[0].action).toBe('toggle');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('compound node round-trips', () => {
|
|
410
|
+
const original = createCompoundNode(
|
|
411
|
+
[
|
|
412
|
+
createCommandNode('add', { patient: createSelector('.loading') }),
|
|
413
|
+
createCommandNode('fetch', { source: createLiteral('/api', 'string') }),
|
|
414
|
+
],
|
|
415
|
+
'then'
|
|
416
|
+
);
|
|
417
|
+
const restored = fromProtocolJSON(toProtocolJSON(original));
|
|
418
|
+
expect(restored.kind).toBe('compound');
|
|
419
|
+
const c = restored as import('../core/types').CompoundSemanticNode;
|
|
420
|
+
expect(c.statements).toHaveLength(2);
|
|
421
|
+
expect(c.chainType).toBe('then');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('conditional round-trip is lossless (v1.1)', () => {
|
|
425
|
+
const original = createConditionalNode('if', { condition: createExpression('x > 0') }, [
|
|
426
|
+
createCommandNode('show', { patient: createSelector('#result') }),
|
|
427
|
+
]);
|
|
428
|
+
const protocol = toProtocolJSON(original);
|
|
429
|
+
const restored = fromProtocolJSON(protocol);
|
|
430
|
+
expect(restored.kind).toBe('conditional');
|
|
431
|
+
expect(restored.action).toBe('if');
|
|
432
|
+
const cond = restored as import('../core/types').ConditionalSemanticNode;
|
|
433
|
+
expect(cond.thenBranch).toHaveLength(1);
|
|
434
|
+
expect(cond.thenBranch[0].action).toBe('show');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('loop round-trip is lossless (v1.1)', () => {
|
|
438
|
+
const body = [createCommandNode('wait', { duration: createLiteral('1s', 'duration') })];
|
|
439
|
+
const original = createLoopNode('repeat', {}, 'forever', body);
|
|
440
|
+
const protocol = toProtocolJSON(original);
|
|
441
|
+
const restored = fromProtocolJSON(protocol);
|
|
442
|
+
expect(restored.kind).toBe('loop');
|
|
443
|
+
const loop = restored as import('../core/types').LoopSemanticNode;
|
|
444
|
+
expect(loop.loopVariant).toBe('forever');
|
|
445
|
+
expect(loop.body).toHaveLength(1);
|
|
446
|
+
expect(loop.body[0].action).toBe('wait');
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// validateProtocolJSON
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
describe('validateProtocolJSON', () => {
|
|
455
|
+
const validCommand = {
|
|
456
|
+
kind: 'command',
|
|
457
|
+
action: 'toggle',
|
|
458
|
+
roles: { patient: { type: 'selector', value: '.active' } },
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
it('accepts valid command node', () => {
|
|
462
|
+
expect(validateProtocolJSON(validCommand)).toEqual([]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('accepts valid event-handler node', () => {
|
|
466
|
+
const json = {
|
|
467
|
+
kind: 'event-handler',
|
|
468
|
+
action: 'on',
|
|
469
|
+
roles: { event: { type: 'literal', value: 'click', dataType: 'string' } },
|
|
470
|
+
body: [validCommand],
|
|
471
|
+
};
|
|
472
|
+
expect(validateProtocolJSON(json)).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('accepts valid compound node', () => {
|
|
476
|
+
const json = {
|
|
477
|
+
kind: 'compound',
|
|
478
|
+
action: 'compound',
|
|
479
|
+
roles: {},
|
|
480
|
+
statements: [validCommand],
|
|
481
|
+
chainType: 'then',
|
|
482
|
+
};
|
|
483
|
+
expect(validateProtocolJSON(json)).toEqual([]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('rejects null', () => {
|
|
487
|
+
const errors = validateProtocolJSON(null);
|
|
488
|
+
expect(errors.some(e => e.code === 'INVALID_ROOT')).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('accepts missing kind (defaults to command)', () => {
|
|
492
|
+
const errors = validateProtocolJSON({ action: 'toggle', roles: {} });
|
|
493
|
+
expect(errors).toEqual([]);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('rejects invalid kind', () => {
|
|
497
|
+
const errors = validateProtocolJSON({ kind: 'conditional', action: 'if', roles: {} });
|
|
498
|
+
expect(errors.some(e => e.code === 'INVALID_KIND')).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('rejects missing action', () => {
|
|
502
|
+
const errors = validateProtocolJSON({ kind: 'command', roles: {} });
|
|
503
|
+
expect(errors.some(e => e.code === 'MISSING_ACTION')).toBe(true);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('rejects empty action string', () => {
|
|
507
|
+
const errors = validateProtocolJSON({ kind: 'command', action: '', roles: {} });
|
|
508
|
+
expect(errors.some(e => e.code === 'INVALID_ACTION')).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('rejects missing roles', () => {
|
|
512
|
+
const errors = validateProtocolJSON({ kind: 'command', action: 'toggle' });
|
|
513
|
+
expect(errors.some(e => e.code === 'MISSING_ROLES')).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('rejects roles as array', () => {
|
|
517
|
+
const errors = validateProtocolJSON({ kind: 'command', action: 'toggle', roles: [] });
|
|
518
|
+
expect(errors.some(e => e.code === 'INVALID_ROLES')).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('rejects invalid role value type', () => {
|
|
522
|
+
const errors = validateProtocolJSON({
|
|
523
|
+
kind: 'command',
|
|
524
|
+
action: 'toggle',
|
|
525
|
+
roles: { patient: { type: 'conditional' } },
|
|
526
|
+
});
|
|
527
|
+
expect(errors.some(e => e.code === 'INVALID_VALUE_TYPE')).toBe(true);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('rejects event-handler without body', () => {
|
|
531
|
+
const errors = validateProtocolJSON({
|
|
532
|
+
kind: 'event-handler',
|
|
533
|
+
action: 'on',
|
|
534
|
+
roles: {},
|
|
535
|
+
});
|
|
536
|
+
expect(errors.some(e => e.code === 'MISSING_BODY')).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('rejects compound without statements', () => {
|
|
540
|
+
const errors = validateProtocolJSON({
|
|
541
|
+
kind: 'compound',
|
|
542
|
+
action: 'compound',
|
|
543
|
+
roles: {},
|
|
544
|
+
});
|
|
545
|
+
expect(errors.some(e => e.code === 'MISSING_STATEMENTS')).toBe(true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('rejects compound with invalid chainType', () => {
|
|
549
|
+
const errors = validateProtocolJSON({
|
|
550
|
+
kind: 'compound',
|
|
551
|
+
action: 'compound',
|
|
552
|
+
roles: {},
|
|
553
|
+
statements: [],
|
|
554
|
+
chainType: 'invalid',
|
|
555
|
+
});
|
|
556
|
+
expect(errors.some(e => e.code === 'INVALID_CHAIN_TYPE')).toBe(true);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('rejects flag without name', () => {
|
|
560
|
+
const errors = validateProtocolJSON({
|
|
561
|
+
kind: 'command',
|
|
562
|
+
action: 'column',
|
|
563
|
+
roles: { 'primary-key': { type: 'flag', enabled: true } },
|
|
564
|
+
});
|
|
565
|
+
expect(errors.some(e => e.code === 'MISSING_FLAG_NAME')).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('rejects flag without enabled', () => {
|
|
569
|
+
const errors = validateProtocolJSON({
|
|
570
|
+
kind: 'command',
|
|
571
|
+
action: 'column',
|
|
572
|
+
roles: { 'primary-key': { type: 'flag', name: 'primary-key' } },
|
|
573
|
+
});
|
|
574
|
+
expect(errors.some(e => e.code === 'MISSING_FLAG_ENABLED')).toBe(true);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('accepts expression with value as fallback for raw', () => {
|
|
578
|
+
const errors = validateProtocolJSON({
|
|
579
|
+
kind: 'command',
|
|
580
|
+
action: 'set',
|
|
581
|
+
roles: { goal: { type: 'expression', value: 'x + 1' } },
|
|
582
|
+
});
|
|
583
|
+
expect(errors).toEqual([]);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('rejects expression without raw or value', () => {
|
|
587
|
+
const errors = validateProtocolJSON({
|
|
588
|
+
kind: 'command',
|
|
589
|
+
action: 'set',
|
|
590
|
+
roles: { goal: { type: 'expression' } },
|
|
591
|
+
});
|
|
592
|
+
expect(errors.some(e => e.code === 'MISSING_EXPRESSION_RAW')).toBe(true);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Unified format: trigger sugar, optional kind, value fallbacks
|
|
596
|
+
it('accepts trigger sugar and wraps in event handler', () => {
|
|
597
|
+
const errors = validateProtocolJSON({
|
|
598
|
+
action: 'toggle',
|
|
599
|
+
roles: { patient: { type: 'selector', value: '.active' } },
|
|
600
|
+
trigger: { event: 'click' },
|
|
601
|
+
});
|
|
602
|
+
expect(errors).toEqual([]);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('rejects trigger with missing event', () => {
|
|
606
|
+
const errors = validateProtocolJSON({
|
|
607
|
+
action: 'toggle',
|
|
608
|
+
roles: {},
|
|
609
|
+
trigger: { modifiers: {} },
|
|
610
|
+
});
|
|
611
|
+
expect(errors.some(e => e.code === 'INVALID_TRIGGER')).toBe(true);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('accepts flag with value shorthand', () => {
|
|
615
|
+
const errors = validateProtocolJSON({
|
|
616
|
+
action: 'column',
|
|
617
|
+
roles: { 'primary-key': { type: 'flag', value: true } },
|
|
618
|
+
});
|
|
619
|
+
expect(errors).toEqual([]);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// v1.2 validation
|
|
623
|
+
it('accepts pipe as valid chainType (v1.2)', () => {
|
|
624
|
+
expect(
|
|
625
|
+
validateProtocolJSON({
|
|
626
|
+
kind: 'compound',
|
|
627
|
+
action: 'compound',
|
|
628
|
+
roles: {},
|
|
629
|
+
statements: [],
|
|
630
|
+
chainType: 'pipe',
|
|
631
|
+
})
|
|
632
|
+
).toEqual([]);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('validates diagnostics array entries (v1.2)', () => {
|
|
636
|
+
const errors = validateProtocolJSON({
|
|
637
|
+
kind: 'command',
|
|
638
|
+
action: 'toggle',
|
|
639
|
+
roles: {},
|
|
640
|
+
diagnostics: [{ level: 'invalid' }],
|
|
641
|
+
});
|
|
642
|
+
expect(errors.some(e => e.code === 'INVALID_DIAGNOSTIC_LEVEL')).toBe(true);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('validates annotations array entries (v1.2)', () => {
|
|
646
|
+
const errors = validateProtocolJSON({
|
|
647
|
+
kind: 'command',
|
|
648
|
+
action: 'toggle',
|
|
649
|
+
roles: {},
|
|
650
|
+
annotations: [{}],
|
|
651
|
+
});
|
|
652
|
+
expect(errors.some(e => e.code === 'MISSING_ANNOTATION_NAME')).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('validates asyncVariant (v1.2)', () => {
|
|
656
|
+
const errors = validateProtocolJSON({
|
|
657
|
+
kind: 'command',
|
|
658
|
+
action: 'all',
|
|
659
|
+
roles: {},
|
|
660
|
+
asyncVariant: 'invalid',
|
|
661
|
+
});
|
|
662
|
+
expect(errors.some(e => e.code === 'INVALID_ASYNC_VARIANT')).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('validates arms entries (v1.2)', () => {
|
|
666
|
+
const errors = validateProtocolJSON({
|
|
667
|
+
kind: 'command',
|
|
668
|
+
action: 'match',
|
|
669
|
+
roles: {},
|
|
670
|
+
arms: [{ pattern: null }],
|
|
671
|
+
});
|
|
672
|
+
expect(
|
|
673
|
+
errors.some(e => e.code === 'MISSING_ARM_PATTERN' || e.code === 'MISSING_ARM_BODY')
|
|
674
|
+
).toBe(true);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// ===========================================================================
|
|
679
|
+
// v1.2: try/catch/finally
|
|
680
|
+
// ===========================================================================
|
|
681
|
+
|
|
682
|
+
describe('v1.2: try/catch/finally', () => {
|
|
683
|
+
it('createTryNode produces correct structure', () => {
|
|
684
|
+
const body = [createCommandNode('fetch', { source: createLiteral('/api', 'string') })];
|
|
685
|
+
const catchBranch = [createCommandNode('log', { patient: createLiteral('error', 'string') })];
|
|
686
|
+
const node = createTryNode(body, catchBranch);
|
|
687
|
+
expect(node.kind).toBe('command');
|
|
688
|
+
expect(node.action).toBe('try');
|
|
689
|
+
expect(node.body).toHaveLength(1);
|
|
690
|
+
expect(node.catchBranch).toHaveLength(1);
|
|
691
|
+
expect(node.finallyBranch).toBeUndefined();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('serializes try/catch/finally to protocol JSON', () => {
|
|
695
|
+
const node = createTryNode(
|
|
696
|
+
[createCommandNode('fetch', { source: createLiteral('/api/users', 'string') })],
|
|
697
|
+
[createCommandNode('show', { patient: createSelector('#error-message') })],
|
|
698
|
+
[createCommandNode('remove', { patient: createSelector('.loading') })]
|
|
699
|
+
);
|
|
700
|
+
const json = toProtocolJSON(node);
|
|
701
|
+
expect(json.kind).toBe('command');
|
|
702
|
+
expect(json.action).toBe('try');
|
|
703
|
+
expect(json.body).toHaveLength(1);
|
|
704
|
+
expect(json.catchBranch).toHaveLength(1);
|
|
705
|
+
expect(json.finallyBranch).toHaveLength(1);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('deserializes try/catch/finally from protocol JSON', () => {
|
|
709
|
+
const json: ProtocolNodeJSON = {
|
|
710
|
+
kind: 'command',
|
|
711
|
+
action: 'try',
|
|
712
|
+
roles: {},
|
|
713
|
+
body: [
|
|
714
|
+
{
|
|
715
|
+
kind: 'command',
|
|
716
|
+
action: 'fetch',
|
|
717
|
+
roles: { source: { type: 'literal', value: '/api', dataType: 'string' } },
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
catchBranch: [
|
|
721
|
+
{
|
|
722
|
+
kind: 'command',
|
|
723
|
+
action: 'log',
|
|
724
|
+
roles: { patient: { type: 'literal', value: 'error', dataType: 'string' } },
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
finallyBranch: [
|
|
728
|
+
{
|
|
729
|
+
kind: 'command',
|
|
730
|
+
action: 'remove',
|
|
731
|
+
roles: { patient: { type: 'selector', value: '.loading' } },
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
};
|
|
735
|
+
const node = fromProtocolJSON(json) as CommandSemanticNode;
|
|
736
|
+
expect(node.kind).toBe('command');
|
|
737
|
+
expect(node.action).toBe('try');
|
|
738
|
+
expect(node.body).toHaveLength(1);
|
|
739
|
+
expect(node.catchBranch).toHaveLength(1);
|
|
740
|
+
expect(node.finallyBranch).toHaveLength(1);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe('fixture conformance: try-catch.json', () => {
|
|
744
|
+
const fixtures = loadFixtures('try-catch.json');
|
|
745
|
+
for (const fixture of fixtures) {
|
|
746
|
+
const f = fixture as {
|
|
747
|
+
id: string;
|
|
748
|
+
description: string;
|
|
749
|
+
jsonInput: ProtocolNodeJSON;
|
|
750
|
+
expectedRoundTrip?: boolean;
|
|
751
|
+
};
|
|
752
|
+
if (f.expectedRoundTrip) {
|
|
753
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
754
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
755
|
+
const roundTripped = toProtocolJSON(node);
|
|
756
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// ===========================================================================
|
|
764
|
+
// v1.2: async coordination (all/race)
|
|
765
|
+
// ===========================================================================
|
|
766
|
+
|
|
767
|
+
describe('v1.2: async coordination', () => {
|
|
768
|
+
it('createAsyncNode produces correct structure', () => {
|
|
769
|
+
const body = [
|
|
770
|
+
createCommandNode('fetch', { source: createLiteral('/api/user', 'string') }),
|
|
771
|
+
createCommandNode('fetch', { source: createLiteral('/api/prefs', 'string') }),
|
|
772
|
+
];
|
|
773
|
+
const node = createAsyncNode('all', body);
|
|
774
|
+
expect(node.kind).toBe('command');
|
|
775
|
+
expect(node.action).toBe('all');
|
|
776
|
+
expect(node.asyncVariant).toBe('all');
|
|
777
|
+
expect(node.asyncBody).toHaveLength(2);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('serializes async all to protocol JSON', () => {
|
|
781
|
+
const node = createAsyncNode('all', [
|
|
782
|
+
createCommandNode('fetch', { source: createLiteral('/api/user', 'string') }),
|
|
783
|
+
]);
|
|
784
|
+
const json = toProtocolJSON(node);
|
|
785
|
+
expect(json.asyncVariant).toBe('all');
|
|
786
|
+
expect(json.asyncBody).toHaveLength(1);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('deserializes async race from protocol JSON', () => {
|
|
790
|
+
const json: ProtocolNodeJSON = {
|
|
791
|
+
kind: 'command',
|
|
792
|
+
action: 'race',
|
|
793
|
+
roles: {},
|
|
794
|
+
asyncVariant: 'race',
|
|
795
|
+
asyncBody: [
|
|
796
|
+
{
|
|
797
|
+
kind: 'command',
|
|
798
|
+
action: 'fetch',
|
|
799
|
+
roles: { source: { type: 'literal', value: '/cache', dataType: 'string' } },
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
kind: 'command',
|
|
803
|
+
action: 'fetch',
|
|
804
|
+
roles: { source: { type: 'literal', value: '/api', dataType: 'string' } },
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
};
|
|
808
|
+
const node = fromProtocolJSON(json) as CommandSemanticNode;
|
|
809
|
+
expect(node.asyncVariant).toBe('race');
|
|
810
|
+
expect(node.asyncBody).toHaveLength(2);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
describe('fixture conformance: async-coordination.json', () => {
|
|
814
|
+
const fixtures = loadFixtures('async-coordination.json');
|
|
815
|
+
for (const fixture of fixtures) {
|
|
816
|
+
const f = fixture as {
|
|
817
|
+
id: string;
|
|
818
|
+
description: string;
|
|
819
|
+
jsonInput: ProtocolNodeJSON;
|
|
820
|
+
expectedRoundTrip?: boolean;
|
|
821
|
+
};
|
|
822
|
+
if (f.expectedRoundTrip) {
|
|
823
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
824
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
825
|
+
const roundTripped = toProtocolJSON(node);
|
|
826
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// ===========================================================================
|
|
834
|
+
// v1.2: match/arms
|
|
835
|
+
// ===========================================================================
|
|
836
|
+
|
|
837
|
+
describe('v1.2: match', () => {
|
|
838
|
+
it('createMatchNode produces correct structure', () => {
|
|
839
|
+
const arms = [
|
|
840
|
+
{
|
|
841
|
+
pattern: createLiteral('200', 'string'),
|
|
842
|
+
body: [createCommandNode('show', { patient: createSelector('#success') })],
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
pattern: createLiteral('404', 'string'),
|
|
846
|
+
body: [createCommandNode('show', { patient: createSelector('#not-found') })],
|
|
847
|
+
},
|
|
848
|
+
];
|
|
849
|
+
const node = createMatchNode({ patient: createReference('result') }, arms, [
|
|
850
|
+
createCommandNode('log', { patient: createReference('result') }),
|
|
851
|
+
]);
|
|
852
|
+
expect(node.kind).toBe('command');
|
|
853
|
+
expect(node.action).toBe('match');
|
|
854
|
+
expect(node.arms).toHaveLength(2);
|
|
855
|
+
expect(node.defaultArm).toHaveLength(1);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('serializes match arms to protocol JSON', () => {
|
|
859
|
+
const node = createMatchNode({ patient: createReference('result') }, [
|
|
860
|
+
{
|
|
861
|
+
pattern: createLiteral('ok', 'string'),
|
|
862
|
+
body: [createCommandNode('toggle', { patient: createSelector('.active') })],
|
|
863
|
+
},
|
|
864
|
+
]);
|
|
865
|
+
const json = toProtocolJSON(node);
|
|
866
|
+
expect(json.arms).toHaveLength(1);
|
|
867
|
+
expect(json.arms![0].pattern).toEqual({ type: 'literal', value: 'ok', dataType: 'string' });
|
|
868
|
+
expect(json.arms![0].body).toHaveLength(1);
|
|
869
|
+
expect(json.defaultArm).toBeUndefined();
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('deserializes match with default arm from protocol JSON', () => {
|
|
873
|
+
const json: ProtocolNodeJSON = {
|
|
874
|
+
kind: 'command',
|
|
875
|
+
action: 'match',
|
|
876
|
+
roles: { patient: { type: 'reference', value: 'result' } },
|
|
877
|
+
arms: [
|
|
878
|
+
{
|
|
879
|
+
pattern: { type: 'literal', value: '200', dataType: 'string' },
|
|
880
|
+
body: [
|
|
881
|
+
{
|
|
882
|
+
kind: 'command',
|
|
883
|
+
action: 'show',
|
|
884
|
+
roles: { patient: { type: 'selector', value: '#success' } },
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
defaultArm: [
|
|
890
|
+
{
|
|
891
|
+
kind: 'command',
|
|
892
|
+
action: 'log',
|
|
893
|
+
roles: { patient: { type: 'reference', value: 'result' } },
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
const node = fromProtocolJSON(json) as CommandSemanticNode;
|
|
898
|
+
expect(node.arms).toHaveLength(1);
|
|
899
|
+
expect(node.defaultArm).toHaveLength(1);
|
|
900
|
+
expect(node.roles.get('patient')).toEqual({ type: 'reference', value: 'result' });
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
describe('fixture conformance: match.json', () => {
|
|
904
|
+
const fixtures = loadFixtures('match.json');
|
|
905
|
+
for (const fixture of fixtures) {
|
|
906
|
+
const f = fixture as {
|
|
907
|
+
id: string;
|
|
908
|
+
description: string;
|
|
909
|
+
jsonInput: ProtocolNodeJSON;
|
|
910
|
+
expectedRoundTrip?: boolean;
|
|
911
|
+
};
|
|
912
|
+
if (f.expectedRoundTrip) {
|
|
913
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
914
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
915
|
+
const roundTripped = toProtocolJSON(node);
|
|
916
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// ===========================================================================
|
|
924
|
+
// v1.2: annotations
|
|
925
|
+
// ===========================================================================
|
|
926
|
+
|
|
927
|
+
describe('v1.2: annotations', () => {
|
|
928
|
+
it('serializes annotations on command node', () => {
|
|
929
|
+
const node: CommandSemanticNode = {
|
|
930
|
+
...createCommandNode('fetch', { source: createLiteral('/api', 'string') }),
|
|
931
|
+
annotations: [{ name: 'timeout', value: '5s' }],
|
|
932
|
+
};
|
|
933
|
+
const json = toProtocolJSON(node);
|
|
934
|
+
expect(json.annotations).toEqual([{ name: 'timeout', value: '5s' }]);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('serializes annotation without value', () => {
|
|
938
|
+
const node: CommandSemanticNode = {
|
|
939
|
+
...createCommandNode('toggle', { patient: createSelector('.active') }),
|
|
940
|
+
annotations: [{ name: 'deprecated' }],
|
|
941
|
+
};
|
|
942
|
+
const json = toProtocolJSON(node);
|
|
943
|
+
expect(json.annotations).toEqual([{ name: 'deprecated' }]);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('deserializes annotations from protocol JSON', () => {
|
|
947
|
+
const json: ProtocolNodeJSON = {
|
|
948
|
+
kind: 'command',
|
|
949
|
+
action: 'fetch',
|
|
950
|
+
roles: { source: { type: 'literal', value: '/api', dataType: 'string' } },
|
|
951
|
+
annotations: [
|
|
952
|
+
{ name: 'retry', value: '3' },
|
|
953
|
+
{ name: 'timeout', value: '10s' },
|
|
954
|
+
],
|
|
955
|
+
};
|
|
956
|
+
const node = fromProtocolJSON(json);
|
|
957
|
+
expect(node.annotations).toHaveLength(2);
|
|
958
|
+
expect(node.annotations![0].name).toBe('retry');
|
|
959
|
+
expect(node.annotations![1].value).toBe('10s');
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('omits annotations field when empty', () => {
|
|
963
|
+
const node = createCommandNode('toggle', { patient: createSelector('.active') });
|
|
964
|
+
const json = toProtocolJSON(node);
|
|
965
|
+
expect(json.annotations).toBeUndefined();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
describe('fixture conformance: annotations.json', () => {
|
|
969
|
+
const fixtures = loadFixtures('annotations.json');
|
|
970
|
+
for (const fixture of fixtures) {
|
|
971
|
+
const f = fixture as {
|
|
972
|
+
id: string;
|
|
973
|
+
description: string;
|
|
974
|
+
jsonInput: ProtocolNodeJSON;
|
|
975
|
+
expectedRoundTrip?: boolean;
|
|
976
|
+
};
|
|
977
|
+
if (f.expectedRoundTrip) {
|
|
978
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
979
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
980
|
+
const roundTripped = toProtocolJSON(node);
|
|
981
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// ===========================================================================
|
|
989
|
+
// v1.2: diagnostics (type constraints)
|
|
990
|
+
// ===========================================================================
|
|
991
|
+
|
|
992
|
+
describe('v1.2: diagnostics', () => {
|
|
993
|
+
it('serializes diagnostics on command node', () => {
|
|
994
|
+
const node: CommandSemanticNode = {
|
|
995
|
+
...createCommandNode('toggle', { patient: createSelector('#button', 'id') }),
|
|
996
|
+
diagnostics: [
|
|
997
|
+
{
|
|
998
|
+
severity: 'error',
|
|
999
|
+
message: "toggle.patient expects selector kind [class, attribute], got 'id'",
|
|
1000
|
+
code: 'SCHEMA_SELECTOR_KIND_MISMATCH',
|
|
1001
|
+
source: 'schema',
|
|
1002
|
+
},
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
const json = toProtocolJSON(node);
|
|
1006
|
+
expect(json.diagnostics).toHaveLength(1);
|
|
1007
|
+
expect(json.diagnostics![0].level).toBe('error');
|
|
1008
|
+
expect(json.diagnostics![0].code).toBe('SCHEMA_SELECTOR_KIND_MISMATCH');
|
|
1009
|
+
expect(json.diagnostics![0].source).toBe('schema');
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('deserializes diagnostics from protocol JSON', () => {
|
|
1013
|
+
const json: ProtocolNodeJSON = {
|
|
1014
|
+
kind: 'command',
|
|
1015
|
+
action: 'toggle',
|
|
1016
|
+
roles: { patient: { type: 'literal', value: 'hello', dataType: 'string' } },
|
|
1017
|
+
diagnostics: [
|
|
1018
|
+
{
|
|
1019
|
+
level: 'error',
|
|
1020
|
+
role: 'patient',
|
|
1021
|
+
message: "toggle.patient expects type [selector], got 'literal'",
|
|
1022
|
+
code: 'SCHEMA_VALUE_TYPE_MISMATCH',
|
|
1023
|
+
},
|
|
1024
|
+
],
|
|
1025
|
+
};
|
|
1026
|
+
const node = fromProtocolJSON(json);
|
|
1027
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
1028
|
+
expect(node.diagnostics![0].code).toBe('SCHEMA_VALUE_TYPE_MISMATCH');
|
|
1029
|
+
expect(node.diagnostics![0].severity).toBe('error');
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('fixture conformance: type-constraints.json', () => {
|
|
1033
|
+
const fixtures = loadFixtures('type-constraints.json');
|
|
1034
|
+
for (const fixture of fixtures) {
|
|
1035
|
+
const f = fixture as {
|
|
1036
|
+
id: string;
|
|
1037
|
+
description: string;
|
|
1038
|
+
jsonInput: ProtocolNodeJSON;
|
|
1039
|
+
expectedRoundTrip?: boolean;
|
|
1040
|
+
};
|
|
1041
|
+
if (f.expectedRoundTrip) {
|
|
1042
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
1043
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
1044
|
+
const roundTripped = toProtocolJSON(node);
|
|
1045
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// ===========================================================================
|
|
1053
|
+
// v1.2: pipe chainType
|
|
1054
|
+
// ===========================================================================
|
|
1055
|
+
|
|
1056
|
+
describe('v1.2: pipe', () => {
|
|
1057
|
+
it('serializes pipe compound node', () => {
|
|
1058
|
+
const node = createCompoundNode(
|
|
1059
|
+
[
|
|
1060
|
+
createCommandNode('fetch', { source: createLiteral('/api/users', 'string') }),
|
|
1061
|
+
createCommandNode('put', { destination: createSelector('#user-list') }),
|
|
1062
|
+
],
|
|
1063
|
+
'pipe'
|
|
1064
|
+
);
|
|
1065
|
+
const json = toProtocolJSON(node);
|
|
1066
|
+
expect(json.chainType).toBe('pipe');
|
|
1067
|
+
expect(json.statements).toHaveLength(2);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('deserializes pipe compound node', () => {
|
|
1071
|
+
const json: ProtocolNodeJSON = {
|
|
1072
|
+
kind: 'compound',
|
|
1073
|
+
action: 'compound',
|
|
1074
|
+
roles: {},
|
|
1075
|
+
chainType: 'pipe',
|
|
1076
|
+
statements: [
|
|
1077
|
+
{
|
|
1078
|
+
kind: 'command',
|
|
1079
|
+
action: 'fetch',
|
|
1080
|
+
roles: { source: { type: 'literal', value: '/api', dataType: 'string' } },
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
kind: 'command',
|
|
1084
|
+
action: 'put',
|
|
1085
|
+
roles: { destination: { type: 'selector', value: '#list' } },
|
|
1086
|
+
},
|
|
1087
|
+
],
|
|
1088
|
+
};
|
|
1089
|
+
const node = fromProtocolJSON(json) as CompoundSemanticNode;
|
|
1090
|
+
expect(node.chainType).toBe('pipe');
|
|
1091
|
+
expect(node.statements).toHaveLength(2);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
describe('fixture conformance: pipe.json', () => {
|
|
1095
|
+
const fixtures = loadFixtures('pipe.json');
|
|
1096
|
+
for (const fixture of fixtures) {
|
|
1097
|
+
const f = fixture as {
|
|
1098
|
+
id: string;
|
|
1099
|
+
description: string;
|
|
1100
|
+
jsonInput: ProtocolNodeJSON;
|
|
1101
|
+
expectedRoundTrip?: boolean;
|
|
1102
|
+
};
|
|
1103
|
+
if (f.expectedRoundTrip) {
|
|
1104
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
1105
|
+
const node = fromProtocolJSON(f.jsonInput);
|
|
1106
|
+
const roundTripped = toProtocolJSON(node);
|
|
1107
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// ===========================================================================
|
|
1115
|
+
// v1.2: version envelope
|
|
1116
|
+
// ===========================================================================
|
|
1117
|
+
|
|
1118
|
+
describe('v1.2: envelope', () => {
|
|
1119
|
+
it('serializes envelope with nodes', () => {
|
|
1120
|
+
const envelope = {
|
|
1121
|
+
lseVersion: '1.2',
|
|
1122
|
+
nodes: [
|
|
1123
|
+
createCommandNode('toggle', { patient: createSelector('.active') }),
|
|
1124
|
+
createCommandNode('add', { patient: createSelector('.highlight') }),
|
|
1125
|
+
],
|
|
1126
|
+
};
|
|
1127
|
+
const json = toEnvelopeJSON(envelope);
|
|
1128
|
+
expect(json.lseVersion).toBe('1.2');
|
|
1129
|
+
expect(json.nodes).toHaveLength(2);
|
|
1130
|
+
expect(json.features).toBeUndefined();
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('serializes envelope with features', () => {
|
|
1134
|
+
const envelope = {
|
|
1135
|
+
lseVersion: '1.2',
|
|
1136
|
+
features: ['diagnostics', 'version-header'] as readonly string[],
|
|
1137
|
+
nodes: [createCommandNode('fetch', { source: createLiteral('/api', 'string') })],
|
|
1138
|
+
};
|
|
1139
|
+
const json = toEnvelopeJSON(envelope);
|
|
1140
|
+
expect(json.features).toEqual(['diagnostics', 'version-header']);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('deserializes envelope', () => {
|
|
1144
|
+
const json: LSEEnvelopeJSON = {
|
|
1145
|
+
lseVersion: '1.2',
|
|
1146
|
+
nodes: [
|
|
1147
|
+
{
|
|
1148
|
+
kind: 'command',
|
|
1149
|
+
action: 'toggle',
|
|
1150
|
+
roles: { patient: { type: 'selector', value: '.active' } },
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
};
|
|
1154
|
+
const envelope = fromEnvelopeJSON(json);
|
|
1155
|
+
expect(envelope.lseVersion).toBe('1.2');
|
|
1156
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
1157
|
+
expect(envelope.nodes[0].action).toBe('toggle');
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('isEnvelope detects envelope', () => {
|
|
1161
|
+
expect(isEnvelope({ lseVersion: '1.2', nodes: [] })).toBe(true);
|
|
1162
|
+
expect(isEnvelope({ kind: 'command', action: 'toggle', roles: {} })).toBe(false);
|
|
1163
|
+
expect(isEnvelope(null)).toBe(false);
|
|
1164
|
+
expect(isEnvelope('string')).toBe(false);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
describe('fixture conformance: version-envelope.json', () => {
|
|
1168
|
+
const fixtures = loadFixtures('version-envelope.json');
|
|
1169
|
+
for (const fixture of fixtures) {
|
|
1170
|
+
const f = fixture as {
|
|
1171
|
+
id: string;
|
|
1172
|
+
description: string;
|
|
1173
|
+
jsonInput?: LSEEnvelopeJSON;
|
|
1174
|
+
expectedRoundTrip?: boolean;
|
|
1175
|
+
streamingInput?: string;
|
|
1176
|
+
};
|
|
1177
|
+
// Only test JSON round-trips (skip streaming fixtures)
|
|
1178
|
+
if (f.expectedRoundTrip && f.jsonInput && !f.streamingInput) {
|
|
1179
|
+
it(`${f.id}: ${f.description}`, () => {
|
|
1180
|
+
const envelope = fromEnvelopeJSON(f.jsonInput!);
|
|
1181
|
+
const roundTripped = toEnvelopeJSON(envelope);
|
|
1182
|
+
expect(roundTripped).toEqual(f.jsonInput);
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
});
|