@objectql/core 1.8.4 → 1.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/CHANGELOG.md +17 -0
- package/dist/formula-engine.d.ts +95 -0
- package/dist/formula-engine.js +426 -0
- package/dist/formula-engine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/repository.d.ts +6 -0
- package/dist/repository.js +65 -2
- package/dist/repository.js.map +1 -1
- package/package.json +2 -2
- package/src/formula-engine.ts +564 -0
- package/src/index.ts +1 -0
- package/src/repository.ts +80 -3
- package/test/formula-engine.test.ts +717 -0
- package/test/formula-integration.test.ts +278 -0
- package/test/mock-driver.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formula Engine Tests
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive test suite for the FormulaEngine class
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { FormulaEngine } from '../src/formula-engine';
|
|
8
|
+
import {
|
|
9
|
+
FormulaContext,
|
|
10
|
+
} from '@objectql/types';
|
|
11
|
+
|
|
12
|
+
describe('FormulaEngine', () => {
|
|
13
|
+
let engine: FormulaEngine;
|
|
14
|
+
let baseContext: FormulaContext;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
engine = new FormulaEngine();
|
|
18
|
+
|
|
19
|
+
// Create a base context for testing
|
|
20
|
+
const now = new Date('2026-01-15T12:30:45Z');
|
|
21
|
+
baseContext = {
|
|
22
|
+
record: {
|
|
23
|
+
name: 'Test User',
|
|
24
|
+
quantity: 10,
|
|
25
|
+
unit_price: 25.5,
|
|
26
|
+
discount_rate: 0.1,
|
|
27
|
+
is_active: true,
|
|
28
|
+
created_at: new Date('2026-01-01'),
|
|
29
|
+
},
|
|
30
|
+
system: {
|
|
31
|
+
today: new Date('2026-01-15'),
|
|
32
|
+
now: now,
|
|
33
|
+
year: 2026,
|
|
34
|
+
month: 1,
|
|
35
|
+
day: 15,
|
|
36
|
+
hour: 12,
|
|
37
|
+
minute: 30,
|
|
38
|
+
second: 45,
|
|
39
|
+
},
|
|
40
|
+
current_user: {
|
|
41
|
+
id: 'user-123',
|
|
42
|
+
name: 'John Doe',
|
|
43
|
+
email: 'john@example.com',
|
|
44
|
+
role: 'admin',
|
|
45
|
+
},
|
|
46
|
+
is_new: false,
|
|
47
|
+
record_id: 'record-456',
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('Basic Arithmetic', () => {
|
|
52
|
+
it('should evaluate simple addition', () => {
|
|
53
|
+
const result = engine.evaluate('5 + 3', baseContext, 'number');
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
expect(result.value).toBe(8);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should evaluate subtraction', () => {
|
|
59
|
+
const result = engine.evaluate('10 - 3', baseContext, 'number');
|
|
60
|
+
expect(result.success).toBe(true);
|
|
61
|
+
expect(result.value).toBe(7);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should evaluate multiplication', () => {
|
|
65
|
+
const result = engine.evaluate('4 * 5', baseContext, 'number');
|
|
66
|
+
expect(result.success).toBe(true);
|
|
67
|
+
expect(result.value).toBe(20);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should evaluate division', () => {
|
|
71
|
+
const result = engine.evaluate('20 / 4', baseContext, 'number');
|
|
72
|
+
expect(result.success).toBe(true);
|
|
73
|
+
expect(result.value).toBe(5);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should evaluate modulo', () => {
|
|
77
|
+
const result = engine.evaluate('10 % 3', baseContext, 'number');
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
expect(result.value).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should evaluate exponentiation', () => {
|
|
83
|
+
const result = engine.evaluate('2 ** 3', baseContext, 'number');
|
|
84
|
+
expect(result.success).toBe(true);
|
|
85
|
+
expect(result.value).toBe(8);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle complex arithmetic expressions', () => {
|
|
89
|
+
const result = engine.evaluate('(5 + 3) * 2 - 4', baseContext, 'number');
|
|
90
|
+
expect(result.success).toBe(true);
|
|
91
|
+
expect(result.value).toBe(12);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Field References', () => {
|
|
96
|
+
it('should access field values', () => {
|
|
97
|
+
const result = engine.evaluate('quantity', baseContext, 'number');
|
|
98
|
+
expect(result.success).toBe(true);
|
|
99
|
+
expect(result.value).toBe(10);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should perform calculations with field references', () => {
|
|
103
|
+
const result = engine.evaluate('quantity * unit_price', baseContext, 'currency');
|
|
104
|
+
expect(result.success).toBe(true);
|
|
105
|
+
expect(result.value).toBe(255); // 10 * 25.5
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should handle complex field calculations', () => {
|
|
109
|
+
const result = engine.evaluate(
|
|
110
|
+
'quantity * unit_price * (1 - discount_rate)',
|
|
111
|
+
baseContext,
|
|
112
|
+
'currency'
|
|
113
|
+
);
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
expect(result.value).toBeCloseTo(229.5, 1); // 255 * 0.9
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('String Operations', () => {
|
|
120
|
+
it('should concatenate strings', () => {
|
|
121
|
+
const context = {
|
|
122
|
+
...baseContext,
|
|
123
|
+
record: {
|
|
124
|
+
...baseContext.record,
|
|
125
|
+
first_name: 'John',
|
|
126
|
+
last_name: 'Doe',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const result = engine.evaluate('first_name + " " + last_name', context, 'text');
|
|
130
|
+
expect(result.success).toBe(true);
|
|
131
|
+
expect(result.value).toBe('John Doe');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should handle template literals', () => {
|
|
135
|
+
const context = {
|
|
136
|
+
...baseContext,
|
|
137
|
+
record: { ...baseContext.record, first_name: 'Jane' },
|
|
138
|
+
};
|
|
139
|
+
const result = engine.evaluate('`Hello, ${first_name}!`', context, 'text');
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
expect(result.value).toBe('Hello, Jane!');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should convert to uppercase', () => {
|
|
145
|
+
const result = engine.evaluate('name.toUpperCase()', baseContext, 'text');
|
|
146
|
+
expect(result.success).toBe(true);
|
|
147
|
+
expect(result.value).toBe('TEST USER');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should convert to lowercase', () => {
|
|
151
|
+
const result = engine.evaluate('name.toLowerCase()', baseContext, 'text');
|
|
152
|
+
expect(result.success).toBe(true);
|
|
153
|
+
expect(result.value).toBe('test user');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should get string length', () => {
|
|
157
|
+
const result = engine.evaluate('name.length', baseContext, 'number');
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
expect(result.value).toBe(9); // "Test User"
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should extract substring', () => {
|
|
163
|
+
const result = engine.evaluate('name.substring(0, 4)', baseContext, 'text');
|
|
164
|
+
expect(result.success).toBe(true);
|
|
165
|
+
expect(result.value).toBe('Test');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should get character at index', () => {
|
|
169
|
+
const result = engine.evaluate('name.charAt(0)', baseContext, 'text');
|
|
170
|
+
expect(result.success).toBe(true);
|
|
171
|
+
expect(result.value).toBe('T');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('Comparison Operators', () => {
|
|
176
|
+
it('should compare with >', () => {
|
|
177
|
+
const result = engine.evaluate('quantity > 5', baseContext, 'boolean');
|
|
178
|
+
expect(result.success).toBe(true);
|
|
179
|
+
expect(result.value).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should compare with <', () => {
|
|
183
|
+
const result = engine.evaluate('quantity < 5', baseContext, 'boolean');
|
|
184
|
+
expect(result.success).toBe(true);
|
|
185
|
+
expect(result.value).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should compare with ===', () => {
|
|
189
|
+
const result = engine.evaluate('quantity === 10', baseContext, 'boolean');
|
|
190
|
+
expect(result.success).toBe(true);
|
|
191
|
+
expect(result.value).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should compare with !==', () => {
|
|
195
|
+
const result = engine.evaluate('quantity !== 5', baseContext, 'boolean');
|
|
196
|
+
expect(result.success).toBe(true);
|
|
197
|
+
expect(result.value).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should compare with >=', () => {
|
|
201
|
+
const result = engine.evaluate('quantity >= 10', baseContext, 'boolean');
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
expect(result.value).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should compare with <=', () => {
|
|
207
|
+
const result = engine.evaluate('quantity <= 10', baseContext, 'boolean');
|
|
208
|
+
expect(result.success).toBe(true);
|
|
209
|
+
expect(result.value).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('Logical Operators', () => {
|
|
214
|
+
it('should evaluate AND operator', () => {
|
|
215
|
+
const result = engine.evaluate('quantity > 5 && is_active', baseContext, 'boolean');
|
|
216
|
+
expect(result.success).toBe(true);
|
|
217
|
+
expect(result.value).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should evaluate OR operator', () => {
|
|
221
|
+
const result = engine.evaluate('quantity < 5 || is_active', baseContext, 'boolean');
|
|
222
|
+
expect(result.success).toBe(true);
|
|
223
|
+
expect(result.value).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should evaluate NOT operator', () => {
|
|
227
|
+
const result = engine.evaluate('!is_active', baseContext, 'boolean');
|
|
228
|
+
expect(result.success).toBe(true);
|
|
229
|
+
expect(result.value).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('Conditional Expressions', () => {
|
|
234
|
+
it('should evaluate ternary operator', () => {
|
|
235
|
+
const result = engine.evaluate(
|
|
236
|
+
'is_active ? "Active" : "Inactive"',
|
|
237
|
+
baseContext,
|
|
238
|
+
'text'
|
|
239
|
+
);
|
|
240
|
+
expect(result.success).toBe(true);
|
|
241
|
+
expect(result.value).toBe('Active');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should handle nested ternary', () => {
|
|
245
|
+
const result = engine.evaluate(
|
|
246
|
+
'quantity > 100 ? "High" : (quantity > 10 ? "Medium" : "Low")',
|
|
247
|
+
baseContext,
|
|
248
|
+
'text'
|
|
249
|
+
);
|
|
250
|
+
expect(result.success).toBe(true);
|
|
251
|
+
expect(result.value).toBe('Low');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should handle if-else statements', () => {
|
|
255
|
+
const expression = `
|
|
256
|
+
if (quantity > 50) {
|
|
257
|
+
return "High";
|
|
258
|
+
} else if (quantity > 10) {
|
|
259
|
+
return "Medium";
|
|
260
|
+
} else {
|
|
261
|
+
return "Low";
|
|
262
|
+
}
|
|
263
|
+
`;
|
|
264
|
+
const result = engine.evaluate(expression, baseContext, 'text');
|
|
265
|
+
expect(result.success).toBe(true);
|
|
266
|
+
expect(result.value).toBe('Low');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('System Variables', () => {
|
|
271
|
+
it('should access $today', () => {
|
|
272
|
+
const result = engine.evaluate('$today', baseContext, 'date');
|
|
273
|
+
expect(result.success).toBe(true);
|
|
274
|
+
expect(result.value).toEqual(new Date('2026-01-15'));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should access $now', () => {
|
|
278
|
+
const result = engine.evaluate('$now', baseContext, 'datetime');
|
|
279
|
+
expect(result.success).toBe(true);
|
|
280
|
+
expect(result.value).toEqual(new Date('2026-01-15T12:30:45Z'));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should access $year', () => {
|
|
284
|
+
const result = engine.evaluate('$year', baseContext, 'number');
|
|
285
|
+
expect(result.success).toBe(true);
|
|
286
|
+
expect(result.value).toBe(2026);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should access $month', () => {
|
|
290
|
+
const result = engine.evaluate('$month', baseContext, 'number');
|
|
291
|
+
expect(result.success).toBe(true);
|
|
292
|
+
expect(result.value).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should access $current_user.id', () => {
|
|
296
|
+
const result = engine.evaluate('$current_user.id', baseContext, 'text');
|
|
297
|
+
expect(result.success).toBe(true);
|
|
298
|
+
expect(result.value).toBe('user-123');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should access $current_user.name', () => {
|
|
302
|
+
const result = engine.evaluate('$current_user.name', baseContext, 'text');
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
expect(result.value).toBe('John Doe');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should access $is_new', () => {
|
|
308
|
+
const result = engine.evaluate('$is_new', baseContext, 'boolean');
|
|
309
|
+
expect(result.success).toBe(true);
|
|
310
|
+
expect(result.value).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('Math Functions', () => {
|
|
315
|
+
it('should use Math.round', () => {
|
|
316
|
+
const result = engine.evaluate('Math.round(25.7)', baseContext, 'number');
|
|
317
|
+
expect(result.success).toBe(true);
|
|
318
|
+
expect(result.value).toBe(26);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should use Math.ceil', () => {
|
|
322
|
+
const result = engine.evaluate('Math.ceil(25.1)', baseContext, 'number');
|
|
323
|
+
expect(result.success).toBe(true);
|
|
324
|
+
expect(result.value).toBe(26);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should use Math.floor', () => {
|
|
328
|
+
const result = engine.evaluate('Math.floor(25.9)', baseContext, 'number');
|
|
329
|
+
expect(result.success).toBe(true);
|
|
330
|
+
expect(result.value).toBe(25);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should use Math.abs', () => {
|
|
334
|
+
const result = engine.evaluate('Math.abs(-10)', baseContext, 'number');
|
|
335
|
+
expect(result.success).toBe(true);
|
|
336
|
+
expect(result.value).toBe(10);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should use Math.max', () => {
|
|
340
|
+
const result = engine.evaluate('Math.max(5, 10, 3)', baseContext, 'number');
|
|
341
|
+
expect(result.success).toBe(true);
|
|
342
|
+
expect(result.value).toBe(10);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should use Math.min', () => {
|
|
346
|
+
const result = engine.evaluate('Math.min(5, 10, 3)', baseContext, 'number');
|
|
347
|
+
expect(result.success).toBe(true);
|
|
348
|
+
expect(result.value).toBe(3);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should use Math.pow', () => {
|
|
352
|
+
const result = engine.evaluate('Math.pow(2, 3)', baseContext, 'number');
|
|
353
|
+
expect(result.success).toBe(true);
|
|
354
|
+
expect(result.value).toBe(8);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should use Math.sqrt', () => {
|
|
358
|
+
const result = engine.evaluate('Math.sqrt(16)', baseContext, 'number');
|
|
359
|
+
expect(result.success).toBe(true);
|
|
360
|
+
expect(result.value).toBe(4);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('Date Functions', () => {
|
|
365
|
+
it('should get year from date', () => {
|
|
366
|
+
const result = engine.evaluate('created_at.getFullYear()', baseContext, 'number');
|
|
367
|
+
expect(result.success).toBe(true);
|
|
368
|
+
expect(result.value).toBe(2026);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should get month from date', () => {
|
|
372
|
+
const result = engine.evaluate('created_at.getMonth()', baseContext, 'number');
|
|
373
|
+
expect(result.success).toBe(true);
|
|
374
|
+
expect(result.value).toBe(0); // January is 0
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should get day from date', () => {
|
|
378
|
+
const result = engine.evaluate('created_at.getDate()', baseContext, 'number');
|
|
379
|
+
expect(result.success).toBe(true);
|
|
380
|
+
expect(result.value).toBe(1);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('Null Handling', () => {
|
|
385
|
+
it('should handle null values', () => {
|
|
386
|
+
const context = {
|
|
387
|
+
...baseContext,
|
|
388
|
+
record: { ...baseContext.record, optional_field: null },
|
|
389
|
+
};
|
|
390
|
+
const result = engine.evaluate('optional_field ?? "default"', context, 'text');
|
|
391
|
+
expect(result.success).toBe(true);
|
|
392
|
+
expect(result.value).toBe('default');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should handle undefined values', () => {
|
|
396
|
+
const context = {
|
|
397
|
+
...baseContext,
|
|
398
|
+
record: { ...baseContext.record, optional_field: undefined },
|
|
399
|
+
};
|
|
400
|
+
const result = engine.evaluate('optional_field ?? "default"', context, 'text');
|
|
401
|
+
expect(result.success).toBe(true);
|
|
402
|
+
expect(result.value).toBe('default');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should handle logical OR for null', () => {
|
|
406
|
+
const context = {
|
|
407
|
+
...baseContext,
|
|
408
|
+
record: { ...baseContext.record, value: null },
|
|
409
|
+
};
|
|
410
|
+
const result = engine.evaluate('value || 0', context, 'number');
|
|
411
|
+
expect(result.success).toBe(true);
|
|
412
|
+
expect(result.value).toBe(0);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('Type Coercion', () => {
|
|
417
|
+
it('should coerce string to number', () => {
|
|
418
|
+
const context = {
|
|
419
|
+
...baseContext,
|
|
420
|
+
record: { ...baseContext.record, text_value: '42' },
|
|
421
|
+
};
|
|
422
|
+
const result = engine.evaluate('text_value', context, 'number');
|
|
423
|
+
expect(result.success).toBe(true);
|
|
424
|
+
expect(result.value).toBe(42);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should coerce number to text', () => {
|
|
428
|
+
const result = engine.evaluate('quantity', baseContext, 'text');
|
|
429
|
+
expect(result.success).toBe(true);
|
|
430
|
+
expect(result.value).toBe('10');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should coerce boolean to number', () => {
|
|
434
|
+
const result = engine.evaluate('is_active', baseContext, 'number');
|
|
435
|
+
expect(result.success).toBe(true);
|
|
436
|
+
expect(result.value).toBe(1);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should handle invalid number conversion', () => {
|
|
440
|
+
const context = {
|
|
441
|
+
...baseContext,
|
|
442
|
+
record: { ...baseContext.record, invalid: 'not-a-number' },
|
|
443
|
+
};
|
|
444
|
+
const result = engine.evaluate('invalid', context, 'number');
|
|
445
|
+
expect(result.success).toBe(false);
|
|
446
|
+
expect(result.error).toContain('Cannot convert');
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('Error Handling', () => {
|
|
451
|
+
it('should handle empty expression', () => {
|
|
452
|
+
const result = engine.evaluate('', baseContext, 'number');
|
|
453
|
+
expect(result.success).toBe(false);
|
|
454
|
+
expect(result.error).toContain('empty');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should handle syntax error', () => {
|
|
458
|
+
const result = engine.evaluate('5 +', baseContext, 'number');
|
|
459
|
+
expect(result.success).toBe(false);
|
|
460
|
+
expect(result.error).toBeDefined();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should handle division by zero', () => {
|
|
464
|
+
const result = engine.evaluate('10 / 0', baseContext, 'number');
|
|
465
|
+
expect(result.success).toBe(false);
|
|
466
|
+
expect(result.error).toContain('Infinity');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should handle undefined field reference', () => {
|
|
470
|
+
const result = engine.evaluate('nonexistent_field', baseContext, 'number');
|
|
471
|
+
expect(result.success).toBe(false);
|
|
472
|
+
expect(result.error).toBeDefined();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should detect blocked operations', () => {
|
|
476
|
+
const result = engine.evaluate('eval("malicious code")', baseContext, 'text');
|
|
477
|
+
expect(result.success).toBe(false);
|
|
478
|
+
expect(result.error).toContain('Blocked operation');
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('Complex Business Logic', () => {
|
|
483
|
+
it('should calculate commission rate', () => {
|
|
484
|
+
const context = {
|
|
485
|
+
...baseContext,
|
|
486
|
+
record: { ...baseContext.record, sales_total: 75000 },
|
|
487
|
+
};
|
|
488
|
+
const expression = `
|
|
489
|
+
if (sales_total > 100000) {
|
|
490
|
+
return 0.15;
|
|
491
|
+
} else if (sales_total > 50000) {
|
|
492
|
+
return 0.10;
|
|
493
|
+
} else if (sales_total > 10000) {
|
|
494
|
+
return 0.05;
|
|
495
|
+
} else {
|
|
496
|
+
return 0.02;
|
|
497
|
+
}
|
|
498
|
+
`;
|
|
499
|
+
const result = engine.evaluate(expression, context, 'percent');
|
|
500
|
+
expect(result.success).toBe(true);
|
|
501
|
+
expect(result.value).toBe(0.10);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should calculate risk score', () => {
|
|
505
|
+
const context = {
|
|
506
|
+
...baseContext,
|
|
507
|
+
record: {
|
|
508
|
+
...baseContext.record,
|
|
509
|
+
customer: {
|
|
510
|
+
credit_score: 650,
|
|
511
|
+
payment_history: 'fair',
|
|
512
|
+
},
|
|
513
|
+
amount: 60000,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
const expression = `
|
|
517
|
+
let score = 0;
|
|
518
|
+
if (customer.credit_score < 600) {
|
|
519
|
+
score += 40;
|
|
520
|
+
} else if (customer.credit_score < 700) {
|
|
521
|
+
score += 20;
|
|
522
|
+
}
|
|
523
|
+
if (amount > 100000) {
|
|
524
|
+
score += 30;
|
|
525
|
+
} else if (amount > 50000) {
|
|
526
|
+
score += 15;
|
|
527
|
+
}
|
|
528
|
+
if (customer.payment_history === 'poor') {
|
|
529
|
+
score += 30;
|
|
530
|
+
} else if (customer.payment_history === 'fair') {
|
|
531
|
+
score += 15;
|
|
532
|
+
}
|
|
533
|
+
return score;
|
|
534
|
+
`;
|
|
535
|
+
const result = engine.evaluate(expression, context, 'number');
|
|
536
|
+
expect(result.success).toBe(true);
|
|
537
|
+
expect(result.value).toBe(50); // 20 + 15 + 15
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('Metadata Extraction', () => {
|
|
542
|
+
it('should extract dependencies from simple expression', () => {
|
|
543
|
+
const metadata = engine.extractMetadata(
|
|
544
|
+
'total',
|
|
545
|
+
'quantity * unit_price',
|
|
546
|
+
'currency'
|
|
547
|
+
);
|
|
548
|
+
expect(metadata.field_name).toBe('total');
|
|
549
|
+
expect(metadata.dependencies).toContain('quantity');
|
|
550
|
+
expect(metadata.dependencies).toContain('unit_price');
|
|
551
|
+
expect(metadata.is_valid).toBe(true);
|
|
552
|
+
expect(metadata.complexity).toBe('simple');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should extract system variables', () => {
|
|
556
|
+
const metadata = engine.extractMetadata(
|
|
557
|
+
'age',
|
|
558
|
+
'$today - birth_date',
|
|
559
|
+
'number'
|
|
560
|
+
);
|
|
561
|
+
expect(metadata.system_variables).toContain('$today');
|
|
562
|
+
expect(metadata.dependencies).toContain('birth_date');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should extract lookup chains', () => {
|
|
566
|
+
const metadata = engine.extractMetadata(
|
|
567
|
+
'owner_name',
|
|
568
|
+
'account.owner.name',
|
|
569
|
+
'text'
|
|
570
|
+
);
|
|
571
|
+
expect(metadata.lookup_chains).toContain('account.owner.name');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should estimate complexity', () => {
|
|
575
|
+
const simpleMetadata = engine.extractMetadata(
|
|
576
|
+
'total',
|
|
577
|
+
'a + b',
|
|
578
|
+
'number'
|
|
579
|
+
);
|
|
580
|
+
expect(simpleMetadata.complexity).toBe('simple');
|
|
581
|
+
|
|
582
|
+
const mediumMetadata = engine.extractMetadata(
|
|
583
|
+
'status',
|
|
584
|
+
'value > 10 ? "high" : "low"',
|
|
585
|
+
'text'
|
|
586
|
+
);
|
|
587
|
+
expect(mediumMetadata.complexity).toBe('medium');
|
|
588
|
+
|
|
589
|
+
const complexMetadata = engine.extractMetadata(
|
|
590
|
+
'score',
|
|
591
|
+
`
|
|
592
|
+
if (a > 10) {
|
|
593
|
+
return 100;
|
|
594
|
+
} else if (a > 5) {
|
|
595
|
+
return 50;
|
|
596
|
+
} else {
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
`,
|
|
600
|
+
'number'
|
|
601
|
+
);
|
|
602
|
+
expect(complexMetadata.complexity).toBe('medium');
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('Custom Functions', () => {
|
|
607
|
+
it('should register and use custom function', () => {
|
|
608
|
+
engine.registerFunction('DOUBLE', (x: number) => x * 2);
|
|
609
|
+
const result = engine.evaluate('DOUBLE(5)', baseContext, 'number');
|
|
610
|
+
expect(result.success).toBe(true);
|
|
611
|
+
expect(result.value).toBe(10);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('should use multiple custom functions', () => {
|
|
615
|
+
engine.registerFunction('ADD', (a: number, b: number) => a + b);
|
|
616
|
+
engine.registerFunction('MULTIPLY', (a: number, b: number) => a * b);
|
|
617
|
+
const result = engine.evaluate('MULTIPLY(ADD(3, 2), 4)', baseContext, 'number');
|
|
618
|
+
expect(result.success).toBe(true);
|
|
619
|
+
expect(result.value).toBe(20);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe('Validation', () => {
|
|
624
|
+
it('should validate valid expression', () => {
|
|
625
|
+
const validation = engine.validate('quantity * unit_price');
|
|
626
|
+
expect(validation.valid).toBe(true);
|
|
627
|
+
expect(validation.errors).toHaveLength(0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('should detect syntax errors', () => {
|
|
631
|
+
const validation = engine.validate('5 +');
|
|
632
|
+
expect(validation.valid).toBe(false);
|
|
633
|
+
expect(validation.errors.length).toBeGreaterThan(0);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should detect empty expression', () => {
|
|
637
|
+
const validation = engine.validate('');
|
|
638
|
+
expect(validation.valid).toBe(false);
|
|
639
|
+
expect(validation.errors).toContain('Expression cannot be empty');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should detect blocked operations', () => {
|
|
643
|
+
const validation = engine.validate('eval("code")');
|
|
644
|
+
expect(validation.valid).toBe(false);
|
|
645
|
+
expect(validation.errors.some(e => e.includes('Blocked operation'))).toBe(true);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('Real-world Examples', () => {
|
|
650
|
+
it('should calculate e-commerce final price', () => {
|
|
651
|
+
const context = {
|
|
652
|
+
...baseContext,
|
|
653
|
+
record: {
|
|
654
|
+
...baseContext.record,
|
|
655
|
+
list_price: 100,
|
|
656
|
+
discount_rate: 0.2,
|
|
657
|
+
tax_rate: 0.08,
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
const result = engine.evaluate(
|
|
661
|
+
'list_price * (1 - discount_rate) * (1 + tax_rate)',
|
|
662
|
+
context,
|
|
663
|
+
'currency'
|
|
664
|
+
);
|
|
665
|
+
expect(result.success).toBe(true);
|
|
666
|
+
expect(result.value).toBeCloseTo(86.4, 1); // 100 * 0.8 * 1.08
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('should classify account tier', () => {
|
|
670
|
+
const context = {
|
|
671
|
+
...baseContext,
|
|
672
|
+
record: { ...baseContext.record, annual_revenue: 5000000 },
|
|
673
|
+
};
|
|
674
|
+
const expression = `
|
|
675
|
+
if (annual_revenue > 10000000) return 'Enterprise';
|
|
676
|
+
if (annual_revenue > 1000000) return 'Corporate';
|
|
677
|
+
if (annual_revenue > 100000) return 'SMB';
|
|
678
|
+
return 'Startup';
|
|
679
|
+
`;
|
|
680
|
+
const result = engine.evaluate(expression, context, 'text');
|
|
681
|
+
expect(result.success).toBe(true);
|
|
682
|
+
expect(result.value).toBe('Corporate');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should calculate full name', () => {
|
|
686
|
+
const context = {
|
|
687
|
+
...baseContext,
|
|
688
|
+
record: {
|
|
689
|
+
...baseContext.record,
|
|
690
|
+
first_name: 'Jane',
|
|
691
|
+
last_name: 'Smith',
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
const result = engine.evaluate(
|
|
695
|
+
'first_name + " " + last_name',
|
|
696
|
+
context,
|
|
697
|
+
'text'
|
|
698
|
+
);
|
|
699
|
+
expect(result.success).toBe(true);
|
|
700
|
+
expect(result.value).toBe('Jane Smith');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should check if user is owner', () => {
|
|
704
|
+
const context = {
|
|
705
|
+
...baseContext,
|
|
706
|
+
record: { ...baseContext.record, owner_id: 'user-123' },
|
|
707
|
+
};
|
|
708
|
+
const result = engine.evaluate(
|
|
709
|
+
'owner_id === $current_user.id',
|
|
710
|
+
context,
|
|
711
|
+
'boolean'
|
|
712
|
+
);
|
|
713
|
+
expect(result.success).toBe(true);
|
|
714
|
+
expect(result.value).toBe(true);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
});
|