lightview 2.2.2 → 2.3.4

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/jprx/parser.js CHANGED
@@ -28,6 +28,9 @@ const DEFAULT_PRECEDENCE = {
28
28
  */
29
29
  export const registerHelper = (name, fn, options = {}) => {
30
30
  helpers.set(name, fn);
31
+ if (globalThis.__LIGHTVIEW_INTERNALS__) {
32
+ globalThis.__LIGHTVIEW_INTERNALS__.helpers.set(name, fn);
33
+ }
31
34
  if (options) helperOptions.set(name, options);
32
35
  };
33
36
 
@@ -133,28 +136,13 @@ export const resolvePath = (path, context) => {
133
136
  // Current context: .
134
137
  if (path === '.') return unwrapSignal(context);
135
138
 
136
- // Global absolute path: $/something
137
- // First check if the root is in the local context's state (cdom-state)
138
- // This allows $/cart to resolve from cdom-state: { cart: {...} }
139
- if (path.startsWith('$/')) {
139
+ // Global absolute path: =/something
140
+ if (path.startsWith('=/')) {
140
141
  const [rootName, ...rest] = path.slice(2).split('/');
141
-
142
- // Check local state chain first (via __state__ property set by handleCDOMState)
143
- let cur = context;
144
- while (cur) {
145
- const localState = cur.__state__;
146
- if (localState && rootName in localState) {
147
- return traverse(localState[rootName], rest);
148
- }
149
- cur = cur.__parent__;
150
- }
151
-
152
- // Then check global registry
153
- const rootSignal = registry?.get(rootName);
154
- if (!rootSignal) return undefined;
155
-
156
- // Root can be a signal or a state proxy
157
- return traverse(rootSignal, rest);
142
+ const LV = getLV();
143
+ const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
144
+ if (!root) return undefined;
145
+ return traverse(root, rest);
158
146
  }
159
147
 
160
148
  // Relative path from current context
@@ -196,27 +184,14 @@ export const resolvePathAsContext = (path, context) => {
196
184
  // Current context: .
197
185
  if (path === '.') return context;
198
186
 
199
- // Global absolute path: $/something
200
- // First check if the root is in the local context's state (cdom-state)
201
- if (path.startsWith('$/')) {
187
+ // Global absolute path: =/something
188
+ if (path.startsWith('=/')) {
202
189
  const segments = path.slice(2).split(/[/.]/);
203
190
  const rootName = segments.shift();
204
-
205
- // Check local state chain first
206
- let cur = context;
207
- while (cur) {
208
- const localState = cur.__state__;
209
- if (localState && rootName in localState) {
210
- return traverseAsContext(localState[rootName], segments);
211
- }
212
- cur = cur.__parent__;
213
- }
214
-
215
- // Then check global registry
216
- const rootSignal = registry?.get(rootName);
217
- if (!rootSignal) return undefined;
218
-
219
- return traverseAsContext(rootSignal, segments);
191
+ const LV = getLV();
192
+ const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
193
+ if (!root) return undefined;
194
+ return traverseAsContext(root, segments);
220
195
  }
221
196
 
222
197
  // Relative path from current context
@@ -260,6 +235,11 @@ class LazyValue {
260
235
  }
261
236
  }
262
237
 
238
+ /**
239
+ * Node helper - identifies if a value is a DOM node.
240
+ */
241
+ const isNode = (val) => val && typeof val === 'object' && globalThis.Node && val instanceof globalThis.Node;
242
+
263
243
  /**
264
244
  * Helper to resolve an argument which could be a literal, a path, or an explosion.
265
245
  * @param {string} arg - The argument string
@@ -294,10 +274,23 @@ const resolveArgument = (arg, context, globalMode = false) => {
294
274
  };
295
275
  }
296
276
 
297
- // 5. Event Placeholder ($event)
277
+ // 5. Context Identifiers ($this, $event)
278
+ if (arg === '$this' || arg.startsWith('$this/') || arg.startsWith('$this.')) {
279
+ return {
280
+ value: new LazyValue((context) => {
281
+ const node = context?.__node__ || context;
282
+ if (arg === '$this') return node;
283
+ const path = arg.startsWith('$this.') ? arg.slice(6) : arg.slice(6);
284
+ return resolvePath(path, node);
285
+ }),
286
+ isLazy: true
287
+ };
288
+ }
289
+
298
290
  if (arg === '$event' || arg.startsWith('$event/') || arg.startsWith('$event.')) {
299
291
  return {
300
- value: new LazyValue((event) => {
292
+ value: new LazyValue((context) => {
293
+ const event = context?.$event || context?.event || context;
301
294
  if (arg === '$event') return event;
302
295
  const path = arg.startsWith('$event.') ? arg.slice(7) : arg.slice(7);
303
296
  return resolvePath(path, event);
@@ -311,14 +304,25 @@ const resolveArgument = (arg, context, globalMode = false) => {
311
304
  try {
312
305
  const data = parseJPRX(arg);
313
306
 
314
- // Define a recursive resolver for template objects
315
307
  const resolveTemplate = (node, context) => {
316
308
  if (typeof node === 'string') {
317
- if (node.startsWith('$')) {
309
+ if (node.startsWith('=')) {
318
310
  const res = resolveExpression(node, context);
319
311
  const final = (res instanceof LazyValue) ? res.resolve(context) : res;
320
312
  return unwrapSignal(final);
321
313
  }
314
+ if (node === '$this' || node.startsWith('$this/') || node.startsWith('$this.')) {
315
+ const path = (node.startsWith('$this.') || node.startsWith('$this/')) ? node.slice(6) : node.slice(6);
316
+ const ctxNode = context?.__node__ || context;
317
+ const res = node === '$this' ? ctxNode : resolvePath(path, ctxNode);
318
+ return unwrapSignal(res);
319
+ }
320
+ if (node === '$event' || node.startsWith('$event/') || node.startsWith('$event.')) {
321
+ const path = (node.startsWith('$event.') || node.startsWith('$event/')) ? node.slice(7) : node.slice(7);
322
+ const event = context?.$event || context?.event || (context && !isNode(context) ? context : null);
323
+ const res = node === '$event' ? event : resolvePath(path, event);
324
+ return unwrapSignal(res);
325
+ }
322
326
  if (node === '_' || node.startsWith('_/') || node.startsWith('_.')) {
323
327
  const path = (node.startsWith('_.') || node.startsWith('_/')) ? node.slice(2) : node.slice(2);
324
328
  const res = node === '_' ? context : resolvePath(path, context);
@@ -335,10 +339,9 @@ const resolveArgument = (arg, context, globalMode = false) => {
335
339
  return node;
336
340
  };
337
341
 
338
- // Check if it contains any reactive parts
339
342
  const hasReactive = (obj) => {
340
343
  if (typeof obj === 'string') {
341
- return obj.startsWith('$') || obj.startsWith('_') || obj.startsWith('../');
344
+ return obj.startsWith('=') || obj.startsWith('_') || obj.startsWith('../');
342
345
  }
343
346
  if (Array.isArray(obj)) return obj.some(hasReactive);
344
347
  if (obj && typeof obj === 'object') return Object.values(obj).some(hasReactive);
@@ -361,9 +364,9 @@ const resolveArgument = (arg, context, globalMode = false) => {
361
364
  if (arg.includes('(')) {
362
365
  let nestedExpr = arg;
363
366
  if (arg.startsWith('/')) {
364
- nestedExpr = '$' + arg;
365
- } else if (globalMode && !arg.startsWith('$') && !arg.startsWith('./')) {
366
- nestedExpr = `$/${arg}`;
367
+ nestedExpr = '=' + arg;
368
+ } else if (globalMode && !arg.startsWith('=') && !arg.startsWith('./')) {
369
+ nestedExpr = `=/${arg}`;
367
370
  }
368
371
 
369
372
  const val = resolveExpression(nestedExpr, context);
@@ -376,11 +379,11 @@ const resolveArgument = (arg, context, globalMode = false) => {
376
379
  // 8. Path normalization
377
380
  let normalizedPath;
378
381
  if (arg.startsWith('/')) {
379
- normalizedPath = '$' + arg;
380
- } else if (arg.startsWith('$') || arg.startsWith('./') || arg.startsWith('../')) {
382
+ normalizedPath = '=' + arg;
383
+ } else if (arg.startsWith('=') || arg.startsWith('./') || arg.startsWith('../')) {
381
384
  normalizedPath = arg;
382
385
  } else if (globalMode) {
383
- normalizedPath = `$/${arg}`;
386
+ normalizedPath = `=/${arg}`;
384
387
  } else {
385
388
  normalizedPath = `./${arg}`;
386
389
  }
@@ -433,6 +436,7 @@ const TokenType = {
433
436
  COMMA: 'COMMA', // ,
434
437
  EXPLOSION: 'EXPLOSION', // ... suffix
435
438
  PLACEHOLDER: 'PLACEHOLDER', // _, _/path
439
+ THIS: 'THIS', // $this
436
440
  EVENT: 'EVENT', // $event, $event.target
437
441
  EOF: 'EOF'
438
442
  };
@@ -477,20 +481,21 @@ const tokenize = (expr) => {
477
481
  continue;
478
482
  }
479
483
 
480
- // Special: $ followed immediately by an operator symbol
481
- // In expressions like "$++/count", the $ is just the JPRX delimiter
484
+ // Special: = followed immediately by an operator symbol
485
+ // In expressions like "=++/count", the = is just the JPRX delimiter
482
486
  // and ++ is a prefix operator applied to /count
483
- if (expr[i] === '$' && i + 1 < len) {
484
- // Check if next chars are an operator
485
- let isOpAfter = false;
486
- for (const op of opSymbols) {
487
+ if (expr[i] === '=' && i + 1 < len) {
488
+ // Check if next chars are a PREFIX operator (sort by length to match longest first)
489
+ const prefixOps = [...operators.prefix.keys()].sort((a, b) => b.length - a.length);
490
+ let isPrefixOp = false;
491
+ for (const op of prefixOps) {
487
492
  if (expr.slice(i + 1, i + 1 + op.length) === op) {
488
- isOpAfter = true;
493
+ isPrefixOp = true;
489
494
  break;
490
495
  }
491
496
  }
492
- if (isOpAfter) {
493
- // Skip the $, it's just a delimiter
497
+ if (isPrefixOp) {
498
+ // Skip the =, it's just a delimiter for a prefix operator (e.g., =++/count)
494
499
  i++;
495
500
  continue;
496
501
  }
@@ -547,7 +552,7 @@ const tokenize = (expr) => {
547
552
  tokens[tokens.length - 1].type === TokenType.LPAREN ||
548
553
  tokens[tokens.length - 1].type === TokenType.COMMA ||
549
554
  tokens[tokens.length - 1].type === TokenType.OPERATOR;
550
- const validAfter = /[\s($./'"0-9_]/.test(after) ||
555
+ const validAfter = /[\s(=./'"0-9_]/.test(after) ||
551
556
  i + op.length >= len ||
552
557
  opSymbols.some(o => expr.slice(i + op.length).startsWith(o));
553
558
 
@@ -620,6 +625,18 @@ const tokenize = (expr) => {
620
625
  continue;
621
626
  }
622
627
 
628
+ // $this placeholder
629
+ if (expr.slice(i, i + 5) === '$this') {
630
+ let thisPath = '$this';
631
+ i += 5;
632
+ while (i < len && /[a-zA-Z0-9_./]/.test(expr[i])) {
633
+ thisPath += expr[i];
634
+ i++;
635
+ }
636
+ tokens.push({ type: TokenType.THIS, value: thisPath });
637
+ continue;
638
+ }
639
+
623
640
  // $event placeholder
624
641
  if (expr.slice(i, i + 6) === '$event') {
625
642
  let eventPath = '$event';
@@ -632,8 +649,8 @@ const tokenize = (expr) => {
632
649
  continue;
633
650
  }
634
651
 
635
- // Paths: start with $, ., or /
636
- if (expr[i] === '$' || expr[i] === '.' || expr[i] === '/') {
652
+ // Paths: start with =, ., or /
653
+ if (expr[i] === '=' || expr[i] === '.' || expr[i] === '/') {
637
654
  let path = '';
638
655
  // Consume the path, but stop at operators
639
656
  while (i < len) {
@@ -715,9 +732,9 @@ const tokenize = (expr) => {
715
732
  * Used to determine whether to use Pratt parser or legacy parser.
716
733
  *
717
734
  * CONSERVATIVE: Only detect explicit patterns to avoid false positives.
718
- * - Prefix: $++/path, $--/path, $!!/path (operator immediately after $ before path)
719
- * - Postfix: $/path++ or $/path-- (operator at end of expression, not followed by ()
720
- * - Infix with spaces: $/path + $/other (spaces around operator)
735
+ * - Prefix: =++/path, =--/path, =!!/path (operator immediately after = before path)
736
+ * - Postfix: =/path++ or =/path-- (operator at end of expression, not followed by ()
737
+ * - Infix with spaces: =/path + =/other (spaces around operator)
721
738
  */
722
739
  const hasOperatorSyntax = (expr) => {
723
740
  if (!expr || typeof expr !== 'string') return false;
@@ -725,19 +742,19 @@ const hasOperatorSyntax = (expr) => {
725
742
  // Skip function calls - they use legacy parser
726
743
  if (expr.includes('(')) return false;
727
744
 
728
- // Check for prefix operator pattern: $++ or $-- followed by /
729
- // This catches: $++/counter, $--/value
730
- if (/^\$(\+\+|--|!!)\/?/.test(expr)) {
745
+ // Check for prefix operator pattern: =++ or =-- followed by /
746
+ // This catches: =++/counter, =--/value
747
+ if (/^=(\+\+|--|!!)\/?/.test(expr)) {
731
748
  return true;
732
749
  }
733
750
 
734
751
  // Check for postfix operator pattern: path ending with ++ or --
735
- // This catches: $/counter++, $/value--
752
+ // This catches: =/counter++, =/value--
736
753
  if (/(\+\+|--)$/.test(expr)) {
737
754
  return true;
738
755
  }
739
756
 
740
- // Check for infix with explicit whitespace: $/a + $/b
757
+ // Check for infix with explicit whitespace: =/a + =/b
741
758
  // The spaces make it unambiguous that the symbol is an operator, not part of a path
742
759
  if (/\s+([+\-*/]|>|<|>=|<=|!=)\s+/.test(expr)) {
743
760
  return true;
@@ -796,7 +813,7 @@ class PrattParser {
796
813
  const prec = this.getInfixPrecedence(tok.value);
797
814
  if (prec < minPrecedence) break;
798
815
 
799
- // Check if it's a postfix operator
816
+ // Check if it's a postfix-only operator
800
817
  if (operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
801
818
  this.consume();
802
819
  left = { type: 'Postfix', operator: tok.value, operand: left };
@@ -814,6 +831,12 @@ class PrattParser {
814
831
  continue;
815
832
  }
816
833
 
834
+ // Operator not registered as postfix or infix - treat as unknown and stop
835
+ // This prevents infinite loops when operators are tokenized but not registered
836
+ if (!operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
837
+ break;
838
+ }
839
+
817
840
  // Postfix that's also infix - context determines
818
841
  // If next token is a value, treat as infix
819
842
  this.consume();
@@ -871,6 +894,12 @@ class PrattParser {
871
894
  return { type: 'Placeholder', value: tok.value };
872
895
  }
873
896
 
897
+ // This
898
+ if (tok.type === TokenType.THIS) {
899
+ this.consume();
900
+ return { type: 'This', value: tok.value };
901
+ }
902
+
874
903
  // Event
875
904
  if (tok.type === TokenType.EVENT) {
876
905
  this.consume();
@@ -927,8 +956,18 @@ const evaluateAST = (ast, context, forMutation = false) => {
927
956
  });
928
957
  }
929
958
 
959
+ case 'This': {
960
+ return new LazyValue((context) => {
961
+ const node = context?.__node__ || context;
962
+ if (ast.value === '$this') return node;
963
+ const path = ast.value.startsWith('$this.') ? ast.value.slice(6) : ast.value.slice(6);
964
+ return resolvePath(path, node);
965
+ });
966
+ }
967
+
930
968
  case 'Event': {
931
- return new LazyValue((event) => {
969
+ return new LazyValue((context) => {
970
+ const event = context?.$event || context?.event || context;
932
971
  if (ast.value === '$event') return event;
933
972
  const path = ast.value.startsWith('$event.') ? ast.value.slice(7) : ast.value.slice(7);
934
973
  return resolvePath(path, event);
@@ -985,7 +1024,18 @@ const evaluateAST = (ast, context, forMutation = false) => {
985
1024
  // For infix, typically first arg might be pathAware
986
1025
  const left = evaluateAST(ast.left, context, opts.pathAware);
987
1026
  const right = evaluateAST(ast.right, context, false);
988
- return helper(unwrapSignal(left), unwrapSignal(right));
1027
+
1028
+ const finalArgs = [];
1029
+
1030
+ // Handle potentially exploded arguments (like in sum(/items...p))
1031
+ // Although infix operators usually take exactly 2 args, we treat them consistently
1032
+ if (Array.isArray(left) && ast.left.type === 'Explosion') finalArgs.push(...left);
1033
+ else finalArgs.push(unwrapSignal(left));
1034
+
1035
+ if (Array.isArray(right) && ast.right.type === 'Explosion') finalArgs.push(...right);
1036
+ else finalArgs.push(unwrapSignal(right));
1037
+
1038
+ return helper(...finalArgs);
989
1039
  }
990
1040
 
991
1041
  default:
@@ -1032,19 +1082,19 @@ export const resolveExpression = (expr, context) => {
1032
1082
  const argsStr = expr.slice(funcStart + 1, -1);
1033
1083
 
1034
1084
  const segments = fullPath.split('/');
1035
- let funcName = segments.pop().replace(/^\$/, '');
1085
+ let funcName = segments.pop().replace(/^=/, '');
1036
1086
 
1037
- // Handle case where path ends in / (like $/ for division helper)
1087
+ // Handle case where path ends in / (like =/ for division helper)
1038
1088
  if (funcName === '' && (segments.length > 0 || fullPath === '/')) {
1039
1089
  funcName = '/';
1040
1090
  }
1041
1091
 
1042
1092
  const navPath = segments.join('/');
1043
1093
 
1044
- const isGlobalExpr = expr.startsWith('$/') || expr.startsWith('$');
1094
+ const isGlobalExpr = expr.startsWith('=/') || expr.startsWith('=');
1045
1095
 
1046
1096
  let baseContext = context;
1047
- if (navPath && navPath !== '$') {
1097
+ if (navPath && navPath !== '=') {
1048
1098
  baseContext = resolvePathAsContext(navPath, context);
1049
1099
  }
1050
1100
 
@@ -1082,7 +1132,7 @@ export const resolveExpression = (expr, context) => {
1082
1132
  let hasLazy = false;
1083
1133
  for (let i = 0; i < argsList.length; i++) {
1084
1134
  const arg = argsList[i];
1085
- const useGlobalMode = isGlobalExpr && (navPath === '$' || !navPath);
1135
+ const useGlobalMode = isGlobalExpr && (navPath === '=' || !navPath);
1086
1136
  const res = resolveArgument(arg, baseContext, useGlobalMode);
1087
1137
 
1088
1138
  if (res.isLazy) hasLazy = true;
@@ -1117,7 +1167,7 @@ export const resolveExpression = (expr, context) => {
1117
1167
  });
1118
1168
  }
1119
1169
 
1120
- const result = helper(...resolvedArgs);
1170
+ const result = helper.apply(context?.__node__ || null, resolvedArgs);
1121
1171
  return unwrapSignal(result);
1122
1172
  }
1123
1173
 
@@ -1247,8 +1297,8 @@ export const parseCDOMC = (input) => {
1247
1297
 
1248
1298
  const word = input.slice(start, i);
1249
1299
 
1250
- // If word starts with $, preserve it as a string for cDOM expression parsing
1251
- if (word.startsWith('$')) {
1300
+ // If word starts with =, preserve it as a string for cDOM expression parsing
1301
+ if (word.startsWith('=')) {
1252
1302
  return word;
1253
1303
  }
1254
1304
 
@@ -1424,8 +1474,8 @@ export const parseJPRX = (input) => {
1424
1474
  continue;
1425
1475
  }
1426
1476
 
1427
- // Handle JPRX expressions starting with $ (MUST come before word handler!)
1428
- if (char === '$') {
1477
+ // Handle JPRX expressions starting with = (MUST come before word handler!)
1478
+ if (char === '=') {
1429
1479
  let expr = '';
1430
1480
  let parenDepth = 0;
1431
1481
  let braceDepth = 0;
@@ -1463,7 +1513,7 @@ export const parseJPRX = (input) => {
1463
1513
  }
1464
1514
 
1465
1515
  // Handle unquoted property names, identifiers, and paths
1466
- if (/[a-zA-Z_./]/.test(char)) {
1516
+ if (/[a-zA-Z_$/./]/.test(char)) {
1467
1517
  let word = '';
1468
1518
  while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
1469
1519
  word += input[i];
@@ -0,0 +1,71 @@
1
+ [
2
+ {
3
+ "name": "Global Path Resolution",
4
+ "state": {
5
+ "userName": "Alice"
6
+ },
7
+ "expression": "=/userName",
8
+ "expected": "Alice"
9
+ },
10
+ {
11
+ "name": "Deep Global Path Resolution",
12
+ "state": {
13
+ "user": {
14
+ "profile": {
15
+ "name": "Bob"
16
+ }
17
+ }
18
+ },
19
+ "expression": "=/user/profile/name",
20
+ "expected": "Bob"
21
+ },
22
+ {
23
+ "name": "Relative Path Resolution",
24
+ "context": {
25
+ "age": 30
26
+ },
27
+ "expression": "./age",
28
+ "expected": 30
29
+ },
30
+ {
31
+ "name": "Math: Addition",
32
+ "state": {
33
+ "a": 5,
34
+ "b": 10
35
+ },
36
+ "expression": "=+(/a, /b)",
37
+ "expected": 15
38
+ },
39
+ {
40
+ "name": "Operator: Addition (Infix)",
41
+ "state": {
42
+ "a": 5,
43
+ "b": 10
44
+ },
45
+ "expression": "=/a + =/b",
46
+ "expected": 15
47
+ },
48
+ {
49
+ "name": "Logic: If (True)",
50
+ "state": {
51
+ "isVip": true
52
+ },
53
+ "expression": "=if(/isVip, \"Gold\", \"Silver\")",
54
+ "expected": "Gold"
55
+ },
56
+ {
57
+ "name": "Explosion Operator",
58
+ "state": {
59
+ "items": [
60
+ {
61
+ "p": 10
62
+ },
63
+ {
64
+ "p": 20
65
+ }
66
+ ]
67
+ },
68
+ "expression": "=sum(/items...p)",
69
+ "expected": 30
70
+ }
71
+ ]
@@ -0,0 +1,150 @@
1
+ [
2
+ {
3
+ "name": "Math: Subtraction",
4
+ "expression": "=-(10, 3)",
5
+ "expected": 7
6
+ },
7
+ {
8
+ "name": "Math: Multiplication",
9
+ "expression": "=*(4, 5)",
10
+ "expected": 20
11
+ },
12
+ {
13
+ "name": "Math: Division",
14
+ "expression": "=/(20, 4)",
15
+ "expected": 5
16
+ },
17
+ {
18
+ "name": "Math: Round",
19
+ "expression": "=round(3.7)",
20
+ "expected": 4
21
+ },
22
+ {
23
+ "name": "String: Upper",
24
+ "expression": "=upper('hello')",
25
+ "expected": "HELLO"
26
+ },
27
+ {
28
+ "name": "String: Lower",
29
+ "expression": "=lower('WORLD')",
30
+ "expected": "world"
31
+ },
32
+ {
33
+ "name": "String: Concat",
34
+ "expression": "=concat('Hello', ' ', 'World')",
35
+ "expected": "Hello World"
36
+ },
37
+ {
38
+ "name": "String: Trim",
39
+ "expression": "=trim(' spaced ')",
40
+ "expected": "spaced"
41
+ },
42
+ {
43
+ "name": "Logic: And (true)",
44
+ "expression": "=and(true, true)",
45
+ "expected": true
46
+ },
47
+ {
48
+ "name": "Logic: And (false)",
49
+ "expression": "=and(true, false)",
50
+ "expected": false
51
+ },
52
+ {
53
+ "name": "Logic: Or",
54
+ "expression": "=or(false, true)",
55
+ "expected": true
56
+ },
57
+ {
58
+ "name": "Logic: Not",
59
+ "expression": "=not(false)",
60
+ "expected": true
61
+ },
62
+ {
63
+ "name": "Compare: Greater Than",
64
+ "expression": "=gt(10, 5)",
65
+ "expected": true
66
+ },
67
+ {
68
+ "name": "Compare: Less Than",
69
+ "expression": "=lt(3, 7)",
70
+ "expected": true
71
+ },
72
+ {
73
+ "name": "Compare: Equal",
74
+ "expression": "=eq(5, 5)",
75
+ "expected": true
76
+ },
77
+ {
78
+ "name": "Conditional: If (true branch)",
79
+ "expression": "=if(true, 'Yes', 'No')",
80
+ "expected": "Yes"
81
+ },
82
+ {
83
+ "name": "Conditional: If (false branch)",
84
+ "expression": "=if(false, 'Yes', 'No')",
85
+ "expected": "No"
86
+ },
87
+ {
88
+ "name": "Stats: Average",
89
+ "expression": "=avg(10, 20, 30)",
90
+ "expected": 20
91
+ },
92
+ {
93
+ "name": "Stats: Min",
94
+ "expression": "=min(5, 2, 8)",
95
+ "expected": 2
96
+ },
97
+ {
98
+ "name": "Stats: Max",
99
+ "expression": "=max(5, 2, 8)",
100
+ "expected": 8
101
+ },
102
+ {
103
+ "name": "Array: Length",
104
+ "state": {
105
+ "items": [
106
+ "a",
107
+ "b",
108
+ "c"
109
+ ]
110
+ },
111
+ "expression": "=len(/items)",
112
+ "expected": 3
113
+ },
114
+ {
115
+ "name": "Array: Join",
116
+ "state": {
117
+ "items": [
118
+ "a",
119
+ "b",
120
+ "c"
121
+ ]
122
+ },
123
+ "expression": "=join(/items, '-')",
124
+ "expected": "a-b-c"
125
+ },
126
+ {
127
+ "name": "Array: First",
128
+ "state": {
129
+ "items": [
130
+ "a",
131
+ "b",
132
+ "c"
133
+ ]
134
+ },
135
+ "expression": "=first(/items)",
136
+ "expected": "a"
137
+ },
138
+ {
139
+ "name": "Array: Last",
140
+ "state": {
141
+ "items": [
142
+ "a",
143
+ "b",
144
+ "c"
145
+ ]
146
+ },
147
+ "expression": "=last(/items)",
148
+ "expected": "c"
149
+ }
150
+ ]