@fogpipe/forma-core 0.8.1 → 0.9.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.
@@ -0,0 +1,833 @@
1
+ /**
2
+ * Tests for FEEL expression evaluation
3
+ *
4
+ * Focuses on null handling behavior and warning scenarios.
5
+ * FEEL uses three-valued logic where comparisons with null/undefined return null,
6
+ * not false. This is critical to understand for form visibility expressions.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+ import {
11
+ evaluate,
12
+ evaluateBoolean,
13
+ evaluateNumber,
14
+ evaluateString,
15
+ evaluateBooleanBatch,
16
+ isValidExpression,
17
+ validateExpression,
18
+ } from "../feel/index.js";
19
+ import type { EvaluationContext } from "../types.js";
20
+
21
+ // =============================================================================
22
+ // Test Helpers
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Create a minimal evaluation context for testing
27
+ */
28
+ function createContext(
29
+ overrides: Partial<EvaluationContext> = {}
30
+ ): EvaluationContext {
31
+ return {
32
+ data: {},
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ // =============================================================================
38
+ // evaluate() Tests
39
+ // =============================================================================
40
+
41
+ describe("evaluate", () => {
42
+ describe("basic expressions", () => {
43
+ it("evaluates simple arithmetic", () => {
44
+ const result = evaluate("2 + 3", createContext());
45
+ expect(result.success).toBe(true);
46
+ if (result.success) {
47
+ expect(result.value).toBe(5);
48
+ }
49
+ });
50
+
51
+ it("evaluates field references from data", () => {
52
+ const result = evaluate(
53
+ "age >= 18",
54
+ createContext({ data: { age: 21 } })
55
+ );
56
+ expect(result.success).toBe(true);
57
+ if (result.success) {
58
+ expect(result.value).toBe(true);
59
+ }
60
+ });
61
+
62
+ it("evaluates computed field references", () => {
63
+ const result = evaluate(
64
+ "computed.total > 100",
65
+ createContext({ data: {}, computed: { total: 150 } })
66
+ );
67
+ expect(result.success).toBe(true);
68
+ if (result.success) {
69
+ expect(result.value).toBe(true);
70
+ }
71
+ });
72
+
73
+ it("evaluates item field references in array context", () => {
74
+ const result = evaluate(
75
+ "item.quantity > 5",
76
+ createContext({ data: {}, item: { quantity: 10 } })
77
+ );
78
+ expect(result.success).toBe(true);
79
+ if (result.success) {
80
+ expect(result.value).toBe(true);
81
+ }
82
+ });
83
+
84
+ it("evaluates value reference for validation", () => {
85
+ const result = evaluate("value >= 0", createContext({ data: {}, value: 5 }));
86
+ expect(result.success).toBe(true);
87
+ if (result.success) {
88
+ expect(result.value).toBe(true);
89
+ }
90
+ });
91
+ });
92
+
93
+ describe("null/undefined handling", () => {
94
+ /**
95
+ * IMPORTANT: Feelin's behavior differs from strict FEEL three-valued logic.
96
+ *
97
+ * In feelin:
98
+ * - Equality checks (=, !=) with undefined return false, not null
99
+ * - Numeric comparisons (>, <, >=, <=) with undefined return null
100
+ * - Logical operators (and, or) propagate null correctly
101
+ * - Function calls on undefined (string length, count, etc.) return null
102
+ */
103
+
104
+ it("equality check on undefined field returns false (feelin behavior)", () => {
105
+ // In feelin, undefined = true returns false, not null
106
+ // This differs from strict FEEL three-valued logic
107
+ const result = evaluate(
108
+ "undefinedField = true",
109
+ createContext({ data: {} })
110
+ );
111
+ expect(result.success).toBe(true);
112
+ if (result.success) {
113
+ expect(result.value).toBe(false);
114
+ }
115
+ });
116
+
117
+ it("!= null check on undefined field returns false (feelin behavior)", () => {
118
+ // In feelin, undefined != null returns false
119
+ const result = evaluate(
120
+ "undefinedField != null",
121
+ createContext({ data: {} })
122
+ );
123
+ expect(result.success).toBe(true);
124
+ if (result.success) {
125
+ expect(result.value).toBe(false);
126
+ }
127
+ });
128
+
129
+ it("returns null for numeric comparison with undefined field", () => {
130
+ // Numeric comparisons DO return null when field is undefined
131
+ const result = evaluate(
132
+ "undefinedField > 5",
133
+ createContext({ data: {} })
134
+ );
135
+ expect(result.success).toBe(true);
136
+ if (result.success) {
137
+ expect(result.value).toBeNull();
138
+ }
139
+ });
140
+
141
+ it("returns null for string length of undefined field", () => {
142
+ const result = evaluate(
143
+ "string length(undefinedField)",
144
+ createContext({ data: {} })
145
+ );
146
+ expect(result.success).toBe(true);
147
+ if (result.success) {
148
+ expect(result.value).toBeNull();
149
+ }
150
+ });
151
+
152
+ it("returns null for string length comparison with undefined field", () => {
153
+ const result = evaluate(
154
+ "string length(undefinedField) > 0",
155
+ createContext({ data: {} })
156
+ );
157
+ expect(result.success).toBe(true);
158
+ if (result.success) {
159
+ expect(result.value).toBeNull();
160
+ }
161
+ });
162
+
163
+ it("returns null for count of undefined field", () => {
164
+ const result = evaluate(
165
+ "count(undefinedField) > 0",
166
+ createContext({ data: {} })
167
+ );
168
+ expect(result.success).toBe(true);
169
+ if (result.success) {
170
+ expect(result.value).toBeNull();
171
+ }
172
+ });
173
+
174
+ it("propagates null through logical AND with null operand", () => {
175
+ // null and true = null
176
+ const result = evaluate(
177
+ "null and true",
178
+ createContext({ data: {} })
179
+ );
180
+ expect(result.success).toBe(true);
181
+ if (result.success) {
182
+ expect(result.value).toBeNull();
183
+ }
184
+ });
185
+
186
+ it("returns null when computed field is null in AND expression with defined operand", () => {
187
+ // When computed.eligible is null and the other operand is defined,
188
+ // null propagates through AND
189
+ const result = evaluate(
190
+ "computed.eligible and x = true",
191
+ createContext({ data: { x: true }, computed: { eligible: null } })
192
+ );
193
+ expect(result.success).toBe(true);
194
+ if (result.success) {
195
+ expect(result.value).toBeNull();
196
+ }
197
+ });
198
+
199
+ it("returns false when computed field is null but other operand evaluates to false", () => {
200
+ // When the other operand is undefined, x = true returns false
201
+ // false and null = false (short-circuit)
202
+ const result = evaluate(
203
+ "computed.eligible and x = true",
204
+ createContext({ data: {}, computed: { eligible: null } })
205
+ );
206
+ expect(result.success).toBe(true);
207
+ if (result.success) {
208
+ expect(result.value).toBe(false);
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("error handling", () => {
214
+ it("returns error for invalid expression syntax", () => {
215
+ // Note: feelin may not always throw on syntax errors at evaluation
216
+ // It depends on the expression and context
217
+ const result = evaluate("@#$%", createContext());
218
+ // If it doesn't throw, we just check the result structure
219
+ expect(result).toHaveProperty("success");
220
+ });
221
+ });
222
+ });
223
+
224
+ // =============================================================================
225
+ // evaluateBoolean() Tests - Null Warning Scenarios
226
+ // =============================================================================
227
+
228
+ describe("evaluateBoolean", () => {
229
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
230
+
231
+ beforeEach(() => {
232
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
233
+ });
234
+
235
+ afterEach(() => {
236
+ consoleWarnSpy.mockRestore();
237
+ });
238
+
239
+ describe("normal boolean evaluation", () => {
240
+ it("returns true for true expression", () => {
241
+ const result = evaluateBoolean(
242
+ "age >= 18",
243
+ createContext({ data: { age: 21 } })
244
+ );
245
+ expect(result).toBe(true);
246
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("returns false for false expression", () => {
250
+ const result = evaluateBoolean(
251
+ "age >= 18",
252
+ createContext({ data: { age: 15 } })
253
+ );
254
+ expect(result).toBe(false);
255
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it("returns true for explicitly true boolean field", () => {
259
+ const result = evaluateBoolean(
260
+ "hasConsent = true",
261
+ createContext({ data: { hasConsent: true } })
262
+ );
263
+ expect(result).toBe(true);
264
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
265
+ });
266
+
267
+ it("returns false for explicitly false boolean field", () => {
268
+ const result = evaluateBoolean(
269
+ "hasConsent = true",
270
+ createContext({ data: { hasConsent: false } })
271
+ );
272
+ expect(result).toBe(false);
273
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
274
+ });
275
+ });
276
+
277
+ describe("null result warning scenarios", () => {
278
+ /**
279
+ * These tests verify that evaluateBoolean logs warnings when expressions
280
+ * return null. In feelin, null results occur from:
281
+ * - Numeric comparisons with undefined fields (>, <, >=, <=)
282
+ * - String/array functions on undefined (string length, count, sum)
283
+ * - Logical AND/OR with null operands
284
+ *
285
+ * Note: Equality checks (=, !=) on undefined return false, NOT null in feelin.
286
+ */
287
+
288
+ it("does NOT warn for equality check on undefined (feelin returns false)", () => {
289
+ // feelin returns false for undefined = true, so no warning
290
+ const result = evaluateBoolean(
291
+ "undefinedField = true",
292
+ createContext({ data: {} })
293
+ );
294
+
295
+ expect(result).toBe(false);
296
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("logs warning for numeric comparison with undefined field", () => {
300
+ // undefinedField > 5 returns null, triggering warning
301
+ const result = evaluateBoolean(
302
+ "undefinedField > 5",
303
+ createContext({ data: {} })
304
+ );
305
+
306
+ expect(result).toBe(false);
307
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
308
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
309
+ expect.stringContaining("returned null")
310
+ );
311
+ });
312
+
313
+ it("logs warning for string length comparison on undefined field", () => {
314
+ const result = evaluateBoolean(
315
+ "string length(undefinedField) > 0",
316
+ createContext({ data: {} })
317
+ );
318
+
319
+ expect(result).toBe(false);
320
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
321
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
322
+ expect.stringContaining("returned null")
323
+ );
324
+ });
325
+
326
+ it("logs warning for count comparison on undefined field", () => {
327
+ const result = evaluateBoolean(
328
+ "count(undefinedField) > 0",
329
+ createContext({ data: {} })
330
+ );
331
+
332
+ expect(result).toBe(false);
333
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
334
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
335
+ expect.stringContaining("returned null")
336
+ );
337
+ });
338
+
339
+ it("logs warning when computed field is null in AND expression with defined operand", () => {
340
+ // computed.eligible is null, AND propagates null when other operand is defined
341
+ const result = evaluateBoolean(
342
+ "computed.eligible and x = true",
343
+ createContext({ data: { x: true }, computed: { eligible: null } })
344
+ );
345
+
346
+ expect(result).toBe(false);
347
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
348
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
349
+ expect.stringContaining("returned null")
350
+ );
351
+ });
352
+
353
+ it("no warning when computed is null but other operand evaluates false first", () => {
354
+ // When x is undefined, x = true returns false
355
+ // feelin may short-circuit: false and null = false (no null in result)
356
+ const result = evaluateBoolean(
357
+ "computed.eligible and x = true",
358
+ createContext({ data: {}, computed: { eligible: null } })
359
+ );
360
+
361
+ expect(result).toBe(false);
362
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
363
+ });
364
+
365
+ it("logs warning for explicit null and true expression", () => {
366
+ const result = evaluateBoolean(
367
+ "null and true",
368
+ createContext({ data: {} })
369
+ );
370
+
371
+ expect(result).toBe(false);
372
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
373
+ });
374
+
375
+ it("warning message includes expression that caused it", () => {
376
+ evaluateBoolean(
377
+ "undefinedField > 5",
378
+ createContext({ data: {} })
379
+ );
380
+
381
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
382
+ expect.stringContaining("undefinedField > 5")
383
+ );
384
+ });
385
+
386
+ it("warning message includes null-safe pattern guidance", () => {
387
+ evaluateBoolean(
388
+ "undefinedField > 5",
389
+ createContext({ data: {} })
390
+ );
391
+
392
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
393
+ expect.stringContaining("null-safe patterns")
394
+ );
395
+ });
396
+ });
397
+
398
+ describe("non-boolean result handling", () => {
399
+ it("logs warning and returns false for numeric result", () => {
400
+ const result = evaluateBoolean(
401
+ "2 + 3",
402
+ createContext({ data: {} })
403
+ );
404
+
405
+ expect(result).toBe(false);
406
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
407
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
408
+ expect.stringContaining("did not return boolean")
409
+ );
410
+ });
411
+
412
+ it("logs warning and returns false for string result", () => {
413
+ const result = evaluateBoolean(
414
+ '"hello"',
415
+ createContext({ data: {} })
416
+ );
417
+
418
+ expect(result).toBe(false);
419
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
420
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
421
+ expect.stringContaining("did not return boolean")
422
+ );
423
+ });
424
+ });
425
+
426
+ describe("error handling", () => {
427
+ it("logs warning and returns false on evaluation error", () => {
428
+ // Force an error by using invalid function
429
+ const result = evaluateBoolean(
430
+ "nonExistentFunction(x)",
431
+ createContext({ data: {} })
432
+ );
433
+
434
+ expect(result).toBe(false);
435
+ // May log either error or null warning depending on feelin behavior
436
+ expect(consoleWarnSpy).toHaveBeenCalled();
437
+ });
438
+ });
439
+ });
440
+
441
+ // =============================================================================
442
+ // evaluateNumber() Tests
443
+ // =============================================================================
444
+
445
+ describe("evaluateNumber", () => {
446
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
447
+
448
+ beforeEach(() => {
449
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
450
+ });
451
+
452
+ afterEach(() => {
453
+ consoleWarnSpy.mockRestore();
454
+ });
455
+
456
+ it("returns number for valid arithmetic", () => {
457
+ const result = evaluateNumber("quantity * price", createContext({
458
+ data: { quantity: 5, price: 10 }
459
+ }));
460
+ expect(result).toBe(50);
461
+ });
462
+
463
+ it("returns null for non-numeric result", () => {
464
+ const result = evaluateNumber('"hello"', createContext());
465
+ expect(result).toBeNull();
466
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
467
+ expect.stringContaining("did not return number")
468
+ );
469
+ });
470
+
471
+ it("returns null on evaluation error", () => {
472
+ const result = evaluateNumber("invalidExpression((", createContext());
473
+ expect(result).toBeNull();
474
+ expect(consoleWarnSpy).toHaveBeenCalled();
475
+ });
476
+ });
477
+
478
+ // =============================================================================
479
+ // evaluateString() Tests
480
+ // =============================================================================
481
+
482
+ describe("evaluateString", () => {
483
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
484
+
485
+ beforeEach(() => {
486
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
487
+ });
488
+
489
+ afterEach(() => {
490
+ consoleWarnSpy.mockRestore();
491
+ });
492
+
493
+ it("returns string for valid string expression", () => {
494
+ const result = evaluateString(
495
+ 'if age >= 18 then "adult" else "minor"',
496
+ createContext({ data: { age: 21 } })
497
+ );
498
+ expect(result).toBe("adult");
499
+ });
500
+
501
+ it("returns null for non-string result", () => {
502
+ const result = evaluateString("2 + 3", createContext());
503
+ expect(result).toBeNull();
504
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
505
+ expect.stringContaining("did not return string")
506
+ );
507
+ });
508
+ });
509
+
510
+ // =============================================================================
511
+ // evaluateBooleanBatch() Tests
512
+ // =============================================================================
513
+
514
+ describe("evaluateBooleanBatch", () => {
515
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
516
+
517
+ beforeEach(() => {
518
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
519
+ });
520
+
521
+ afterEach(() => {
522
+ consoleWarnSpy.mockRestore();
523
+ });
524
+
525
+ it("evaluates multiple expressions at once", () => {
526
+ const results = evaluateBooleanBatch(
527
+ {
528
+ canVote: "age >= 18",
529
+ canDrive: "age >= 16",
530
+ canDrink: "age >= 21",
531
+ },
532
+ createContext({ data: { age: 20 } })
533
+ );
534
+
535
+ expect(results.canVote).toBe(true);
536
+ expect(results.canDrive).toBe(true);
537
+ expect(results.canDrink).toBe(false);
538
+ });
539
+
540
+ it("handles equality checks on undefined without warnings (feelin behavior)", () => {
541
+ // Equality checks return false in feelin, not null, so no warnings
542
+ const results = evaluateBooleanBatch(
543
+ {
544
+ visible: "undefinedField = true",
545
+ enabled: "anotherUndefined = false",
546
+ },
547
+ createContext({ data: {} })
548
+ );
549
+
550
+ expect(results.visible).toBe(false);
551
+ expect(results.enabled).toBe(false);
552
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
553
+ });
554
+
555
+ it("handles null results in batch with warnings", () => {
556
+ // Numeric comparisons return null, triggering warnings
557
+ const results = evaluateBooleanBatch(
558
+ {
559
+ hasEnoughItems: "count(items) > 5",
560
+ hasLongName: "string length(name) > 10",
561
+ },
562
+ createContext({ data: {} })
563
+ );
564
+
565
+ expect(results.hasEnoughItems).toBe(false);
566
+ expect(results.hasLongName).toBe(false);
567
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
568
+ });
569
+ });
570
+
571
+ // =============================================================================
572
+ // isValidExpression() Tests
573
+ // =============================================================================
574
+
575
+ describe("isValidExpression", () => {
576
+ it("returns true for valid simple expression", () => {
577
+ expect(isValidExpression("age >= 18")).toBe(true);
578
+ });
579
+
580
+ it("returns true for valid complex expression", () => {
581
+ expect(isValidExpression("x > 5 and y < 10 or z = true")).toBe(true);
582
+ });
583
+
584
+ it("returns true for valid function call", () => {
585
+ expect(isValidExpression("count(items) > 0")).toBe(true);
586
+ });
587
+
588
+ it("returns false for clearly invalid syntax", () => {
589
+ // Some syntax errors may only be caught at runtime
590
+ // Check for common invalid patterns
591
+ const result = isValidExpression("@#$%^&*");
592
+ // Result depends on feelin's behavior
593
+ expect(typeof result).toBe("boolean");
594
+ });
595
+ });
596
+
597
+ // =============================================================================
598
+ // validateExpression() Tests
599
+ // =============================================================================
600
+
601
+ describe("validateExpression", () => {
602
+ it("returns null for valid expression", () => {
603
+ expect(validateExpression("age >= 18")).toBeNull();
604
+ });
605
+
606
+ it("returns null for runtime errors (not parsing errors)", () => {
607
+ // Missing variable is a runtime error, not a parse error
608
+ // Should return null since it's syntactically valid
609
+ const result = validateExpression("missingVariable > 5");
610
+ expect(result).toBeNull();
611
+ });
612
+
613
+ it("returns error for syntax errors", () => {
614
+ // Force a parse error
615
+ const result = validateExpression("if then else");
616
+ // May or may not catch depending on feelin's behavior
617
+ expect(typeof result === "string" || result === null).toBe(true);
618
+ });
619
+ });
620
+
621
+ // =============================================================================
622
+ // Real-World Scenario Tests
623
+ // =============================================================================
624
+
625
+ describe("real-world scenarios", () => {
626
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
627
+
628
+ beforeEach(() => {
629
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
630
+ });
631
+
632
+ afterEach(() => {
633
+ consoleWarnSpy.mockRestore();
634
+ });
635
+
636
+ describe("clinical trial eligibility pattern", () => {
637
+ it("correctly evaluates when all inclusion criteria are met", () => {
638
+ const result = evaluateBoolean(
639
+ "inclusionAge = true and inclusionDiagnosis = true and inclusionHbA1c = true",
640
+ createContext({
641
+ data: {
642
+ inclusionAge: true,
643
+ inclusionDiagnosis: true,
644
+ inclusionHbA1c: true,
645
+ },
646
+ })
647
+ );
648
+
649
+ expect(result).toBe(true);
650
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it("correctly evaluates when any inclusion criteria is false", () => {
654
+ const result = evaluateBoolean(
655
+ "inclusionAge = true and inclusionDiagnosis = true and inclusionHbA1c = true",
656
+ createContext({
657
+ data: {
658
+ inclusionAge: true,
659
+ inclusionDiagnosis: false,
660
+ inclusionHbA1c: true,
661
+ },
662
+ })
663
+ );
664
+
665
+ expect(result).toBe(false);
666
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
667
+ });
668
+
669
+ it("returns false without warning when field is undefined (feelin equality behavior)", () => {
670
+ // In feelin, undefined = true returns false, not null
671
+ // This means no warning is triggered, but result is still false
672
+ const result = evaluateBoolean(
673
+ "inclusionAge = true and inclusionDiagnosis = true and inclusionHbA1c = true",
674
+ createContext({
675
+ data: {
676
+ inclusionAge: true,
677
+ // inclusionDiagnosis not yet answered
678
+ inclusionHbA1c: true,
679
+ },
680
+ })
681
+ );
682
+
683
+ expect(result).toBe(false);
684
+ // No warning because feelin returns false for undefined = true
685
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
686
+ });
687
+
688
+ it("null-safe pattern detects unanswered fields (feelin behavior)", () => {
689
+ // In feelin, equality check on undefined returns false
690
+ // So (undefined = true or undefined = false) = (false or false) = false
691
+ const allAnswered = evaluateBoolean(
692
+ "(inclusionAge = true or inclusionAge = false) and (inclusionDiagnosis = true or inclusionDiagnosis = false)",
693
+ createContext({
694
+ data: {
695
+ inclusionAge: true,
696
+ // inclusionDiagnosis not yet answered
697
+ },
698
+ })
699
+ );
700
+
701
+ expect(allAnswered).toBe(false);
702
+ // No warning because equality checks don't return null in feelin
703
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
704
+ });
705
+
706
+ it("!= true on undefined returns true in feelin", () => {
707
+ // In feelin, undefined != true returns true
708
+ // This is useful for checking "not true" conditions
709
+ const result = evaluateBoolean(
710
+ "signingOnBehalf != true",
711
+ createContext({
712
+ data: {
713
+ // signingOnBehalf not yet answered
714
+ },
715
+ })
716
+ );
717
+
718
+ // feelin returns true for undefined != true
719
+ expect(result).toBe(true);
720
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
721
+ });
722
+ });
723
+
724
+ describe("page visibility with computed dependencies", () => {
725
+ it("correctly shows page when computed eligibility is true", () => {
726
+ const result = evaluateBoolean(
727
+ "computed.eligible = true",
728
+ createContext({
729
+ data: {},
730
+ computed: { eligible: true },
731
+ })
732
+ );
733
+
734
+ expect(result).toBe(true);
735
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
736
+ });
737
+
738
+ it("correctly hides page when computed eligibility is false", () => {
739
+ const result = evaluateBoolean(
740
+ "computed.eligible = true",
741
+ createContext({
742
+ data: {},
743
+ computed: { eligible: false },
744
+ })
745
+ );
746
+
747
+ expect(result).toBe(false);
748
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
749
+ });
750
+
751
+ it("returns false without warning when computed is null (feelin equality behavior)", () => {
752
+ // In feelin, null = true returns false, not null
753
+ const result = evaluateBoolean(
754
+ "computed.eligible = true",
755
+ createContext({
756
+ data: {},
757
+ computed: { eligible: null },
758
+ })
759
+ );
760
+
761
+ expect(result).toBe(false);
762
+ // No warning because equality check returns false, not null
763
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
764
+ });
765
+
766
+ it("warns when computed field is used in AND with defined operand (null propagates)", () => {
767
+ // Using computed field directly in AND expression propagates null
768
+ // when the other operand is defined and evaluates to true
769
+ const result = evaluateBoolean(
770
+ "computed.eligible and otherCondition = true",
771
+ createContext({
772
+ data: { otherCondition: true },
773
+ computed: { eligible: null },
774
+ })
775
+ );
776
+
777
+ expect(result).toBe(false);
778
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
779
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
780
+ expect.stringContaining("returned null")
781
+ );
782
+ });
783
+
784
+ it("no warning when computed null and other operand undefined (short-circuit to false)", () => {
785
+ // When other operand is undefined, feelin returns false immediately
786
+ const result = evaluateBoolean(
787
+ "computed.eligible and otherCondition = true",
788
+ createContext({
789
+ data: {},
790
+ computed: { eligible: null },
791
+ })
792
+ );
793
+
794
+ expect(result).toBe(false);
795
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
796
+ });
797
+ });
798
+
799
+ describe("string validation scenarios", () => {
800
+ it("warns when validating string length of undefined field", () => {
801
+ // This is a common pattern that triggers warnings
802
+ const result = evaluateBoolean(
803
+ "string length(signature) > 0",
804
+ createContext({ data: {} })
805
+ );
806
+
807
+ expect(result).toBe(false);
808
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
809
+ });
810
+
811
+ it("no warning when field has value", () => {
812
+ const result = evaluateBoolean(
813
+ "string length(signature) > 0",
814
+ createContext({ data: { signature: "John Doe" } })
815
+ );
816
+
817
+ expect(result).toBe(true);
818
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
819
+ });
820
+
821
+ it("no warning when null check precedes string length", () => {
822
+ // Proper null-safe pattern: check != null first
823
+ const result = evaluateBoolean(
824
+ "signature != null and string length(signature) > 0",
825
+ createContext({ data: {} })
826
+ );
827
+
828
+ expect(result).toBe(false);
829
+ // The != null check short-circuits, so string length is not evaluated
830
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
831
+ });
832
+ });
833
+ });