@the-trybe/formula-engine 1.0.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/.claude/settings.local.json +6 -0
- package/PRD_FORMULA_ENGINE.md +1863 -0
- package/README.md +382 -0
- package/dist/decimal-utils.d.ts +180 -0
- package/dist/decimal-utils.js +355 -0
- package/dist/dependency-extractor.d.ts +20 -0
- package/dist/dependency-extractor.js +103 -0
- package/dist/dependency-graph.d.ts +60 -0
- package/dist/dependency-graph.js +252 -0
- package/dist/errors.d.ts +161 -0
- package/dist/errors.js +260 -0
- package/dist/evaluator.d.ts +51 -0
- package/dist/evaluator.js +494 -0
- package/dist/formula-engine.d.ts +79 -0
- package/dist/formula-engine.js +355 -0
- package/dist/functions.d.ts +3 -0
- package/dist/functions.js +720 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +61 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +357 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.js +372 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +62 -0
- package/jest.config.js +23 -0
- package/package.json +35 -0
- package/src/decimal-utils.ts +408 -0
- package/src/dependency-extractor.ts +117 -0
- package/src/dependency-graph.test.ts +238 -0
- package/src/dependency-graph.ts +288 -0
- package/src/errors.ts +296 -0
- package/src/evaluator.ts +604 -0
- package/src/formula-engine.test.ts +660 -0
- package/src/formula-engine.ts +430 -0
- package/src/functions.ts +770 -0
- package/src/index.ts +103 -0
- package/src/lexer.test.ts +288 -0
- package/src/lexer.ts +394 -0
- package/src/parser.test.ts +349 -0
- package/src/parser.ts +449 -0
- package/src/types.ts +347 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { FormulaEngine } from './formula-engine';
|
|
2
|
+
import { FormulaDefinition, EvaluationContext } from './types';
|
|
3
|
+
import { Decimal } from './decimal-utils';
|
|
4
|
+
import {
|
|
5
|
+
CircularDependencyError,
|
|
6
|
+
DivisionByZeroError,
|
|
7
|
+
UndefinedVariableError,
|
|
8
|
+
UndefinedFunctionError,
|
|
9
|
+
} from './errors';
|
|
10
|
+
|
|
11
|
+
describe('FormulaEngine', () => {
|
|
12
|
+
let engine: FormulaEngine;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
engine = new FormulaEngine();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('Basic Evaluation', () => {
|
|
19
|
+
it('should evaluate numeric literals', () => {
|
|
20
|
+
const result = engine.evaluate('42', { variables: {} });
|
|
21
|
+
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
expect(result.value).toBeInstanceOf(Decimal);
|
|
24
|
+
expect((result.value as Decimal).toString()).toBe('42');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should evaluate string literals', () => {
|
|
28
|
+
const result = engine.evaluate('"hello"', { variables: {} });
|
|
29
|
+
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
expect(result.value).toBe('hello');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should evaluate boolean literals', () => {
|
|
35
|
+
expect(engine.evaluate('true', { variables: {} }).value).toBe(true);
|
|
36
|
+
expect(engine.evaluate('false', { variables: {} }).value).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should evaluate null literal', () => {
|
|
40
|
+
const result = engine.evaluate('null', { variables: {} });
|
|
41
|
+
|
|
42
|
+
expect(result.value).toBe(null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should evaluate variables', () => {
|
|
46
|
+
const result = engine.evaluate('$price', {
|
|
47
|
+
variables: { price: 100 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
expect((result.value as Decimal).toNumber()).toBe(100);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should evaluate context variables', () => {
|
|
55
|
+
const result = engine.evaluate('@userId', {
|
|
56
|
+
variables: {},
|
|
57
|
+
extra: { userId: 'user123' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result.value).toBe('user123');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('Arithmetic Operations', () => {
|
|
65
|
+
it('should evaluate addition', () => {
|
|
66
|
+
const result = engine.evaluate('$a + $b', {
|
|
67
|
+
variables: { a: 10, b: 5 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect((result.value as Decimal).toNumber()).toBe(15);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should evaluate subtraction', () => {
|
|
74
|
+
const result = engine.evaluate('$a - $b', {
|
|
75
|
+
variables: { a: 10, b: 5 },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect((result.value as Decimal).toNumber()).toBe(5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should evaluate multiplication', () => {
|
|
82
|
+
const result = engine.evaluate('$a * $b', {
|
|
83
|
+
variables: { a: 10, b: 5 },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect((result.value as Decimal).toNumber()).toBe(50);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should evaluate division', () => {
|
|
90
|
+
const result = engine.evaluate('$a / $b', {
|
|
91
|
+
variables: { a: 10, b: 5 },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect((result.value as Decimal).toNumber()).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should evaluate modulo', () => {
|
|
98
|
+
const result = engine.evaluate('$a % $b', {
|
|
99
|
+
variables: { a: 10, b: 3 },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect((result.value as Decimal).toNumber()).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should evaluate power', () => {
|
|
106
|
+
const result = engine.evaluate('$a ^ $b', {
|
|
107
|
+
variables: { a: 2, b: 3 },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect((result.value as Decimal).toNumber()).toBe(8);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should throw on division by zero', () => {
|
|
114
|
+
const result = engine.evaluate('$a / $b', {
|
|
115
|
+
variables: { a: 10, b: 0 },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result.success).toBe(false);
|
|
119
|
+
expect(result.error).toBeInstanceOf(DivisionByZeroError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should handle operator precedence', () => {
|
|
123
|
+
const result = engine.evaluate('$a + $b * $c', {
|
|
124
|
+
variables: { a: 2, b: 3, c: 4 },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect((result.value as Decimal).toNumber()).toBe(14); // 2 + (3*4) = 14
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should handle parentheses', () => {
|
|
131
|
+
const result = engine.evaluate('($a + $b) * $c', {
|
|
132
|
+
variables: { a: 2, b: 3, c: 4 },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect((result.value as Decimal).toNumber()).toBe(20); // (2+3)*4 = 20
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Decimal Precision', () => {
|
|
140
|
+
it('should handle 0.1 + 0.2 correctly', () => {
|
|
141
|
+
const result = engine.evaluate('0.1 + 0.2', { variables: {} });
|
|
142
|
+
|
|
143
|
+
expect((result.value as Decimal).toString()).toBe('0.3');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should handle financial calculations', () => {
|
|
147
|
+
const result = engine.evaluate('$price * $quantity', {
|
|
148
|
+
variables: { price: 19.99, quantity: 3 },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect((result.value as Decimal).toString()).toBe('59.97');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle complex decimal calculations', () => {
|
|
155
|
+
const result = engine.evaluate('1000.10 - 1000.00', { variables: {} });
|
|
156
|
+
|
|
157
|
+
expect((result.value as Decimal).toString()).toBe('0.1');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('Comparison Operations', () => {
|
|
162
|
+
it('should evaluate equality', () => {
|
|
163
|
+
expect(engine.evaluate('$a == $b', { variables: { a: 5, b: 5 } }).value).toBe(true);
|
|
164
|
+
expect(engine.evaluate('$a == $b', { variables: { a: 5, b: 6 } }).value).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should evaluate inequality', () => {
|
|
168
|
+
expect(engine.evaluate('$a != $b', { variables: { a: 5, b: 6 } }).value).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should evaluate less than', () => {
|
|
172
|
+
expect(engine.evaluate('$a < $b', { variables: { a: 5, b: 6 } }).value).toBe(true);
|
|
173
|
+
expect(engine.evaluate('$a < $b', { variables: { a: 6, b: 5 } }).value).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should evaluate greater than', () => {
|
|
177
|
+
expect(engine.evaluate('$a > $b', { variables: { a: 6, b: 5 } }).value).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should evaluate less than or equal', () => {
|
|
181
|
+
expect(engine.evaluate('$a <= $b', { variables: { a: 5, b: 5 } }).value).toBe(true);
|
|
182
|
+
expect(engine.evaluate('$a <= $b', { variables: { a: 5, b: 6 } }).value).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should evaluate greater than or equal', () => {
|
|
186
|
+
expect(engine.evaluate('$a >= $b', { variables: { a: 5, b: 5 } }).value).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('Logical Operations', () => {
|
|
191
|
+
it('should evaluate AND', () => {
|
|
192
|
+
expect(engine.evaluate('$a && $b', { variables: { a: true, b: true } }).value).toBe(true);
|
|
193
|
+
expect(engine.evaluate('$a && $b', { variables: { a: true, b: false } }).value).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should evaluate OR', () => {
|
|
197
|
+
expect(engine.evaluate('$a || $b', { variables: { a: false, b: true } }).value).toBe(true);
|
|
198
|
+
expect(engine.evaluate('$a || $b', { variables: { a: false, b: false } }).value).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should evaluate NOT', () => {
|
|
202
|
+
expect(engine.evaluate('!$a', { variables: { a: true } }).value).toBe(false);
|
|
203
|
+
expect(engine.evaluate('!$a', { variables: { a: false } }).value).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should short-circuit AND', () => {
|
|
207
|
+
// If first operand is false, second shouldn't be evaluated
|
|
208
|
+
const result = engine.evaluate('false && $undefined', { variables: {} });
|
|
209
|
+
expect(result.value).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should short-circuit OR', () => {
|
|
213
|
+
// If first operand is true, second shouldn't be evaluated
|
|
214
|
+
const result = engine.evaluate('true || $undefined', { variables: {} });
|
|
215
|
+
expect(result.value).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('Conditional Expressions', () => {
|
|
220
|
+
it('should evaluate ternary when true', () => {
|
|
221
|
+
const result = engine.evaluate('$a > 5 ? "big" : "small"', {
|
|
222
|
+
variables: { a: 10 },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.value).toBe('big');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should evaluate ternary when false', () => {
|
|
229
|
+
const result = engine.evaluate('$a > 5 ? "big" : "small"', {
|
|
230
|
+
variables: { a: 3 },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(result.value).toBe('small');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle nested ternary', () => {
|
|
237
|
+
const result = engine.evaluate(
|
|
238
|
+
'$score >= 90 ? "A" : ($score >= 80 ? "B" : "C")',
|
|
239
|
+
{ variables: { score: 85 } }
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(result.value).toBe('B');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('Built-in Functions', () => {
|
|
247
|
+
describe('Math Functions', () => {
|
|
248
|
+
it('should evaluate ABS', () => {
|
|
249
|
+
expect((engine.evaluate('ABS(-5)', { variables: {} }).value as Decimal).toNumber()).toBe(5);
|
|
250
|
+
expect((engine.evaluate('ABS(5)', { variables: {} }).value as Decimal).toNumber()).toBe(5);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should evaluate ROUND', () => {
|
|
254
|
+
const result = engine.evaluate('ROUND(3.456, 2)', { variables: {} });
|
|
255
|
+
expect((result.value as Decimal).toString()).toBe('3.46');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should evaluate FLOOR', () => {
|
|
259
|
+
const result = engine.evaluate('FLOOR(3.9)', { variables: {} });
|
|
260
|
+
expect((result.value as Decimal).toNumber()).toBe(3);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should evaluate CEIL', () => {
|
|
264
|
+
const result = engine.evaluate('CEIL(3.1)', { variables: {} });
|
|
265
|
+
expect((result.value as Decimal).toNumber()).toBe(4);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should evaluate MIN', () => {
|
|
269
|
+
const result = engine.evaluate('MIN(5, 3, 8)', { variables: {} });
|
|
270
|
+
expect((result.value as Decimal).toNumber()).toBe(3);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should evaluate MAX', () => {
|
|
274
|
+
const result = engine.evaluate('MAX(5, 3, 8)', { variables: {} });
|
|
275
|
+
expect((result.value as Decimal).toNumber()).toBe(8);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should evaluate POW', () => {
|
|
279
|
+
const result = engine.evaluate('POW(2, 3)', { variables: {} });
|
|
280
|
+
expect((result.value as Decimal).toNumber()).toBe(8);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should evaluate SQRT', () => {
|
|
284
|
+
const result = engine.evaluate('SQRT(16)', { variables: {} });
|
|
285
|
+
expect((result.value as Decimal).toNumber()).toBe(4);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('String Functions', () => {
|
|
290
|
+
it('should evaluate LEN', () => {
|
|
291
|
+
expect(engine.evaluate('LEN("hello")', { variables: {} }).value).toBe(5);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should evaluate UPPER', () => {
|
|
295
|
+
expect(engine.evaluate('UPPER("hello")', { variables: {} }).value).toBe('HELLO');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should evaluate LOWER', () => {
|
|
299
|
+
expect(engine.evaluate('LOWER("HELLO")', { variables: {} }).value).toBe('hello');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should evaluate TRIM', () => {
|
|
303
|
+
expect(engine.evaluate('TRIM(" hello ")', { variables: {} }).value).toBe('hello');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should evaluate CONCAT', () => {
|
|
307
|
+
expect(engine.evaluate('CONCAT("a", "b", "c")', { variables: {} }).value).toBe('abc');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should evaluate SUBSTR', () => {
|
|
311
|
+
expect(engine.evaluate('SUBSTR("hello", 1, 3)', { variables: {} }).value).toBe('ell');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should evaluate CONTAINS', () => {
|
|
315
|
+
expect(engine.evaluate('CONTAINS("hello", "ell")', { variables: {} }).value).toBe(true);
|
|
316
|
+
expect(engine.evaluate('CONTAINS("hello", "xyz")', { variables: {} }).value).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('Logical Functions', () => {
|
|
321
|
+
it('should evaluate IF', () => {
|
|
322
|
+
expect(engine.evaluate('IF(true, "yes", "no")', { variables: {} }).value).toBe('yes');
|
|
323
|
+
expect(engine.evaluate('IF(false, "yes", "no")', { variables: {} }).value).toBe('no');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should evaluate COALESCE', () => {
|
|
327
|
+
expect(engine.evaluate('COALESCE(null, null, 5)', { variables: {} }).value)
|
|
328
|
+
.toBeInstanceOf(Decimal);
|
|
329
|
+
expect((engine.evaluate('COALESCE(null, null, 5)', { variables: {} }).value as Decimal).toNumber())
|
|
330
|
+
.toBe(5);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should evaluate ISNULL', () => {
|
|
334
|
+
expect(engine.evaluate('ISNULL(null)', { variables: {} }).value).toBe(true);
|
|
335
|
+
expect(engine.evaluate('ISNULL(5)', { variables: {} }).value).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should evaluate DEFAULT', () => {
|
|
339
|
+
const result = engine.evaluate('DEFAULT(null, 10)', { variables: {} });
|
|
340
|
+
expect((result.value as Decimal).toNumber()).toBe(10);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('Type Functions', () => {
|
|
345
|
+
it('should evaluate TYPEOF', () => {
|
|
346
|
+
expect(engine.evaluate('TYPEOF(42)', { variables: {} }).value).toBe('decimal');
|
|
347
|
+
expect(engine.evaluate('TYPEOF("hello")', { variables: {} }).value).toBe('string');
|
|
348
|
+
expect(engine.evaluate('TYPEOF(true)', { variables: {} }).value).toBe('boolean');
|
|
349
|
+
expect(engine.evaluate('TYPEOF(null)', { variables: {} }).value).toBe('null');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should evaluate STRING', () => {
|
|
353
|
+
expect(engine.evaluate('STRING(42)', { variables: {} }).value).toBe('42');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should evaluate NUMBER', () => {
|
|
357
|
+
const result = engine.evaluate('NUMBER("42")', { variables: {} });
|
|
358
|
+
expect((result.value as Decimal).toNumber()).toBe(42);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('Array Functions', () => {
|
|
363
|
+
it('should evaluate COUNT', () => {
|
|
364
|
+
expect(engine.evaluate('COUNT($arr)', { variables: { arr: [1, 2, 3, 4, 5] } }).value).toBe(5);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should evaluate FIRST', () => {
|
|
368
|
+
const result = engine.evaluate('FIRST($arr)', { variables: { arr: [1, 2, 3] } });
|
|
369
|
+
expect((result.value as Decimal).toNumber()).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should evaluate LAST', () => {
|
|
373
|
+
const result = engine.evaluate('LAST($arr)', { variables: { arr: [1, 2, 3] } });
|
|
374
|
+
expect((result.value as Decimal).toNumber()).toBe(3);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should evaluate SUM', () => {
|
|
378
|
+
const result = engine.evaluate('SUM($arr)', { variables: { arr: [1, 2, 3, 4, 5] } });
|
|
379
|
+
expect((result.value as Decimal).toNumber()).toBe(15);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should evaluate AVG', () => {
|
|
383
|
+
const result = engine.evaluate('AVG($arr)', { variables: { arr: [10, 20, 30] } });
|
|
384
|
+
expect((result.value as Decimal).toNumber()).toBe(20);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should evaluate SUM with expression', () => {
|
|
388
|
+
const result = engine.evaluate('SUM($items, $it.price * $it.qty)', {
|
|
389
|
+
variables: {
|
|
390
|
+
items: [
|
|
391
|
+
{ price: 10, qty: 2 },
|
|
392
|
+
{ price: 20, qty: 1 },
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
expect((result.value as Decimal).toNumber()).toBe(40);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should evaluate FILTER', () => {
|
|
400
|
+
const result = engine.evaluate('FILTER($arr, $it > 2)', {
|
|
401
|
+
variables: { arr: [1, 2, 3, 4, 5] },
|
|
402
|
+
});
|
|
403
|
+
expect(result.value).toEqual([
|
|
404
|
+
expect.any(Decimal),
|
|
405
|
+
expect.any(Decimal),
|
|
406
|
+
expect.any(Decimal),
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should evaluate MAP', () => {
|
|
411
|
+
const result = engine.evaluate('MAP($arr, $it * 2)', {
|
|
412
|
+
variables: { arr: [1, 2, 3] },
|
|
413
|
+
});
|
|
414
|
+
expect((result.value as Decimal[])[0].toNumber()).toBe(2);
|
|
415
|
+
expect((result.value as Decimal[])[1].toNumber()).toBe(4);
|
|
416
|
+
expect((result.value as Decimal[])[2].toNumber()).toBe(6);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('Member Access', () => {
|
|
422
|
+
it('should access object properties', () => {
|
|
423
|
+
const result = engine.evaluate('$product.price', {
|
|
424
|
+
variables: { product: { price: 100 } },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect((result.value as Decimal).toNumber()).toBe(100);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should access nested properties', () => {
|
|
431
|
+
const result = engine.evaluate('$customer.address.city', {
|
|
432
|
+
variables: {
|
|
433
|
+
customer: {
|
|
434
|
+
address: { city: 'New York' },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(result.value).toBe('New York');
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('Index Access', () => {
|
|
444
|
+
it('should access array by index', () => {
|
|
445
|
+
const result = engine.evaluate('$items[1]', {
|
|
446
|
+
variables: { items: [10, 20, 30] },
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect((result.value as Decimal).toNumber()).toBe(20);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should access object by key', () => {
|
|
453
|
+
const result = engine.evaluate('$data["name"]', {
|
|
454
|
+
variables: { data: { name: 'John' } },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(result.value).toBe('John');
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('Batch Evaluation', () => {
|
|
462
|
+
it('should evaluate formulas in dependency order', () => {
|
|
463
|
+
const formulas: FormulaDefinition[] = [
|
|
464
|
+
{ id: 'basePrice', expression: '$unitPrice * $quantity' },
|
|
465
|
+
{ id: 'discount', expression: '$basePrice * $discountRate' },
|
|
466
|
+
{ id: 'total', expression: '$basePrice - $discount' },
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
const context: EvaluationContext = {
|
|
470
|
+
variables: {
|
|
471
|
+
unitPrice: 100,
|
|
472
|
+
quantity: 5,
|
|
473
|
+
discountRate: 0.1,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const results = engine.evaluateAll(formulas, context);
|
|
478
|
+
|
|
479
|
+
expect(results.success).toBe(true);
|
|
480
|
+
expect(results.evaluationOrder).toEqual(['basePrice', 'discount', 'total']);
|
|
481
|
+
expect((results.results.get('basePrice')?.value as Decimal).toNumber()).toBe(500);
|
|
482
|
+
expect((results.results.get('discount')?.value as Decimal).toNumber()).toBe(50);
|
|
483
|
+
expect((results.results.get('total')?.value as Decimal).toNumber()).toBe(450);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should detect circular dependencies', () => {
|
|
487
|
+
const formulas: FormulaDefinition[] = [
|
|
488
|
+
{ id: 'a', expression: '$b + 1' },
|
|
489
|
+
{ id: 'b', expression: '$c + 1' },
|
|
490
|
+
{ id: 'c', expression: '$a + 1' },
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
494
|
+
|
|
495
|
+
expect(results.success).toBe(false);
|
|
496
|
+
expect(results.errors[0]).toBeInstanceOf(CircularDependencyError);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should handle complex dependency chains', () => {
|
|
500
|
+
const formulas: FormulaDefinition[] = [
|
|
501
|
+
{ id: 'gross', expression: '$unitPrice * $quantity' },
|
|
502
|
+
{ id: 'discount', expression: '$gross * $discountRate' },
|
|
503
|
+
{ id: 'net', expression: '$gross - $discount' },
|
|
504
|
+
{ id: 'tax', expression: '$net * $taxRate' },
|
|
505
|
+
{ id: 'total', expression: '$net + $tax' },
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
const context: EvaluationContext = {
|
|
509
|
+
variables: {
|
|
510
|
+
unitPrice: 100,
|
|
511
|
+
quantity: 5,
|
|
512
|
+
discountRate: 0.1,
|
|
513
|
+
taxRate: 0.2,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const results = engine.evaluateAll(formulas, context);
|
|
518
|
+
|
|
519
|
+
expect(results.success).toBe(true);
|
|
520
|
+
expect((results.results.get('gross')?.value as Decimal).toNumber()).toBe(500);
|
|
521
|
+
expect((results.results.get('discount')?.value as Decimal).toNumber()).toBe(50);
|
|
522
|
+
expect((results.results.get('net')?.value as Decimal).toNumber()).toBe(450);
|
|
523
|
+
expect((results.results.get('tax')?.value as Decimal).toNumber()).toBe(90);
|
|
524
|
+
expect((results.results.get('total')?.value as Decimal).toNumber()).toBe(540);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe('Validation', () => {
|
|
529
|
+
it('should validate formulas without evaluating', () => {
|
|
530
|
+
const formulas: FormulaDefinition[] = [
|
|
531
|
+
{ id: 'a', expression: '$b + 1' },
|
|
532
|
+
{ id: 'b', expression: '$c + 1' },
|
|
533
|
+
];
|
|
534
|
+
|
|
535
|
+
const result = engine.validate(formulas);
|
|
536
|
+
|
|
537
|
+
expect(result.valid).toBe(true);
|
|
538
|
+
expect(result.evaluationOrder).toEqual(['b', 'a']);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should detect circular dependencies in validation', () => {
|
|
542
|
+
const formulas: FormulaDefinition[] = [
|
|
543
|
+
{ id: 'a', expression: '$b + 1' },
|
|
544
|
+
{ id: 'b', expression: '$a + 1' },
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const result = engine.validate(formulas);
|
|
548
|
+
|
|
549
|
+
expect(result.valid).toBe(false);
|
|
550
|
+
expect(result.errors).toHaveLength(1);
|
|
551
|
+
expect(result.errors[0]).toBeInstanceOf(CircularDependencyError);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should detect syntax errors in validation', () => {
|
|
555
|
+
const formulas: FormulaDefinition[] = [
|
|
556
|
+
{ id: 'a', expression: '$b +' },
|
|
557
|
+
];
|
|
558
|
+
|
|
559
|
+
const result = engine.validate(formulas);
|
|
560
|
+
|
|
561
|
+
expect(result.valid).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('Custom Functions', () => {
|
|
566
|
+
it('should register and use custom functions', () => {
|
|
567
|
+
engine.registerFunction({
|
|
568
|
+
name: 'DOUBLE',
|
|
569
|
+
minArgs: 1,
|
|
570
|
+
maxArgs: 1,
|
|
571
|
+
returnType: 'decimal',
|
|
572
|
+
implementation: (args) => {
|
|
573
|
+
const val = args[0] as Decimal;
|
|
574
|
+
return val.times(2);
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const result = engine.evaluate('DOUBLE($x)', { variables: { x: 5 } });
|
|
579
|
+
|
|
580
|
+
expect((result.value as Decimal).toNumber()).toBe(10);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('should throw on undefined function', () => {
|
|
584
|
+
const result = engine.evaluate('UNKNOWN($x)', { variables: { x: 5 } });
|
|
585
|
+
|
|
586
|
+
expect(result.success).toBe(false);
|
|
587
|
+
expect(result.error).toBeInstanceOf(UndefinedFunctionError);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe('Error Handling', () => {
|
|
592
|
+
it('should handle undefined variables in strict mode', () => {
|
|
593
|
+
const strictEngine = new FormulaEngine({ strictMode: true });
|
|
594
|
+
const result = strictEngine.evaluate('$undefined', { variables: {} });
|
|
595
|
+
|
|
596
|
+
expect(result.success).toBe(false);
|
|
597
|
+
expect(result.error).toBeInstanceOf(UndefinedVariableError);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should return null for undefined variables in non-strict mode', () => {
|
|
601
|
+
const lenientEngine = new FormulaEngine({ strictMode: false });
|
|
602
|
+
const result = lenientEngine.evaluate('$undefined', { variables: {} });
|
|
603
|
+
|
|
604
|
+
expect(result.success).toBe(true);
|
|
605
|
+
expect(result.value).toBe(null);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should handle error behavior in batch evaluation', () => {
|
|
609
|
+
const formulas: FormulaDefinition[] = [
|
|
610
|
+
{
|
|
611
|
+
id: 'result',
|
|
612
|
+
expression: '$a / $b',
|
|
613
|
+
onError: { type: 'ZERO' },
|
|
614
|
+
},
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
const results = engine.evaluateAll(formulas, {
|
|
618
|
+
variables: { a: 10, b: 0 },
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect((results.results.get('result')?.value as Decimal).isZero()).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe('Caching', () => {
|
|
626
|
+
it('should cache parsed expressions', () => {
|
|
627
|
+
const expression = '$a + $b';
|
|
628
|
+
|
|
629
|
+
// Parse twice
|
|
630
|
+
engine.parse(expression);
|
|
631
|
+
engine.parse(expression);
|
|
632
|
+
|
|
633
|
+
const stats = engine.getCacheStats();
|
|
634
|
+
expect(stats.hits).toBe(1);
|
|
635
|
+
expect(stats.misses).toBe(1);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('should clear cache', () => {
|
|
639
|
+
engine.parse('$a + $b');
|
|
640
|
+
engine.clearCache();
|
|
641
|
+
|
|
642
|
+
const stats = engine.getCacheStats();
|
|
643
|
+
expect(stats.size).toBe(0);
|
|
644
|
+
expect(stats.hits).toBe(0);
|
|
645
|
+
expect(stats.misses).toBe(0);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('String Concatenation', () => {
|
|
650
|
+
it('should concatenate strings with + operator', () => {
|
|
651
|
+
const result = engine.evaluate('"Hello" + " " + "World"', { variables: {} });
|
|
652
|
+
expect(result.value).toBe('Hello World');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('should concatenate string with number', () => {
|
|
656
|
+
const result = engine.evaluate('"Value: " + $x', { variables: { x: 42 } });
|
|
657
|
+
expect(result.value).toBe('Value: 42');
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
});
|