@a2ui-sdk/utils 0.1.0 → 0.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.
@@ -1,699 +0,0 @@
1
- /**
2
- * Tests for the evaluator.
3
- */
4
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
- import { tokenize } from './lexer.js';
6
- import { parse } from './parser.js';
7
- import { evaluate } from './evaluator.js';
8
- /**
9
- * Test helper functions - these are used within tests to verify
10
- * function call behavior. They are passed via context.functions.
11
- */
12
- const testFunctions = {
13
- // String functions
14
- upper: (str) => String(str).toUpperCase(),
15
- lower: (str) => String(str).toLowerCase(),
16
- trim: (str) => String(str).trim(),
17
- length: (str) => {
18
- if (Array.isArray(str))
19
- return str.length;
20
- return String(str).length;
21
- },
22
- // Date functions
23
- now: () => new Date().toISOString(),
24
- // Math functions
25
- add: (...args) => args.reduce((sum, val) => sum + Number(val), 0),
26
- sub: (a, b) => Number(a) - Number(b),
27
- mul: (...args) => args.reduce((product, val) => product * Number(val), 1),
28
- div: (a, b) => {
29
- const divisor = Number(b);
30
- return divisor !== 0 ? Number(a) / divisor : 0;
31
- },
32
- mod: (a, b) => Number(a) % Number(b),
33
- abs: (n) => Math.abs(Number(n)),
34
- round: (n) => Math.round(Number(n)),
35
- floor: (n) => Math.floor(Number(n)),
36
- ceil: (n) => Math.ceil(Number(n)),
37
- // Conditional functions
38
- if: (condition, thenVal, elseVal) => condition ? thenVal : elseVal,
39
- eq: (a, b) => a === b,
40
- ne: (a, b) => a !== b,
41
- gt: (a, b) => Number(a) > Number(b),
42
- lt: (a, b) => Number(a) < Number(b),
43
- gte: (a, b) => Number(a) >= Number(b),
44
- lte: (a, b) => Number(a) <= Number(b),
45
- // String manipulation
46
- concat: (...args) => args.map(String).join(''),
47
- join: (arr, sep = ',') => {
48
- if (Array.isArray(arr))
49
- return arr.join(String(sep));
50
- return String(arr);
51
- },
52
- split: (str, sep = ',') => String(str).split(String(sep)),
53
- replace: (str, search, replacement) => String(str).replace(String(search), String(replacement)),
54
- substr: (str, start, length) => length !== undefined
55
- ? String(str).substr(Number(start), Number(length))
56
- : String(str).substr(Number(start)),
57
- // Type conversion
58
- toString: (val) => String(val),
59
- toNumber: (val) => Number(val),
60
- toBoolean: (val) => Boolean(val),
61
- // JSON
62
- json: (val) => JSON.stringify(val),
63
- parseJson: (str) => {
64
- try {
65
- return JSON.parse(String(str));
66
- }
67
- catch {
68
- return null;
69
- }
70
- },
71
- // Default/fallback
72
- default: (val, defaultVal) => val !== undefined && val !== null && val !== '' ? val : defaultVal,
73
- };
74
- function evaluateTemplate(template, context) {
75
- const tokens = tokenize(template);
76
- const ast = parse(tokens);
77
- // Merge testFunctions with any custom functions in context
78
- const mergedContext = {
79
- ...context,
80
- functions: { ...testFunctions, ...context.functions },
81
- };
82
- return evaluate(ast, mergedContext);
83
- }
84
- describe('Evaluator', () => {
85
- describe('US1: Path resolution', () => {
86
- const dataModel = {
87
- user: { name: 'John', age: 30 },
88
- items: ['a', 'b', 'c'],
89
- nested: { deep: { value: 'found' } },
90
- };
91
- it('should resolve simple path', () => {
92
- const result = evaluateTemplate('${/user/name}', {
93
- dataModel,
94
- basePath: null,
95
- });
96
- expect(result).toBe('John');
97
- });
98
- it('should resolve path with array index', () => {
99
- const result = evaluateTemplate('${/items/0}', {
100
- dataModel,
101
- basePath: null,
102
- });
103
- expect(result).toBe('a');
104
- });
105
- it('should resolve nested path', () => {
106
- const result = evaluateTemplate('${/nested/deep/value}', {
107
- dataModel,
108
- basePath: null,
109
- });
110
- expect(result).toBe('found');
111
- });
112
- it('should concatenate with literal text', () => {
113
- const result = evaluateTemplate('Hello, ${/user/name}!', {
114
- dataModel,
115
- basePath: null,
116
- });
117
- expect(result).toBe('Hello, John!');
118
- });
119
- it('should resolve multiple paths', () => {
120
- const result = evaluateTemplate('${/user/name} is ${/user/age}', {
121
- dataModel,
122
- basePath: null,
123
- });
124
- expect(result).toBe('John is 30');
125
- });
126
- });
127
- describe('US1: Missing path handling', () => {
128
- const dataModel = { user: { name: 'John' } };
129
- it('should return empty string for missing path', () => {
130
- const result = evaluateTemplate('${/nonexistent}', {
131
- dataModel,
132
- basePath: null,
133
- });
134
- expect(result).toBe('');
135
- });
136
- it('should return empty string for deeply missing path', () => {
137
- const result = evaluateTemplate('${/a/b/c/d}', {
138
- dataModel,
139
- basePath: null,
140
- });
141
- expect(result).toBe('');
142
- });
143
- it('should preserve literal text around missing path', () => {
144
- const result = evaluateTemplate('Value: ${/missing}!', {
145
- dataModel,
146
- basePath: null,
147
- });
148
- expect(result).toBe('Value: !');
149
- });
150
- });
151
- describe('US1: Type coercion', () => {
152
- const dataModel = {
153
- str: 'text',
154
- num: 42,
155
- float: 3.14,
156
- bool: true,
157
- boolFalse: false,
158
- nullVal: null,
159
- arr: [1, 2, 3],
160
- obj: { key: 'value' },
161
- };
162
- it('should convert number to string', () => {
163
- const result = evaluateTemplate('${/num}', { dataModel, basePath: null });
164
- expect(result).toBe('42');
165
- });
166
- it('should convert float to string', () => {
167
- const result = evaluateTemplate('${/float}', {
168
- dataModel,
169
- basePath: null,
170
- });
171
- expect(result).toBe('3.14');
172
- });
173
- it('should convert boolean true to string', () => {
174
- const result = evaluateTemplate('${/bool}', { dataModel, basePath: null });
175
- expect(result).toBe('true');
176
- });
177
- it('should convert boolean false to string', () => {
178
- const result = evaluateTemplate('${/boolFalse}', {
179
- dataModel,
180
- basePath: null,
181
- });
182
- expect(result).toBe('false');
183
- });
184
- it('should convert null to empty string', () => {
185
- const result = evaluateTemplate('${/nullVal}', {
186
- dataModel,
187
- basePath: null,
188
- });
189
- expect(result).toBe('');
190
- });
191
- it('should convert array to JSON', () => {
192
- const result = evaluateTemplate('${/arr}', { dataModel, basePath: null });
193
- expect(result).toBe('[1,2,3]');
194
- });
195
- it('should convert object to JSON', () => {
196
- const result = evaluateTemplate('${/obj}', { dataModel, basePath: null });
197
- expect(result).toBe('{"key":"value"}');
198
- });
199
- });
200
- describe('US1: JSON Pointer escape decoding', () => {
201
- it('should decode ~1 to forward slash', () => {
202
- const dataModel = { 'a/b': 'slash-key' };
203
- const result = evaluateTemplate('${/a~1b}', { dataModel, basePath: null });
204
- expect(result).toBe('slash-key');
205
- });
206
- it('should decode ~0 to tilde', () => {
207
- const dataModel = { 'm~n': 'tilde-key' };
208
- const result = evaluateTemplate('${/m~0n}', { dataModel, basePath: null });
209
- expect(result).toBe('tilde-key');
210
- });
211
- it('should decode multiple escapes', () => {
212
- const dataModel = { 'a/b~c': 'mixed-key' };
213
- const result = evaluateTemplate('${/a~1b~0c}', {
214
- dataModel,
215
- basePath: null,
216
- });
217
- expect(result).toBe('mixed-key');
218
- });
219
- it('should decode in correct order (~1 before ~0)', () => {
220
- // Key is literally "~1" (not "/")
221
- const dataModel = { '~1': 'literal-tilde-one' };
222
- const result = evaluateTemplate('${/~01}', { dataModel, basePath: null });
223
- expect(result).toBe('literal-tilde-one');
224
- });
225
- });
226
- describe('US2: Function invocation', () => {
227
- const dataModel = { name: 'john' };
228
- it('should invoke upper function', () => {
229
- const result = evaluateTemplate("${upper('hello')}", {
230
- dataModel,
231
- basePath: null,
232
- });
233
- expect(result).toBe('HELLO');
234
- });
235
- it('should invoke lower function', () => {
236
- const result = evaluateTemplate("${lower('HELLO')}", {
237
- dataModel,
238
- basePath: null,
239
- });
240
- expect(result).toBe('hello');
241
- });
242
- it('should invoke now function', () => {
243
- const result = evaluateTemplate('${now()}', { dataModel, basePath: null });
244
- expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/);
245
- });
246
- it('should invoke add function', () => {
247
- const result = evaluateTemplate('${add(1, 2, 3)}', {
248
- dataModel,
249
- basePath: null,
250
- });
251
- expect(result).toBe('6');
252
- });
253
- it('should invoke sub function', () => {
254
- const result = evaluateTemplate('${sub(10, 3)}', {
255
- dataModel,
256
- basePath: null,
257
- });
258
- expect(result).toBe('7');
259
- });
260
- it('should invoke mul function', () => {
261
- const result = evaluateTemplate('${mul(2, 3, 4)}', {
262
- dataModel,
263
- basePath: null,
264
- });
265
- expect(result).toBe('24');
266
- });
267
- it('should invoke div function', () => {
268
- const result = evaluateTemplate('${div(10, 2)}', {
269
- dataModel,
270
- basePath: null,
271
- });
272
- expect(result).toBe('5');
273
- });
274
- it('should invoke concat function', () => {
275
- const result = evaluateTemplate("${concat('a', 'b', 'c')}", {
276
- dataModel,
277
- basePath: null,
278
- });
279
- expect(result).toBe('abc');
280
- });
281
- it('should invoke trim function', () => {
282
- const result = evaluateTemplate("${trim(' hello ')}", {
283
- dataModel,
284
- basePath: null,
285
- });
286
- expect(result).toBe('hello');
287
- });
288
- it('should invoke length function', () => {
289
- const result = evaluateTemplate("${length('hello')}", {
290
- dataModel,
291
- basePath: null,
292
- });
293
- expect(result).toBe('5');
294
- });
295
- it('should invoke if function', () => {
296
- const result = evaluateTemplate("${if(true, 'yes', 'no')}", {
297
- dataModel,
298
- basePath: null,
299
- });
300
- expect(result).toBe('yes');
301
- });
302
- it('should invoke default function', () => {
303
- const result = evaluateTemplate("${default(${/missing}, 'fallback')}", {
304
- dataModel,
305
- basePath: null,
306
- });
307
- expect(result).toBe('fallback');
308
- });
309
- });
310
- describe('US2: Unknown function handling', () => {
311
- let warnSpy;
312
- beforeEach(() => {
313
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
314
- });
315
- afterEach(() => {
316
- warnSpy.mockRestore();
317
- });
318
- it('should return empty string for unknown function', () => {
319
- const result = evaluateTemplate('${unknownFunc()}', {
320
- dataModel: {},
321
- basePath: null,
322
- });
323
- expect(result).toBe('');
324
- });
325
- it('should log warning for unknown function', () => {
326
- evaluateTemplate('${unknownFunc()}', { dataModel: {}, basePath: null });
327
- expect(warnSpy).toHaveBeenCalledWith('[A2UI] Unknown function: unknownFunc');
328
- });
329
- });
330
- describe('US2: Function with resolved path arguments', () => {
331
- const dataModel = {
332
- name: 'john',
333
- a: 5,
334
- b: 3,
335
- };
336
- it('should resolve path argument and pass to function', () => {
337
- const result = evaluateTemplate('${upper(${/name})}', {
338
- dataModel,
339
- basePath: null,
340
- });
341
- expect(result).toBe('JOHN');
342
- });
343
- it('should resolve multiple path arguments', () => {
344
- const result = evaluateTemplate('${add(${/a}, ${/b})}', {
345
- dataModel,
346
- basePath: null,
347
- });
348
- expect(result).toBe('8');
349
- });
350
- });
351
- describe('US3: Nested expression evaluation', () => {
352
- const dataModel = {
353
- name: 'john',
354
- value: 10,
355
- };
356
- it('should evaluate nested path in function', () => {
357
- const result = evaluateTemplate('${upper(${/name})}', {
358
- dataModel,
359
- basePath: null,
360
- });
361
- expect(result).toBe('JOHN');
362
- });
363
- it('should evaluate nested function call', () => {
364
- const result = evaluateTemplate("${upper(${lower('HELLO')})}", {
365
- dataModel,
366
- basePath: null,
367
- });
368
- expect(result).toBe('HELLO');
369
- });
370
- it('should evaluate deeply nested expressions', () => {
371
- const result = evaluateTemplate('${add(${mul(${/value}, 2)}, 5)}', {
372
- dataModel,
373
- basePath: null,
374
- });
375
- expect(result).toBe('25');
376
- });
377
- it('should evaluate innermost expressions first', () => {
378
- const result = evaluateTemplate("${concat(${upper('a')}, ${lower('B')})}", { dataModel, basePath: null });
379
- expect(result).toBe('Ab');
380
- });
381
- });
382
- describe('US4: Escaped expression output', () => {
383
- it('should output literal ${ for escaped expression', () => {
384
- const result = evaluateTemplate('\\${escaped}', {
385
- dataModel: {},
386
- basePath: null,
387
- });
388
- expect(result).toBe('${escaped}');
389
- });
390
- it('should handle mixed escaped and unescaped', () => {
391
- const result = evaluateTemplate('\\${escaped} ${/name}', {
392
- dataModel: { name: 'John' },
393
- basePath: null,
394
- });
395
- expect(result).toBe('${escaped} John');
396
- });
397
- it('should handle multiple escapes', () => {
398
- const result = evaluateTemplate('\\${a} and \\${b}', {
399
- dataModel: {},
400
- basePath: null,
401
- });
402
- expect(result).toBe('${a} and ${b}');
403
- });
404
- });
405
- describe('US5: Relative path resolution with basePath', () => {
406
- const dataModel = {
407
- users: [
408
- { name: 'Alice', age: 25 },
409
- { name: 'Bob', age: 30 },
410
- ],
411
- };
412
- it('should resolve relative path with basePath', () => {
413
- const result = evaluateTemplate('${name}', {
414
- dataModel,
415
- basePath: '/users/0',
416
- });
417
- expect(result).toBe('Alice');
418
- });
419
- it('should resolve different items with different basePath', () => {
420
- const result1 = evaluateTemplate('${name}', {
421
- dataModel,
422
- basePath: '/users/0',
423
- });
424
- const result2 = evaluateTemplate('${name}', {
425
- dataModel,
426
- basePath: '/users/1',
427
- });
428
- expect(result1).toBe('Alice');
429
- expect(result2).toBe('Bob');
430
- });
431
- it('should handle mixed absolute and relative paths', () => {
432
- const context = { dataModel, basePath: '/users/0' };
433
- const result = evaluateTemplate('${name} from ${/users/1/name}', context);
434
- expect(result).toBe('Alice from Bob');
435
- });
436
- it('should resolve nested relative paths', () => {
437
- const dataModel2 = {
438
- company: {
439
- dept: {
440
- employee: { name: 'Jane' },
441
- },
442
- },
443
- };
444
- const result = evaluateTemplate('${dept/employee/name}', {
445
- dataModel: dataModel2,
446
- basePath: '/company',
447
- });
448
- expect(result).toBe('Jane');
449
- });
450
- it('should handle null basePath as root', () => {
451
- const dataModel2 = { name: 'Root' };
452
- const result = evaluateTemplate('${name}', {
453
- dataModel: dataModel2,
454
- basePath: null,
455
- });
456
- expect(result).toBe('Root');
457
- });
458
- it('should handle "/" basePath as root', () => {
459
- const dataModel2 = { name: 'Root' };
460
- const result = evaluateTemplate('${name}', {
461
- dataModel: dataModel2,
462
- basePath: '/',
463
- });
464
- expect(result).toBe('Root');
465
- });
466
- });
467
- describe('Custom functions', () => {
468
- it('should use custom function from registry', () => {
469
- const customFunctions = {
470
- greet: (name) => `Hello, ${name}!`,
471
- };
472
- const result = evaluateTemplate("${greet('World')}", {
473
- dataModel: {},
474
- basePath: null,
475
- functions: customFunctions,
476
- });
477
- expect(result).toBe('Hello, World!');
478
- });
479
- it('should override test functions with custom function', () => {
480
- const customFunctions = {
481
- upper: () => 'CUSTOM',
482
- };
483
- const result = evaluateTemplate("${upper('test')}", {
484
- dataModel: {},
485
- basePath: null,
486
- functions: customFunctions,
487
- });
488
- expect(result).toBe('CUSTOM');
489
- });
490
- });
491
- describe('More functions', () => {
492
- it('should invoke mod function', () => {
493
- const result = evaluateTemplate('${mod(10, 3)}', {
494
- dataModel: {},
495
- basePath: null,
496
- });
497
- expect(result).toBe('1');
498
- });
499
- it('should invoke abs function', () => {
500
- const result = evaluateTemplate('${abs(-5)}', {
501
- dataModel: {},
502
- basePath: null,
503
- });
504
- expect(result).toBe('5');
505
- });
506
- it('should invoke round function', () => {
507
- const result = evaluateTemplate('${round(3.7)}', {
508
- dataModel: {},
509
- basePath: null,
510
- });
511
- expect(result).toBe('4');
512
- });
513
- it('should invoke floor function', () => {
514
- const result = evaluateTemplate('${floor(3.9)}', {
515
- dataModel: {},
516
- basePath: null,
517
- });
518
- expect(result).toBe('3');
519
- });
520
- it('should invoke ceil function', () => {
521
- const result = evaluateTemplate('${ceil(3.1)}', {
522
- dataModel: {},
523
- basePath: null,
524
- });
525
- expect(result).toBe('4');
526
- });
527
- it('should invoke eq function', () => {
528
- const result = evaluateTemplate("${eq('a', 'a')}", {
529
- dataModel: {},
530
- basePath: null,
531
- });
532
- expect(result).toBe('true');
533
- });
534
- it('should invoke ne function', () => {
535
- const result = evaluateTemplate("${ne('a', 'b')}", {
536
- dataModel: {},
537
- basePath: null,
538
- });
539
- expect(result).toBe('true');
540
- });
541
- it('should invoke gt function', () => {
542
- const result = evaluateTemplate('${gt(5, 3)}', {
543
- dataModel: {},
544
- basePath: null,
545
- });
546
- expect(result).toBe('true');
547
- });
548
- it('should invoke lt function', () => {
549
- const result = evaluateTemplate('${lt(3, 5)}', {
550
- dataModel: {},
551
- basePath: null,
552
- });
553
- expect(result).toBe('true');
554
- });
555
- it('should invoke gte function', () => {
556
- const result = evaluateTemplate('${gte(5, 5)}', {
557
- dataModel: {},
558
- basePath: null,
559
- });
560
- expect(result).toBe('true');
561
- });
562
- it('should invoke lte function', () => {
563
- const result = evaluateTemplate('${lte(5, 5)}', {
564
- dataModel: {},
565
- basePath: null,
566
- });
567
- expect(result).toBe('true');
568
- });
569
- it('should invoke join function with array', () => {
570
- const result = evaluateTemplate("${join(${/arr}, '-')}", {
571
- dataModel: { arr: [1, 2, 3] },
572
- basePath: null,
573
- });
574
- expect(result).toBe('1-2-3');
575
- });
576
- it('should invoke join function with non-array', () => {
577
- const result = evaluateTemplate("${join('test', '-')}", {
578
- dataModel: {},
579
- basePath: null,
580
- });
581
- expect(result).toBe('test');
582
- });
583
- it('should invoke split function', () => {
584
- const result = evaluateTemplate("${split('a,b,c', ',')}", {
585
- dataModel: {},
586
- basePath: null,
587
- });
588
- expect(result).toBe('["a","b","c"]');
589
- });
590
- it('should invoke replace function', () => {
591
- const result = evaluateTemplate("${replace('hello world', 'world', 'there')}", { dataModel: {}, basePath: null });
592
- expect(result).toBe('hello there');
593
- });
594
- it('should invoke substr function', () => {
595
- const result = evaluateTemplate("${substr('hello', 0, 3)}", {
596
- dataModel: {},
597
- basePath: null,
598
- });
599
- expect(result).toBe('hel');
600
- });
601
- it('should invoke substr function without length', () => {
602
- const result = evaluateTemplate("${substr('hello', 2)}", {
603
- dataModel: {},
604
- basePath: null,
605
- });
606
- expect(result).toBe('llo');
607
- });
608
- it('should invoke toString function', () => {
609
- const result = evaluateTemplate('${toString(42)}', {
610
- dataModel: {},
611
- basePath: null,
612
- });
613
- expect(result).toBe('42');
614
- });
615
- it('should invoke toNumber function', () => {
616
- const result = evaluateTemplate("${toNumber('42')}", {
617
- dataModel: {},
618
- basePath: null,
619
- });
620
- expect(result).toBe('42');
621
- });
622
- it('should invoke toBoolean function', () => {
623
- const result = evaluateTemplate('${toBoolean(1)}', {
624
- dataModel: {},
625
- basePath: null,
626
- });
627
- expect(result).toBe('true');
628
- });
629
- it('should invoke json function', () => {
630
- const result = evaluateTemplate('${json(${/obj})}', {
631
- dataModel: { obj: { a: 1 } },
632
- basePath: null,
633
- });
634
- expect(result).toBe('{"a":1}');
635
- });
636
- it('should invoke parseJson function', () => {
637
- const result = evaluateTemplate('${parseJson(\'{"a":1}\')}', {
638
- dataModel: {},
639
- basePath: null,
640
- });
641
- expect(result).toBe('{"a":1}');
642
- });
643
- it('should handle parseJson with invalid JSON', () => {
644
- const result = evaluateTemplate("${parseJson('invalid')}", {
645
- dataModel: {},
646
- basePath: null,
647
- });
648
- expect(result).toBe('');
649
- });
650
- it('should handle div by zero', () => {
651
- const result = evaluateTemplate('${div(10, 0)}', {
652
- dataModel: {},
653
- basePath: null,
654
- });
655
- expect(result).toBe('0');
656
- });
657
- it('should invoke length function with array', () => {
658
- const result = evaluateTemplate('${length(${/arr})}', {
659
- dataModel: { arr: [1, 2, 3] },
660
- basePath: null,
661
- });
662
- expect(result).toBe('3');
663
- });
664
- });
665
- describe('Edge cases', () => {
666
- it('should handle empty template', () => {
667
- const result = evaluateTemplate('', { dataModel: {}, basePath: null });
668
- expect(result).toBe('');
669
- });
670
- it('should handle template with only literal text', () => {
671
- const result = evaluateTemplate('Hello World', {
672
- dataModel: {},
673
- basePath: null,
674
- });
675
- expect(result).toBe('Hello World');
676
- });
677
- it('should handle expression at start', () => {
678
- const result = evaluateTemplate('${/name} is here', {
679
- dataModel: { name: 'John' },
680
- basePath: null,
681
- });
682
- expect(result).toBe('John is here');
683
- });
684
- it('should handle expression at end', () => {
685
- const result = evaluateTemplate('Name: ${/name}', {
686
- dataModel: { name: 'John' },
687
- basePath: null,
688
- });
689
- expect(result).toBe('Name: John');
690
- });
691
- it('should handle only expression', () => {
692
- const result = evaluateTemplate('${/name}', {
693
- dataModel: { name: 'John' },
694
- basePath: null,
695
- });
696
- expect(result).toBe('John');
697
- });
698
- });
699
- });