@fogpipe/forma-core 0.8.2 → 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.
- package/dist/{chunk-VHKUCCAC.js → chunk-KUZ3NPM4.js} +2 -2
- package/dist/{chunk-QTLXVG6P.js → chunk-U2OXXFEH.js} +5 -1
- package/dist/chunk-U2OXXFEH.js.map +1 -0
- package/dist/engine/index.cjs +4 -0
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/feel/index.cjs +4 -0
- package/dist/feel/index.cjs.map +1 -1
- package/dist/feel/index.d.ts.map +1 -1
- package/dist/feel/index.js +1 -1
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/src/__tests__/feel.test.ts +833 -0
- package/src/feel/index.ts +14 -0
- package/dist/chunk-QTLXVG6P.js.map +0 -1
- /package/dist/{chunk-VHKUCCAC.js.map → chunk-KUZ3NPM4.js.map} +0 -0
|
@@ -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
|
+
});
|