@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.
Files changed (138) hide show
  1. package/dist/aot/index.d.ts +1 -0
  2. package/dist/aot/index.d.ts.map +1 -1
  3. package/dist/aot/registry-bridge.d.ts +27 -0
  4. package/dist/aot/registry-bridge.d.ts.map +1 -0
  5. package/dist/api/create-dsl.d.ts.map +1 -1
  6. package/dist/api/dispatcher.d.ts +22 -0
  7. package/dist/api/dispatcher.d.ts.map +1 -1
  8. package/dist/api/domain-registry.d.ts +41 -0
  9. package/dist/api/domain-registry.d.ts.map +1 -1
  10. package/dist/api/index.js +1438 -21
  11. package/dist/api/index.js.map +1 -1
  12. package/dist/core/index.js +247 -10
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/pattern-matching/index.js +74 -10
  15. package/dist/core/pattern-matching/index.js.map +1 -1
  16. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  17. package/dist/core/tokenization/index.js +133 -0
  18. package/dist/core/tokenization/index.js.map +1 -1
  19. package/dist/core/tokenization/morphology/base-normalizer.d.ts +94 -0
  20. package/dist/core/tokenization/morphology/base-normalizer.d.ts.map +1 -0
  21. package/dist/core/tokenization/morphology/index.d.ts +3 -1
  22. package/dist/core/tokenization/morphology/index.d.ts.map +1 -1
  23. package/dist/core/types.d.ts +81 -2
  24. package/dist/core/types.d.ts.map +1 -1
  25. package/dist/core/types.js +40 -0
  26. package/dist/core/types.js.map +1 -1
  27. package/dist/feedback/confidence-gate.d.ts +28 -0
  28. package/dist/feedback/confidence-gate.d.ts.map +1 -0
  29. package/dist/feedback/feedback-formatter.d.ts +21 -0
  30. package/dist/feedback/feedback-formatter.d.ts.map +1 -0
  31. package/dist/feedback/index.d.ts +11 -0
  32. package/dist/feedback/index.d.ts.map +1 -0
  33. package/dist/feedback/pattern-tracker.d.ts +47 -0
  34. package/dist/feedback/pattern-tracker.d.ts.map +1 -0
  35. package/dist/feedback/types.d.ts +113 -0
  36. package/dist/feedback/types.d.ts.map +1 -0
  37. package/dist/generation/index.js +2 -1
  38. package/dist/generation/index.js.map +1 -1
  39. package/dist/index.cjs +3264 -132
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.ts +8 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3223 -132
  44. package/dist/index.js.map +1 -1
  45. package/dist/ir/explicit-parser.d.ts +86 -0
  46. package/dist/ir/explicit-parser.d.ts.map +1 -0
  47. package/dist/ir/explicit-renderer.d.ts +34 -0
  48. package/dist/ir/explicit-renderer.d.ts.map +1 -0
  49. package/dist/ir/from-interchange.d.ts +51 -0
  50. package/dist/ir/from-interchange.d.ts.map +1 -0
  51. package/dist/ir/index.d.ts +31 -0
  52. package/dist/ir/index.d.ts.map +1 -0
  53. package/dist/ir/index.js +1934 -0
  54. package/dist/ir/index.js.map +1 -0
  55. package/dist/ir/json-schema.d.ts +40 -0
  56. package/dist/ir/json-schema.d.ts.map +1 -0
  57. package/dist/ir/protocol-json.d.ts +73 -0
  58. package/dist/ir/protocol-json.d.ts.map +1 -0
  59. package/dist/ir/references.d.ts +19 -0
  60. package/dist/ir/references.d.ts.map +1 -0
  61. package/dist/ir/to-runtime-ast.d.ts +53 -0
  62. package/dist/ir/to-runtime-ast.d.ts.map +1 -0
  63. package/dist/ir/types.d.ts +151 -0
  64. package/dist/ir/types.d.ts.map +1 -0
  65. package/dist/parsing/index.js +74 -10
  66. package/dist/parsing/index.js.map +1 -1
  67. package/dist/prompts/index.d.ts +9 -0
  68. package/dist/prompts/index.d.ts.map +1 -0
  69. package/dist/prompts/prompt-generator.d.ts +49 -0
  70. package/dist/prompts/prompt-generator.d.ts.map +1 -0
  71. package/dist/prompts/types.d.ts +64 -0
  72. package/dist/prompts/types.d.ts.map +1 -0
  73. package/dist/schema/command-schema.d.ts +17 -0
  74. package/dist/schema/command-schema.d.ts.map +1 -1
  75. package/dist/schema/index.js.map +1 -1
  76. package/dist/testing/index.js +8673 -8260
  77. package/dist/testing/index.js.map +1 -1
  78. package/dist/training/index.d.ts +11 -0
  79. package/dist/training/index.d.ts.map +1 -0
  80. package/dist/training/jsonl-writer.d.ts +42 -0
  81. package/dist/training/jsonl-writer.d.ts.map +1 -0
  82. package/dist/training/schema-synthesizer.d.ts +20 -0
  83. package/dist/training/schema-synthesizer.d.ts.map +1 -0
  84. package/dist/training/types.d.ts +73 -0
  85. package/dist/training/types.d.ts.map +1 -0
  86. package/package.json +5 -1
  87. package/src/aot/index.ts +1 -0
  88. package/src/aot/registry-bridge.test.ts +104 -0
  89. package/src/aot/registry-bridge.ts +66 -0
  90. package/src/api/create-dsl.test.ts +306 -0
  91. package/src/api/create-dsl.ts +21 -0
  92. package/src/api/dispatcher.test.ts +66 -0
  93. package/src/api/dispatcher.ts +77 -9
  94. package/src/api/domain-registry.ts +120 -4
  95. package/src/core/pattern-matching/pattern-matcher.test.ts +131 -0
  96. package/src/core/pattern-matching/pattern-matcher.ts +102 -19
  97. package/src/core/tokenization/morphology/base-normalizer.ts +249 -0
  98. package/src/core/tokenization/morphology/index.ts +3 -1
  99. package/src/core/types.ts +147 -2
  100. package/src/feedback/confidence-gate.test.ts +111 -0
  101. package/src/feedback/confidence-gate.ts +70 -0
  102. package/src/feedback/feedback-formatter.test.ts +198 -0
  103. package/src/feedback/feedback-formatter.ts +298 -0
  104. package/src/feedback/index.ts +24 -0
  105. package/src/feedback/pattern-tracker.test.ts +198 -0
  106. package/src/feedback/pattern-tracker.ts +140 -0
  107. package/src/feedback/types.ts +149 -0
  108. package/src/generation/pattern-generator.ts +2 -2
  109. package/src/index.ts +29 -0
  110. package/src/integration/llm-lse-integration.test.ts +457 -0
  111. package/src/ir/conformance.test.ts +120 -0
  112. package/src/ir/explicit-parser.test.ts +612 -0
  113. package/src/ir/explicit-parser.ts +793 -0
  114. package/src/ir/explicit-renderer.test.ts +229 -0
  115. package/src/ir/explicit-renderer.ts +198 -0
  116. package/src/ir/from-interchange.test.ts +853 -0
  117. package/src/ir/from-interchange.ts +594 -0
  118. package/src/ir/index.ts +77 -0
  119. package/src/ir/json-schema.test.ts +281 -0
  120. package/src/ir/json-schema.ts +299 -0
  121. package/src/ir/protocol-json.test.ts +1187 -0
  122. package/src/ir/protocol-json.ts +751 -0
  123. package/src/ir/references.test.ts +38 -0
  124. package/src/ir/references.ts +33 -0
  125. package/src/ir/round-trip.test.ts +557 -0
  126. package/src/ir/to-runtime-ast.ts +203 -0
  127. package/src/ir/types.ts +191 -0
  128. package/src/prompts/index.ts +15 -0
  129. package/src/prompts/prompt-generator.test.ts +386 -0
  130. package/src/prompts/prompt-generator.ts +531 -0
  131. package/src/prompts/types.ts +89 -0
  132. package/src/schema/command-schema.ts +19 -0
  133. package/src/training/index.ts +13 -0
  134. package/src/training/jsonl-writer.test.ts +138 -0
  135. package/src/training/jsonl-writer.ts +67 -0
  136. package/src/training/schema-synthesizer.test.ts +250 -0
  137. package/src/training/schema-synthesizer.ts +306 -0
  138. package/src/training/types.ts +103 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Tests for InterchangeNode → SemanticNode converter
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { fromInterchangeNode, convertValue, renderExpr } from './from-interchange';
7
+ import { renderExplicit } from './explicit-renderer';
8
+ import { toProtocolJSON } from './protocol-json';
9
+ import type { SemanticNode, SemanticValue } from '../core/types';
10
+
11
+ // =============================================================================
12
+ // Helpers
13
+ // =============================================================================
14
+
15
+ /** Type-assert helper for SemanticValue */
16
+ function val(v: SemanticValue) {
17
+ return v;
18
+ }
19
+
20
+ // =============================================================================
21
+ // 1. Direct Value Mappings
22
+ // =============================================================================
23
+
24
+ describe('convertValue: direct mappings', () => {
25
+ it('converts LiteralNode (string)', () => {
26
+ const result = convertValue({ type: 'literal', value: 'hello' });
27
+ expect(result).toEqual({ type: 'literal', value: 'hello' });
28
+ });
29
+
30
+ it('converts LiteralNode (number)', () => {
31
+ const result = convertValue({ type: 'literal', value: 42 });
32
+ expect(result).toEqual({ type: 'literal', value: 42, dataType: 'number' });
33
+ });
34
+
35
+ it('converts LiteralNode (boolean)', () => {
36
+ const result = convertValue({ type: 'literal', value: true });
37
+ expect(result).toEqual({ type: 'literal', value: true, dataType: 'boolean' });
38
+ });
39
+
40
+ it('converts LiteralNode (null)', () => {
41
+ const result = convertValue({ type: 'literal', value: null });
42
+ expect(result).toEqual({ type: 'literal', value: 'null' });
43
+ });
44
+
45
+ it('converts SelectorNode (#id)', () => {
46
+ const result = convertValue({ type: 'selector', value: '#button' });
47
+ expect(result).toEqual({ type: 'selector', value: '#button', selectorKind: 'id' });
48
+ });
49
+
50
+ it('converts SelectorNode (.class)', () => {
51
+ const result = convertValue({ type: 'selector', value: '.active' });
52
+ expect(result).toEqual({ type: 'selector', value: '.active', selectorKind: 'class' });
53
+ });
54
+
55
+ it('converts SelectorNode ([attr])', () => {
56
+ const result = convertValue({ type: 'selector', value: '[data-x]' });
57
+ expect(result).toEqual({ type: 'selector', value: '[data-x]', selectorKind: 'attribute' });
58
+ });
59
+
60
+ it('converts IdentifierNode (known reference: me)', () => {
61
+ const result = convertValue({ type: 'identifier', value: 'me' });
62
+ expect(result).toEqual({ type: 'reference', value: 'me' });
63
+ });
64
+
65
+ it('converts IdentifierNode (known reference: result)', () => {
66
+ const result = convertValue({ type: 'identifier', value: 'result' });
67
+ expect(result).toEqual({ type: 'reference', value: 'result' });
68
+ });
69
+
70
+ it('converts IdentifierNode (non-reference)', () => {
71
+ const result = convertValue({ type: 'identifier', value: 'foo' });
72
+ expect(result).toEqual({ type: 'literal', value: 'foo' });
73
+ });
74
+
75
+ it('converts VariableNode (element scope)', () => {
76
+ const result = convertValue({ type: 'variable', name: 'count', scope: 'element' });
77
+ expect(result).toEqual({ type: 'expression', raw: ':count' });
78
+ });
79
+
80
+ it('converts VariableNode (global scope)', () => {
81
+ const result = convertValue({ type: 'variable', name: 'total', scope: 'global' });
82
+ expect(result).toEqual({ type: 'expression', raw: '$total' });
83
+ });
84
+
85
+ it('converts VariableNode (local scope)', () => {
86
+ const result = convertValue({ type: 'variable', name: 'temp', scope: 'local' });
87
+ expect(result).toEqual({ type: 'expression', raw: 'temp' });
88
+ });
89
+
90
+ it('converts PossessiveNode to PropertyPathValue', () => {
91
+ const result = convertValue({
92
+ type: 'possessive',
93
+ object: { type: 'identifier', value: 'me' },
94
+ property: 'classList',
95
+ });
96
+ expect(result).toEqual({
97
+ type: 'property-path',
98
+ object: { type: 'reference', value: 'me' },
99
+ property: 'classList',
100
+ });
101
+ });
102
+ });
103
+
104
+ // =============================================================================
105
+ // 2. Expression Collapsing
106
+ // =============================================================================
107
+
108
+ describe('convertValue: expression collapsing', () => {
109
+ it('collapses BinaryNode', () => {
110
+ const result = convertValue({
111
+ type: 'binary',
112
+ operator: '+',
113
+ left: { type: 'literal', value: 1 },
114
+ right: { type: 'literal', value: 2 },
115
+ });
116
+ expect(result).toEqual({ type: 'expression', raw: '1 + 2' });
117
+ });
118
+
119
+ it('collapses UnaryNode', () => {
120
+ const result = convertValue({
121
+ type: 'unary',
122
+ operator: 'not',
123
+ operand: { type: 'identifier', value: 'x' },
124
+ });
125
+ expect(result).toEqual({ type: 'expression', raw: 'not x' });
126
+ });
127
+
128
+ it('collapses MemberNode (dot access)', () => {
129
+ const result = convertValue({
130
+ type: 'member',
131
+ object: { type: 'identifier', value: 'event' },
132
+ property: 'detail',
133
+ });
134
+ expect(result).toEqual({ type: 'expression', raw: 'event.detail' });
135
+ });
136
+
137
+ it('collapses MemberNode (computed)', () => {
138
+ const result = convertValue({
139
+ type: 'member',
140
+ object: { type: 'identifier', value: 'arr' },
141
+ property: { type: 'literal', value: 0 },
142
+ computed: true,
143
+ });
144
+ expect(result).toEqual({ type: 'expression', raw: 'arr[0]' });
145
+ });
146
+
147
+ it('collapses CallNode', () => {
148
+ const result = convertValue({
149
+ type: 'call',
150
+ callee: {
151
+ type: 'member',
152
+ object: { type: 'identifier', value: 'str' },
153
+ property: 'toUpperCase',
154
+ },
155
+ args: [],
156
+ });
157
+ expect(result).toEqual({ type: 'expression', raw: 'str.toUpperCase()' });
158
+ });
159
+
160
+ it('collapses CallNode with args', () => {
161
+ const result = convertValue({
162
+ type: 'call',
163
+ callee: {
164
+ type: 'member',
165
+ object: { type: 'identifier', value: 'arr' },
166
+ property: 'join',
167
+ },
168
+ args: [{ type: 'literal', value: '-' }],
169
+ });
170
+ expect(result).toEqual({ type: 'expression', raw: 'arr.join(-)' });
171
+ });
172
+
173
+ it('collapses PositionalNode', () => {
174
+ const result = convertValue({
175
+ type: 'positional',
176
+ position: 'first',
177
+ target: { type: 'selector', value: '.item' },
178
+ });
179
+ expect(result).toEqual({ type: 'expression', raw: 'first .item' });
180
+ });
181
+
182
+ it('collapses nested expressions', () => {
183
+ const result = convertValue({
184
+ type: 'binary',
185
+ operator: '&&',
186
+ left: {
187
+ type: 'binary',
188
+ operator: '>',
189
+ left: { type: 'identifier', value: 'x' },
190
+ right: { type: 'literal', value: 0 },
191
+ },
192
+ right: {
193
+ type: 'binary',
194
+ operator: '<',
195
+ left: { type: 'identifier', value: 'x' },
196
+ right: { type: 'literal', value: 100 },
197
+ },
198
+ });
199
+ expect(result).toEqual({
200
+ type: 'expression',
201
+ raw: 'x > 0 && x < 100',
202
+ });
203
+ });
204
+ });
205
+
206
+ // =============================================================================
207
+ // 3. renderExpr
208
+ // =============================================================================
209
+
210
+ describe('renderExpr', () => {
211
+ it('renders literal string with spaces in quotes', () => {
212
+ expect(renderExpr({ type: 'literal', value: 'hello world' })).toBe('"hello world"');
213
+ });
214
+
215
+ it('renders literal string without spaces unquoted', () => {
216
+ expect(renderExpr({ type: 'literal', value: 'hello' })).toBe('hello');
217
+ });
218
+
219
+ it('renders null literal', () => {
220
+ expect(renderExpr({ type: 'literal', value: null })).toBe('null');
221
+ });
222
+
223
+ it('renders possessive', () => {
224
+ expect(
225
+ renderExpr({
226
+ type: 'possessive',
227
+ object: { type: 'identifier', value: 'me' },
228
+ property: 'innerHTML',
229
+ })
230
+ ).toBe("me's innerHTML");
231
+ });
232
+
233
+ it('renders variable scopes', () => {
234
+ expect(renderExpr({ type: 'variable', name: 'x', scope: 'element' })).toBe(':x');
235
+ expect(renderExpr({ type: 'variable', name: 'x', scope: 'global' })).toBe('$x');
236
+ expect(renderExpr({ type: 'variable', name: 'x', scope: 'local' })).toBe('x');
237
+ });
238
+
239
+ it('renders positional without target', () => {
240
+ expect(renderExpr({ type: 'positional', position: 'first' })).toBe('first');
241
+ });
242
+ });
243
+
244
+ // =============================================================================
245
+ // 4. Command Conversion
246
+ // =============================================================================
247
+
248
+ describe('fromInterchangeNode: command conversion', () => {
249
+ it('converts command with roles (semantic parser path)', () => {
250
+ const node = fromInterchangeNode({
251
+ type: 'command',
252
+ name: 'toggle',
253
+ roles: {
254
+ patient: { type: 'selector', value: '.active' },
255
+ destination: { type: 'selector', value: '#button' },
256
+ },
257
+ });
258
+
259
+ expect(node.kind).toBe('command');
260
+ expect(node.action).toBe('toggle');
261
+ expect(node.roles.get('patient')).toEqual({
262
+ type: 'selector',
263
+ value: '.active',
264
+ selectorKind: 'class',
265
+ });
266
+ expect(node.roles.get('destination')).toEqual({
267
+ type: 'selector',
268
+ value: '#button',
269
+ selectorKind: 'id',
270
+ });
271
+ });
272
+
273
+ it('infers roles for toggle (core parser path)', () => {
274
+ const node = fromInterchangeNode({
275
+ type: 'command',
276
+ name: 'toggle',
277
+ args: [{ type: 'selector', value: '.active' }],
278
+ target: { type: 'selector', value: '#button' },
279
+ });
280
+
281
+ expect(node.kind).toBe('command');
282
+ expect(node.action).toBe('toggle');
283
+ expect(node.roles.get('patient')).toEqual({
284
+ type: 'selector',
285
+ value: '.active',
286
+ selectorKind: 'class',
287
+ });
288
+ expect(node.roles.get('destination')).toEqual({
289
+ type: 'selector',
290
+ value: '#button',
291
+ selectorKind: 'id',
292
+ });
293
+ });
294
+
295
+ it('infers roles for set', () => {
296
+ const node = fromInterchangeNode({
297
+ type: 'command',
298
+ name: 'set',
299
+ args: [{ type: 'variable', name: 'count', scope: 'element' }],
300
+ modifiers: { to: { type: 'literal', value: 5 } },
301
+ });
302
+
303
+ expect(node.action).toBe('set');
304
+ expect(node.roles.get('destination')).toEqual({ type: 'expression', raw: ':count' });
305
+ expect(node.roles.get('patient')).toEqual({ type: 'literal', value: 5, dataType: 'number' });
306
+ });
307
+
308
+ it('infers roles for put', () => {
309
+ const node = fromInterchangeNode({
310
+ type: 'command',
311
+ name: 'put',
312
+ args: [{ type: 'literal', value: 'hello' }],
313
+ modifiers: { into: { type: 'selector', value: '#output' } },
314
+ });
315
+
316
+ expect(node.action).toBe('put');
317
+ expect(node.roles.get('patient')).toEqual({ type: 'literal', value: 'hello' });
318
+ expect(node.roles.get('destination')).toEqual({
319
+ type: 'selector',
320
+ value: '#output',
321
+ selectorKind: 'id',
322
+ });
323
+ expect(node.roles.get('method')).toEqual({ type: 'literal', value: 'into' });
324
+ });
325
+
326
+ it('infers roles for increment', () => {
327
+ const node = fromInterchangeNode({
328
+ type: 'command',
329
+ name: 'increment',
330
+ args: [{ type: 'variable', name: 'count', scope: 'element' }],
331
+ modifiers: { by: { type: 'literal', value: 5 } },
332
+ });
333
+
334
+ expect(node.action).toBe('increment');
335
+ expect(node.roles.get('destination')).toEqual({ type: 'expression', raw: ':count' });
336
+ expect(node.roles.get('quantity')).toEqual({ type: 'literal', value: 5, dataType: 'number' });
337
+ });
338
+
339
+ it('infers roles for fetch', () => {
340
+ const node = fromInterchangeNode({
341
+ type: 'command',
342
+ name: 'fetch',
343
+ args: [{ type: 'literal', value: '/api/data' }],
344
+ modifiers: { as: 'json' },
345
+ });
346
+
347
+ expect(node.action).toBe('fetch');
348
+ expect(node.roles.get('source')).toEqual({ type: 'literal', value: '/api/data' });
349
+ expect(node.roles.get('responseType')).toEqual({ type: 'literal', value: 'json' });
350
+ });
351
+
352
+ it('infers roles for wait', () => {
353
+ const node = fromInterchangeNode({
354
+ type: 'command',
355
+ name: 'wait',
356
+ args: [{ type: 'literal', value: '500ms' }],
357
+ });
358
+
359
+ expect(node.action).toBe('wait');
360
+ expect(node.roles.get('duration')).toEqual({ type: 'literal', value: '500ms' });
361
+ });
362
+
363
+ it('infers roles for remove (source not destination)', () => {
364
+ const node = fromInterchangeNode({
365
+ type: 'command',
366
+ name: 'remove',
367
+ args: [{ type: 'selector', value: '.active' }],
368
+ target: { type: 'selector', value: '#button' },
369
+ });
370
+
371
+ expect(node.action).toBe('remove');
372
+ expect(node.roles.get('patient')).toEqual({
373
+ type: 'selector',
374
+ value: '.active',
375
+ selectorKind: 'class',
376
+ });
377
+ expect(node.roles.get('source')).toEqual({
378
+ type: 'selector',
379
+ value: '#button',
380
+ selectorKind: 'id',
381
+ });
382
+ expect(node.roles.has('destination')).toBe(false);
383
+ });
384
+
385
+ it('infers roles for send', () => {
386
+ const node = fromInterchangeNode({
387
+ type: 'command',
388
+ name: 'send',
389
+ args: [{ type: 'literal', value: 'customEvent' }],
390
+ target: { type: 'selector', value: '#target' },
391
+ });
392
+
393
+ expect(node.action).toBe('send');
394
+ expect(node.roles.get('patient')).toEqual({ type: 'literal', value: 'customEvent' });
395
+ expect(node.roles.get('destination')).toEqual({
396
+ type: 'selector',
397
+ value: '#target',
398
+ selectorKind: 'id',
399
+ });
400
+ });
401
+
402
+ it('infers roles for log', () => {
403
+ const node = fromInterchangeNode({
404
+ type: 'command',
405
+ name: 'log',
406
+ args: [{ type: 'literal', value: 'debug message' }],
407
+ });
408
+
409
+ expect(node.action).toBe('log');
410
+ expect(node.roles.get('patient')).toEqual({ type: 'literal', value: 'debug message' });
411
+ });
412
+
413
+ it('uses generic fallback for unknown commands', () => {
414
+ const node = fromInterchangeNode({
415
+ type: 'command',
416
+ name: 'myCustomCommand',
417
+ args: [{ type: 'literal', value: 'arg1' }],
418
+ target: { type: 'selector', value: '#el' },
419
+ });
420
+
421
+ expect(node.action).toBe('myCustomCommand');
422
+ expect(node.roles.get('patient')).toEqual({ type: 'literal', value: 'arg1' });
423
+ expect(node.roles.get('destination')).toEqual({
424
+ type: 'selector',
425
+ value: '#el',
426
+ selectorKind: 'id',
427
+ });
428
+ });
429
+
430
+ it('wraps bare expression nodes as get command', () => {
431
+ const node = fromInterchangeNode({
432
+ type: 'binary',
433
+ operator: '+',
434
+ left: { type: 'literal', value: 1 },
435
+ right: { type: 'literal', value: 2 },
436
+ });
437
+
438
+ expect(node.kind).toBe('command');
439
+ expect(node.action).toBe('get');
440
+ expect(node.roles.get('patient')).toEqual({ type: 'expression', raw: '1 + 2' });
441
+ });
442
+ });
443
+
444
+ // =============================================================================
445
+ // 5. Control Flow Conversion
446
+ // =============================================================================
447
+
448
+ describe('fromInterchangeNode: control flow', () => {
449
+ it('converts IfNode to conditional', () => {
450
+ const node = fromInterchangeNode({
451
+ type: 'if',
452
+ condition: {
453
+ type: 'binary',
454
+ operator: '>',
455
+ left: { type: 'identifier', value: 'x' },
456
+ right: { type: 'literal', value: 0 },
457
+ },
458
+ thenBranch: [
459
+ { type: 'command', name: 'add', args: [{ type: 'selector', value: '.positive' }] },
460
+ ],
461
+ });
462
+
463
+ expect(node.kind).toBe('conditional');
464
+ expect(node.action).toBe('if');
465
+ expect(node.roles.get('condition')).toEqual({ type: 'expression', raw: 'x > 0' });
466
+
467
+ const cond = node as { thenBranch: SemanticNode[] };
468
+ expect(cond.thenBranch).toHaveLength(1);
469
+ expect(cond.thenBranch[0].action).toBe('add');
470
+ });
471
+
472
+ it('converts IfNode with else branch', () => {
473
+ const node = fromInterchangeNode({
474
+ type: 'if',
475
+ condition: { type: 'identifier', value: 'x' },
476
+ thenBranch: [{ type: 'command', name: 'show' }],
477
+ elseBranch: [{ type: 'command', name: 'hide' }],
478
+ });
479
+
480
+ expect(node.kind).toBe('conditional');
481
+ const cond = node as { thenBranch: SemanticNode[]; elseBranch?: SemanticNode[] };
482
+ expect(cond.thenBranch).toHaveLength(1);
483
+ expect(cond.elseBranch).toHaveLength(1);
484
+ expect(cond.elseBranch![0].action).toBe('hide');
485
+ });
486
+
487
+ it('converts IfNode with elseIfBranches as nested conditionals', () => {
488
+ const node = fromInterchangeNode({
489
+ type: 'if',
490
+ condition: { type: 'identifier', value: 'a' },
491
+ thenBranch: [{ type: 'command', name: 'log', args: [{ type: 'literal', value: 'a' }] }],
492
+ elseIfBranches: [
493
+ {
494
+ condition: { type: 'identifier', value: 'b' },
495
+ body: [{ type: 'command', name: 'log', args: [{ type: 'literal', value: 'b' }] }],
496
+ },
497
+ ],
498
+ elseBranch: [{ type: 'command', name: 'log', args: [{ type: 'literal', value: 'else' }] }],
499
+ });
500
+
501
+ expect(node.kind).toBe('conditional');
502
+ const cond = node as { elseBranch?: SemanticNode[] };
503
+ expect(cond.elseBranch).toHaveLength(1);
504
+ // The else branch should be a nested conditional
505
+ expect(cond.elseBranch![0].kind).toBe('conditional');
506
+ expect(cond.elseBranch![0].action).toBe('if');
507
+ });
508
+
509
+ it('converts RepeatNode (times)', () => {
510
+ const node = fromInterchangeNode({
511
+ type: 'repeat',
512
+ count: 3,
513
+ body: [{ type: 'command', name: 'log', args: [{ type: 'literal', value: 'hi' }] }],
514
+ });
515
+
516
+ expect(node.kind).toBe('loop');
517
+ const loop = node as { loopVariant: string; body: SemanticNode[] };
518
+ expect(loop.loopVariant).toBe('times');
519
+ expect(node.roles.get('quantity')).toEqual({ type: 'literal', value: 3, dataType: 'number' });
520
+ expect(loop.body).toHaveLength(1);
521
+ });
522
+
523
+ it('converts RepeatNode (forever)', () => {
524
+ const node = fromInterchangeNode({
525
+ type: 'repeat',
526
+ body: [{ type: 'command', name: 'wait', args: [{ type: 'literal', value: '100ms' }] }],
527
+ });
528
+
529
+ expect(node.kind).toBe('loop');
530
+ const loop = node as { loopVariant: string };
531
+ expect(loop.loopVariant).toBe('forever');
532
+ });
533
+
534
+ it('converts RepeatNode (while)', () => {
535
+ const node = fromInterchangeNode({
536
+ type: 'repeat',
537
+ whileCondition: { type: 'identifier', value: 'running' },
538
+ body: [],
539
+ });
540
+
541
+ expect(node.kind).toBe('loop');
542
+ const loop = node as { loopVariant: string };
543
+ expect(loop.loopVariant).toBe('while');
544
+ expect(node.roles.get('condition')).toEqual({ type: 'literal', value: 'running' });
545
+ });
546
+
547
+ it('converts RepeatNode (until)', () => {
548
+ const node = fromInterchangeNode({
549
+ type: 'repeat',
550
+ untilEvent: 'done',
551
+ body: [],
552
+ });
553
+
554
+ expect(node.kind).toBe('loop');
555
+ const loop = node as { loopVariant: string };
556
+ expect(loop.loopVariant).toBe('until');
557
+ expect(node.roles.get('condition')).toEqual({ type: 'literal', value: 'done' });
558
+ });
559
+
560
+ it('converts ForEachNode', () => {
561
+ const node = fromInterchangeNode({
562
+ type: 'foreach',
563
+ itemName: 'item',
564
+ indexName: 'i',
565
+ collection: { type: 'selector', value: '.items' },
566
+ body: [{ type: 'command', name: 'log', args: [{ type: 'identifier', value: 'item' }] }],
567
+ });
568
+
569
+ expect(node.kind).toBe('loop');
570
+ const loop = node as {
571
+ loopVariant: string;
572
+ loopVariable?: string;
573
+ indexVariable?: string;
574
+ body: SemanticNode[];
575
+ };
576
+ expect(loop.loopVariant).toBe('for');
577
+ expect(loop.loopVariable).toBe('item');
578
+ expect(loop.indexVariable).toBe('i');
579
+ expect(node.roles.get('source')).toEqual({
580
+ type: 'selector',
581
+ value: '.items',
582
+ selectorKind: 'class',
583
+ });
584
+ expect(loop.body).toHaveLength(1);
585
+ });
586
+
587
+ it('converts WhileNode', () => {
588
+ const node = fromInterchangeNode({
589
+ type: 'while',
590
+ condition: {
591
+ type: 'binary',
592
+ operator: '<',
593
+ left: { type: 'identifier', value: 'i' },
594
+ right: { type: 'literal', value: 10 },
595
+ },
596
+ body: [{ type: 'command', name: 'increment', args: [{ type: 'identifier', value: 'i' }] }],
597
+ });
598
+
599
+ expect(node.kind).toBe('loop');
600
+ const loop = node as { loopVariant: string; body: SemanticNode[] };
601
+ expect(loop.loopVariant).toBe('while');
602
+ expect(node.roles.get('condition')).toEqual({ type: 'expression', raw: 'i < 10' });
603
+ expect(loop.body).toHaveLength(1);
604
+ });
605
+ });
606
+
607
+ // =============================================================================
608
+ // 6. Event Handler Conversion
609
+ // =============================================================================
610
+
611
+ describe('fromInterchangeNode: event handlers', () => {
612
+ it('converts basic EventNode', () => {
613
+ const node = fromInterchangeNode({
614
+ type: 'event',
615
+ event: 'click',
616
+ body: [{ type: 'command', name: 'toggle', args: [{ type: 'selector', value: '.active' }] }],
617
+ });
618
+
619
+ expect(node.kind).toBe('event-handler');
620
+ expect(node.action).toBe('on');
621
+ expect(node.roles.get('event')).toEqual({ type: 'literal', value: 'click' });
622
+ const eh = node as { body: SemanticNode[] };
623
+ expect(eh.body).toHaveLength(1);
624
+ expect(eh.body[0].action).toBe('toggle');
625
+ });
626
+
627
+ it('converts EventNode with modifiers', () => {
628
+ const node = fromInterchangeNode({
629
+ type: 'event',
630
+ event: 'click',
631
+ modifiers: { once: true, debounce: 300 },
632
+ body: [],
633
+ });
634
+
635
+ expect(node.kind).toBe('event-handler');
636
+ const eh = node as { eventModifiers?: { once?: boolean; debounce?: number } };
637
+ expect(eh.eventModifiers?.once).toBe(true);
638
+ expect(eh.eventModifiers?.debounce).toBe(300);
639
+ });
640
+
641
+ it('bridges from modifier (string → SemanticValue)', () => {
642
+ const node = fromInterchangeNode({
643
+ type: 'event',
644
+ event: 'click',
645
+ modifiers: { from: '.container' },
646
+ body: [],
647
+ });
648
+
649
+ const eh = node as { eventModifiers?: { from?: SemanticValue } };
650
+ expect(eh.eventModifiers?.from).toEqual({
651
+ type: 'selector',
652
+ value: '.container',
653
+ selectorKind: 'class',
654
+ });
655
+ });
656
+
657
+ it('encodes prevent/stop as roles', () => {
658
+ const node = fromInterchangeNode({
659
+ type: 'event',
660
+ event: 'submit',
661
+ modifiers: { prevent: true, stop: true },
662
+ body: [],
663
+ });
664
+
665
+ expect(node.roles.get('prevent')).toEqual({ type: 'literal', value: true });
666
+ expect(node.roles.get('stop')).toEqual({ type: 'literal', value: true });
667
+ });
668
+
669
+ it('preserves event target as source role', () => {
670
+ const node = fromInterchangeNode({
671
+ type: 'event',
672
+ event: 'click',
673
+ target: { type: 'selector', value: '#parent' },
674
+ body: [],
675
+ });
676
+
677
+ expect(node.roles.get('source')).toEqual({
678
+ type: 'selector',
679
+ value: '#parent',
680
+ selectorKind: 'id',
681
+ });
682
+ });
683
+ });
684
+
685
+ // =============================================================================
686
+ // 7. Round-Trip Tests
687
+ // =============================================================================
688
+
689
+ describe('round-trip: InterchangeNode → SemanticNode → bracket syntax', () => {
690
+ it('toggle .active on #button', () => {
691
+ const node = fromInterchangeNode({
692
+ type: 'command',
693
+ name: 'toggle',
694
+ roles: {
695
+ patient: { type: 'selector', value: '.active' },
696
+ destination: { type: 'selector', value: '#button' },
697
+ },
698
+ });
699
+
700
+ const bracket = renderExplicit(node);
701
+ expect(bracket).toBe('[toggle patient:.active destination:#button]');
702
+ });
703
+
704
+ it('put hello into #output', () => {
705
+ const node = fromInterchangeNode({
706
+ type: 'command',
707
+ name: 'put',
708
+ args: [{ type: 'literal', value: 'hello' }],
709
+ modifiers: { into: { type: 'selector', value: '#output' } },
710
+ });
711
+
712
+ const bracket = renderExplicit(node);
713
+ expect(bracket).toContain('[put');
714
+ expect(bracket).toContain('patient:hello');
715
+ expect(bracket).toContain('destination:#output');
716
+ expect(bracket).toContain('method:into');
717
+ });
718
+
719
+ it('event handler with body', () => {
720
+ const node = fromInterchangeNode({
721
+ type: 'event',
722
+ event: 'click',
723
+ body: [
724
+ {
725
+ type: 'command',
726
+ name: 'toggle',
727
+ roles: {
728
+ patient: { type: 'selector', value: '.active' },
729
+ },
730
+ },
731
+ ],
732
+ });
733
+
734
+ const bracket = renderExplicit(node);
735
+ expect(bracket).toContain('[on');
736
+ expect(bracket).toContain('event:click');
737
+ expect(bracket).toContain('body:');
738
+ expect(bracket).toContain('[toggle patient:.active]');
739
+ });
740
+ });
741
+
742
+ describe('round-trip: InterchangeNode → SemanticNode → protocol JSON', () => {
743
+ it('toggle command produces valid protocol JSON', () => {
744
+ const node = fromInterchangeNode({
745
+ type: 'command',
746
+ name: 'toggle',
747
+ roles: {
748
+ patient: { type: 'selector', value: '.active' },
749
+ },
750
+ });
751
+
752
+ const proto = toProtocolJSON(node);
753
+ expect(proto.kind).toBe('command');
754
+ expect(proto.action).toBe('toggle');
755
+ expect(proto.roles.patient).toEqual({
756
+ type: 'selector',
757
+ value: '.active',
758
+ selectorKind: 'class',
759
+ });
760
+ });
761
+
762
+ it('event handler produces valid protocol JSON', () => {
763
+ const node = fromInterchangeNode({
764
+ type: 'event',
765
+ event: 'click',
766
+ body: [{ type: 'command', name: 'log', args: [{ type: 'literal', value: 'clicked' }] }],
767
+ });
768
+
769
+ const proto = toProtocolJSON(node);
770
+ expect(proto.kind).toBe('event-handler');
771
+ expect(proto.body).toHaveLength(1);
772
+ expect(proto.body![0].action).toBe('log');
773
+ });
774
+
775
+ it('if/else produces valid protocol JSON', () => {
776
+ const node = fromInterchangeNode({
777
+ type: 'if',
778
+ condition: { type: 'identifier', value: 'x' },
779
+ thenBranch: [{ type: 'command', name: 'show' }],
780
+ elseBranch: [{ type: 'command', name: 'hide' }],
781
+ });
782
+
783
+ const proto = toProtocolJSON(node);
784
+ expect(proto.kind).toBe('command');
785
+ expect(proto.action).toBe('if');
786
+ expect(proto.thenBranch).toHaveLength(1);
787
+ expect(proto.elseBranch).toHaveLength(1);
788
+ });
789
+
790
+ it('loop produces valid protocol JSON', () => {
791
+ const node = fromInterchangeNode({
792
+ type: 'repeat',
793
+ count: 5,
794
+ body: [{ type: 'command', name: 'log' }],
795
+ });
796
+
797
+ const proto = toProtocolJSON(node);
798
+ expect(proto.kind).toBe('command');
799
+ expect(proto.loopVariant).toBe('times');
800
+ expect(proto.loopBody).toHaveLength(1);
801
+ });
802
+ });
803
+
804
+ // =============================================================================
805
+ // 8. Edge Cases
806
+ // =============================================================================
807
+
808
+ describe('edge cases', () => {
809
+ it('handles null input gracefully', () => {
810
+ const node = fromInterchangeNode(null as unknown as { type: string });
811
+ expect(node.kind).toBe('command');
812
+ expect(node.action).toBe('unknown');
813
+ });
814
+
815
+ it('handles undefined input gracefully', () => {
816
+ const node = fromInterchangeNode(undefined as unknown as { type: string });
817
+ expect(node.kind).toBe('command');
818
+ expect(node.action).toBe('unknown');
819
+ });
820
+
821
+ it('handles unknown node type', () => {
822
+ const node = fromInterchangeNode({ type: 'UNKNOWN_TYPE', value: 'foo' });
823
+ expect(node.kind).toBe('command');
824
+ expect(node.action).toBe('get');
825
+ });
826
+
827
+ it('handles command with no args or target', () => {
828
+ const node = fromInterchangeNode({ type: 'command', name: 'halt' });
829
+ expect(node.kind).toBe('command');
830
+ expect(node.action).toBe('halt');
831
+ expect(node.roles.size).toBe(0);
832
+ });
833
+
834
+ it('handles event with empty body', () => {
835
+ const node = fromInterchangeNode({ type: 'event', event: 'load', body: [] });
836
+ expect(node.kind).toBe('event-handler');
837
+ const eh = node as { body: SemanticNode[] };
838
+ expect(eh.body).toHaveLength(0);
839
+ });
840
+
841
+ it('handles repeat with expression count', () => {
842
+ const node = fromInterchangeNode({
843
+ type: 'repeat',
844
+ count: { type: 'identifier', value: 'n' },
845
+ body: [],
846
+ });
847
+
848
+ expect(node.kind).toBe('loop');
849
+ const loop = node as { loopVariant: string };
850
+ expect(loop.loopVariant).toBe('times');
851
+ expect(node.roles.get('quantity')).toEqual({ type: 'literal', value: 'n' });
852
+ });
853
+ });