@f-o-t/rules-engine 2.0.2 → 3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE.md +16 -4
  3. package/README.md +106 -23
  4. package/__tests__/builder.test.ts +363 -0
  5. package/__tests__/cache.test.ts +130 -0
  6. package/__tests__/config.test.ts +35 -0
  7. package/__tests__/engine.test.ts +1213 -0
  8. package/__tests__/evaluate.test.ts +339 -0
  9. package/__tests__/exports.test.ts +30 -0
  10. package/__tests__/filter-sort.test.ts +303 -0
  11. package/__tests__/integration.test.ts +419 -0
  12. package/__tests__/money-integration.test.ts +149 -0
  13. package/__tests__/validation.test.ts +862 -0
  14. package/biome.json +39 -0
  15. package/docs/MIGRATION-v3.md +118 -0
  16. package/fot.config.ts +5 -0
  17. package/package.json +31 -67
  18. package/src/analyzer/analysis.ts +401 -0
  19. package/src/builder/conditions.ts +321 -0
  20. package/src/builder/rule.ts +192 -0
  21. package/src/cache/cache.ts +135 -0
  22. package/src/cache/noop.ts +20 -0
  23. package/src/core/evaluate.ts +185 -0
  24. package/src/core/filter.ts +85 -0
  25. package/src/core/group.ts +103 -0
  26. package/src/core/sort.ts +90 -0
  27. package/src/engine/engine.ts +462 -0
  28. package/src/engine/hooks.ts +235 -0
  29. package/src/engine/state.ts +322 -0
  30. package/src/index.ts +303 -0
  31. package/src/optimizer/index-builder.ts +381 -0
  32. package/src/serialization/serializer.ts +408 -0
  33. package/src/simulation/simulator.ts +359 -0
  34. package/src/types/config.ts +184 -0
  35. package/src/types/consequence.ts +38 -0
  36. package/src/types/evaluation.ts +87 -0
  37. package/src/types/rule.ts +112 -0
  38. package/src/types/state.ts +116 -0
  39. package/src/utils/conditions.ts +108 -0
  40. package/src/utils/hash.ts +30 -0
  41. package/src/utils/id.ts +6 -0
  42. package/src/utils/time.ts +42 -0
  43. package/src/validation/conflicts.ts +440 -0
  44. package/src/validation/integrity.ts +473 -0
  45. package/src/validation/schema.ts +386 -0
  46. package/src/versioning/version-store.ts +337 -0
  47. package/tsconfig.json +29 -0
  48. package/dist/index.cjs +0 -3088
  49. package/dist/index.d.cts +0 -1173
  50. package/dist/index.d.ts +0 -1173
  51. package/dist/index.js +0 -3072
@@ -0,0 +1,1213 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import type { Condition, ConditionGroup } from "@f-o-t/condition-evaluator";
3
+ import { createEvaluator, createOperator } from "@f-o-t/condition-evaluator";
4
+ import { z } from "zod";
5
+ import { createEngine, type Engine } from "../src/engine/engine";
6
+ import type { ConsequenceDefinitions } from "../src/types/consequence";
7
+
8
+ type TestContext = {
9
+ age: number;
10
+ country: string;
11
+ premium: boolean;
12
+ score: number;
13
+ };
14
+
15
+ const TestConsequences = {
16
+ applyDiscount: z.object({
17
+ percentage: z.number(),
18
+ reason: z.string(),
19
+ }),
20
+ sendNotification: z.object({
21
+ type: z.enum(["email", "sms", "push"]),
22
+ message: z.string(),
23
+ }),
24
+ setFlag: z.object({
25
+ name: z.string(),
26
+ value: z.boolean(),
27
+ }),
28
+ } satisfies ConsequenceDefinitions;
29
+
30
+ type TestConsequences = typeof TestConsequences;
31
+
32
+ const cond = (
33
+ id: string,
34
+ conditions: (Condition | ConditionGroup)[],
35
+ ): ConditionGroup => ({
36
+ id,
37
+ operator: "AND",
38
+ conditions,
39
+ });
40
+
41
+ const condOr = (
42
+ id: string,
43
+ conditions: (Condition | ConditionGroup)[],
44
+ ): ConditionGroup => ({
45
+ id,
46
+ operator: "OR",
47
+ conditions,
48
+ });
49
+
50
+ const numCond = (
51
+ id: string,
52
+ field: string,
53
+ operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq",
54
+ value: number,
55
+ ): Condition => ({
56
+ id,
57
+ type: "number",
58
+ field,
59
+ operator,
60
+ value,
61
+ });
62
+
63
+ const boolCond = (
64
+ id: string,
65
+ field: string,
66
+ operator: "eq" | "neq",
67
+ value: boolean,
68
+ ): Condition => ({
69
+ id,
70
+ type: "boolean",
71
+ field,
72
+ operator,
73
+ value,
74
+ });
75
+
76
+ describe("createEngine", () => {
77
+ let engine: Engine<TestContext, TestConsequences>;
78
+
79
+ beforeEach(() => {
80
+ engine = createEngine<TestContext, TestConsequences>({
81
+ consequences: TestConsequences,
82
+ evaluator: createEvaluator(),
83
+ });
84
+ });
85
+
86
+ describe("rule management", () => {
87
+ it("should add a rule and return it", () => {
88
+ const rule = engine.addRule({
89
+ name: "test-rule",
90
+ conditions: cond("g1", [numCond("c1", "age", "gte", 18)]),
91
+ consequences: [
92
+ {
93
+ type: "applyDiscount",
94
+ payload: { percentage: 10, reason: "Adult discount" },
95
+ },
96
+ ],
97
+ });
98
+
99
+ expect(rule.id).toBeDefined();
100
+ expect(rule.name).toBe("test-rule");
101
+ expect(rule.enabled).toBe(true);
102
+ expect(rule.priority).toBe(0);
103
+ });
104
+
105
+ it("should add multiple rules", () => {
106
+ const rules = engine.addRules([
107
+ {
108
+ name: "rule-1",
109
+ conditions: cond("g1", []),
110
+ consequences: [
111
+ {
112
+ type: "setFlag",
113
+ payload: { name: "flag1", value: true },
114
+ },
115
+ ],
116
+ },
117
+ {
118
+ name: "rule-2",
119
+ conditions: cond("g2", []),
120
+ consequences: [
121
+ {
122
+ type: "setFlag",
123
+ payload: { name: "flag2", value: false },
124
+ },
125
+ ],
126
+ },
127
+ ]);
128
+
129
+ expect(rules).toHaveLength(2);
130
+ expect(engine.getRules()).toHaveLength(2);
131
+ });
132
+
133
+ it("should get rule by id", () => {
134
+ engine.addRule({
135
+ id: "custom-id",
136
+ name: "test-rule",
137
+ conditions: cond("g1", []),
138
+ consequences: [
139
+ {
140
+ type: "setFlag",
141
+ payload: { name: "test", value: true },
142
+ },
143
+ ],
144
+ });
145
+
146
+ const found = engine.getRule("custom-id");
147
+ expect(found).toBeDefined();
148
+ expect(found?.id).toBe("custom-id");
149
+ });
150
+
151
+ it("should return undefined for non-existent rule", () => {
152
+ const found = engine.getRule("non-existent");
153
+ expect(found).toBeUndefined();
154
+ });
155
+
156
+ it("should remove rule", () => {
157
+ const rule = engine.addRule({
158
+ name: "test-rule",
159
+ conditions: cond("g1", []),
160
+ consequences: [
161
+ {
162
+ type: "setFlag",
163
+ payload: { name: "test", value: true },
164
+ },
165
+ ],
166
+ });
167
+
168
+ const removed = engine.removeRule(rule.id);
169
+ expect(removed).toBe(true);
170
+ expect(engine.getRule(rule.id)).toBeUndefined();
171
+ });
172
+
173
+ it("should update rule", () => {
174
+ const rule = engine.addRule({
175
+ name: "original",
176
+ priority: 1,
177
+ conditions: cond("g1", []),
178
+ consequences: [
179
+ {
180
+ type: "setFlag",
181
+ payload: { name: "test", value: true },
182
+ },
183
+ ],
184
+ });
185
+
186
+ const updated = engine.updateRule(rule.id, {
187
+ name: "updated",
188
+ priority: 100,
189
+ });
190
+
191
+ expect(updated?.name).toBe("updated");
192
+ expect(updated?.priority).toBe(100);
193
+ });
194
+
195
+ it("should enable and disable rules", () => {
196
+ const rule = engine.addRule({
197
+ name: "test-rule",
198
+ enabled: true,
199
+ conditions: cond("g1", []),
200
+ consequences: [
201
+ {
202
+ type: "setFlag",
203
+ payload: { name: "test", value: true },
204
+ },
205
+ ],
206
+ });
207
+
208
+ engine.disableRule(rule.id);
209
+ expect(engine.getRule(rule.id)?.enabled).toBe(false);
210
+
211
+ engine.enableRule(rule.id);
212
+ expect(engine.getRule(rule.id)?.enabled).toBe(true);
213
+ });
214
+
215
+ it("should filter rules by enabled status", () => {
216
+ engine.addRule({
217
+ name: "enabled-rule",
218
+ enabled: true,
219
+ conditions: cond("g1", []),
220
+ consequences: [
221
+ {
222
+ type: "setFlag",
223
+ payload: { name: "test", value: true },
224
+ },
225
+ ],
226
+ });
227
+ engine.addRule({
228
+ name: "disabled-rule",
229
+ enabled: false,
230
+ conditions: cond("g2", []),
231
+ consequences: [
232
+ {
233
+ type: "setFlag",
234
+ payload: { name: "test", value: false },
235
+ },
236
+ ],
237
+ });
238
+
239
+ const enabled = engine.getRules({ enabled: true });
240
+ const disabled = engine.getRules({ enabled: false });
241
+
242
+ expect(enabled).toHaveLength(1);
243
+ expect(disabled).toHaveLength(1);
244
+ expect(enabled[0]?.name).toBe("enabled-rule");
245
+ expect(disabled[0]?.name).toBe("disabled-rule");
246
+ });
247
+
248
+ it("should filter rules by tags", () => {
249
+ engine.addRule({
250
+ name: "tagged-rule",
251
+ tags: ["vip", "premium"],
252
+ conditions: cond("g1", []),
253
+ consequences: [
254
+ {
255
+ type: "setFlag",
256
+ payload: { name: "test", value: true },
257
+ },
258
+ ],
259
+ });
260
+ engine.addRule({
261
+ name: "other-rule",
262
+ tags: ["basic"],
263
+ conditions: cond("g2", []),
264
+ consequences: [
265
+ {
266
+ type: "setFlag",
267
+ payload: { name: "test", value: false },
268
+ },
269
+ ],
270
+ });
271
+
272
+ const vipRules = engine.getRules({ tags: ["vip"] });
273
+ expect(vipRules).toHaveLength(1);
274
+ expect(vipRules[0]?.name).toBe("tagged-rule");
275
+ });
276
+
277
+ it("should filter rules by category", () => {
278
+ engine.addRule({
279
+ name: "pricing-rule",
280
+ category: "pricing",
281
+ conditions: cond("g1", []),
282
+ consequences: [
283
+ {
284
+ type: "applyDiscount",
285
+ payload: { percentage: 10, reason: "test" },
286
+ },
287
+ ],
288
+ });
289
+ engine.addRule({
290
+ name: "notification-rule",
291
+ category: "notifications",
292
+ conditions: cond("g2", []),
293
+ consequences: [
294
+ {
295
+ type: "sendNotification",
296
+ payload: { type: "email", message: "test" },
297
+ },
298
+ ],
299
+ });
300
+
301
+ const pricingRules = engine.getRules({ category: "pricing" });
302
+ expect(pricingRules).toHaveLength(1);
303
+ expect(pricingRules[0]?.name).toBe("pricing-rule");
304
+ });
305
+
306
+ it("should clear all rules", () => {
307
+ engine.addRules([
308
+ {
309
+ name: "rule-1",
310
+ conditions: cond("g1", []),
311
+ consequences: [
312
+ {
313
+ type: "setFlag",
314
+ payload: { name: "test", value: true },
315
+ },
316
+ ],
317
+ },
318
+ {
319
+ name: "rule-2",
320
+ conditions: cond("g2", []),
321
+ consequences: [
322
+ {
323
+ type: "setFlag",
324
+ payload: { name: "test", value: false },
325
+ },
326
+ ],
327
+ },
328
+ ]);
329
+
330
+ engine.clearRules();
331
+ expect(engine.getRules()).toHaveLength(0);
332
+ });
333
+ });
334
+
335
+ describe("rule sets", () => {
336
+ it("should add and get rule set", () => {
337
+ const rule1 = engine.addRule({
338
+ name: "rule-1",
339
+ conditions: cond("g1", []),
340
+ consequences: [
341
+ {
342
+ type: "setFlag",
343
+ payload: { name: "test", value: true },
344
+ },
345
+ ],
346
+ });
347
+
348
+ const ruleSet = engine.addRuleSet({
349
+ name: "test-set",
350
+ description: "A test rule set",
351
+ ruleIds: [rule1.id],
352
+ });
353
+
354
+ expect(ruleSet.id).toBeDefined();
355
+ expect(ruleSet.name).toBe("test-set");
356
+ expect(ruleSet.ruleIds).toContain(rule1.id);
357
+ });
358
+
359
+ it("should get all rule sets", () => {
360
+ engine.addRuleSet({ name: "set-1", ruleIds: [] });
361
+ engine.addRuleSet({ name: "set-2", ruleIds: [] });
362
+
363
+ const ruleSets = engine.getRuleSets();
364
+ expect(ruleSets).toHaveLength(2);
365
+ });
366
+
367
+ it("should remove rule set", () => {
368
+ const ruleSet = engine.addRuleSet({ name: "test-set", ruleIds: [] });
369
+
370
+ const removed = engine.removeRuleSet(ruleSet.id);
371
+ expect(removed).toBe(true);
372
+ expect(engine.getRuleSet(ruleSet.id)).toBeUndefined();
373
+ });
374
+ });
375
+
376
+ describe("evaluation", () => {
377
+ it("should evaluate rules and return matching results", async () => {
378
+ engine.addRule({
379
+ name: "adult-discount",
380
+ priority: 10,
381
+ conditions: cond("g1", [numCond("c1", "age", "gte", 18)]),
382
+ consequences: [
383
+ {
384
+ type: "applyDiscount",
385
+ payload: { percentage: 10, reason: "Adult discount" },
386
+ },
387
+ ],
388
+ });
389
+
390
+ const result = await engine.evaluate({
391
+ age: 25,
392
+ country: "US",
393
+ premium: false,
394
+ score: 100,
395
+ });
396
+
397
+ expect(result.totalRulesMatched).toBe(1);
398
+ expect(result.consequences).toHaveLength(1);
399
+ expect(result.consequences[0]?.type).toBe("applyDiscount");
400
+ expect(result.consequences[0]?.payload).toEqual({
401
+ percentage: 10,
402
+ reason: "Adult discount",
403
+ });
404
+ });
405
+
406
+ it("should not match rules when conditions fail", async () => {
407
+ engine.addRule({
408
+ name: "adult-only",
409
+ conditions: cond("g1", [numCond("c1", "age", "gte", 18)]),
410
+ consequences: [
411
+ {
412
+ type: "setFlag",
413
+ payload: { name: "adult", value: true },
414
+ },
415
+ ],
416
+ });
417
+
418
+ const result = await engine.evaluate({
419
+ age: 16,
420
+ country: "US",
421
+ premium: false,
422
+ score: 100,
423
+ });
424
+
425
+ expect(result.totalRulesMatched).toBe(0);
426
+ expect(result.consequences).toHaveLength(0);
427
+ });
428
+
429
+ it("should respect rule priority order", async () => {
430
+ engine.addRule({
431
+ name: "low-priority",
432
+ priority: 1,
433
+ conditions: cond("g1", []),
434
+ consequences: [
435
+ {
436
+ type: "setFlag",
437
+ payload: { name: "low", value: true },
438
+ },
439
+ ],
440
+ });
441
+ engine.addRule({
442
+ name: "high-priority",
443
+ priority: 100,
444
+ conditions: cond("g2", []),
445
+ consequences: [
446
+ {
447
+ type: "setFlag",
448
+ payload: { name: "high", value: true },
449
+ },
450
+ ],
451
+ });
452
+
453
+ const result = await engine.evaluate({
454
+ age: 25,
455
+ country: "US",
456
+ premium: false,
457
+ score: 100,
458
+ });
459
+
460
+ expect(result.matchedRules[0]?.name).toBe("high-priority");
461
+ expect(result.matchedRules[1]?.name).toBe("low-priority");
462
+ });
463
+
464
+ it("should skip disabled rules", async () => {
465
+ engine.addRule({
466
+ name: "enabled-rule",
467
+ enabled: true,
468
+ conditions: cond("g1", []),
469
+ consequences: [
470
+ {
471
+ type: "setFlag",
472
+ payload: { name: "enabled", value: true },
473
+ },
474
+ ],
475
+ });
476
+ engine.addRule({
477
+ name: "disabled-rule",
478
+ enabled: false,
479
+ conditions: cond("g2", []),
480
+ consequences: [
481
+ {
482
+ type: "setFlag",
483
+ payload: { name: "disabled", value: true },
484
+ },
485
+ ],
486
+ });
487
+
488
+ const result = await engine.evaluate({
489
+ age: 25,
490
+ country: "US",
491
+ premium: false,
492
+ score: 100,
493
+ });
494
+
495
+ expect(result.totalRulesEvaluated).toBe(1);
496
+ expect(result.matchedRules[0]?.name).toBe("enabled-rule");
497
+ });
498
+
499
+ it("should stop on match when stopOnMatch is true", async () => {
500
+ engine.addRule({
501
+ name: "stop-rule",
502
+ priority: 100,
503
+ stopOnMatch: true,
504
+ conditions: cond("g1", []),
505
+ consequences: [
506
+ {
507
+ type: "setFlag",
508
+ payload: { name: "stop", value: true },
509
+ },
510
+ ],
511
+ });
512
+ engine.addRule({
513
+ name: "after-stop",
514
+ priority: 50,
515
+ conditions: cond("g2", []),
516
+ consequences: [
517
+ {
518
+ type: "setFlag",
519
+ payload: { name: "after", value: true },
520
+ },
521
+ ],
522
+ });
523
+
524
+ const result = await engine.evaluate({
525
+ age: 25,
526
+ country: "US",
527
+ premium: false,
528
+ score: 100,
529
+ });
530
+
531
+ expect(result.stoppedEarly).toBe(true);
532
+ expect(result.stoppedByRuleId).toBe(result.matchedRules[0]?.id);
533
+ expect(result.totalRulesMatched).toBe(1);
534
+ });
535
+
536
+ it("should use first-match conflict resolution", async () => {
537
+ const engineFirstMatch = createEngine<TestContext, TestConsequences>({
538
+ consequences: TestConsequences,
539
+ conflictResolution: "first-match",
540
+ evaluator: createEvaluator(),
541
+ });
542
+
543
+ engineFirstMatch.addRule({
544
+ name: "first",
545
+ priority: 100,
546
+ conditions: cond("g1", []),
547
+ consequences: [
548
+ {
549
+ type: "setFlag",
550
+ payload: { name: "first", value: true },
551
+ },
552
+ ],
553
+ });
554
+ engineFirstMatch.addRule({
555
+ name: "second",
556
+ priority: 50,
557
+ conditions: cond("g2", []),
558
+ consequences: [
559
+ {
560
+ type: "setFlag",
561
+ payload: { name: "second", value: true },
562
+ },
563
+ ],
564
+ });
565
+
566
+ const result = await engineFirstMatch.evaluate({
567
+ age: 25,
568
+ country: "US",
569
+ premium: false,
570
+ score: 100,
571
+ });
572
+
573
+ expect(result.stoppedEarly).toBe(true);
574
+ expect(result.totalRulesMatched).toBe(1);
575
+ expect(result.matchedRules[0]?.name).toBe("first");
576
+ });
577
+
578
+ it("should filter by tags during evaluation", async () => {
579
+ engine.addRule({
580
+ name: "vip-rule",
581
+ tags: ["vip"],
582
+ conditions: cond("g1", []),
583
+ consequences: [
584
+ {
585
+ type: "applyDiscount",
586
+ payload: { percentage: 20, reason: "VIP" },
587
+ },
588
+ ],
589
+ });
590
+ engine.addRule({
591
+ name: "regular-rule",
592
+ tags: ["regular"],
593
+ conditions: cond("g2", []),
594
+ consequences: [
595
+ {
596
+ type: "applyDiscount",
597
+ payload: { percentage: 5, reason: "Regular" },
598
+ },
599
+ ],
600
+ });
601
+
602
+ const result = await engine.evaluate(
603
+ {
604
+ age: 25,
605
+ country: "US",
606
+ premium: false,
607
+ score: 100,
608
+ },
609
+ { tags: ["vip"] },
610
+ );
611
+
612
+ expect(result.totalRulesEvaluated).toBe(1);
613
+ expect(result.matchedRules[0]?.name).toBe("vip-rule");
614
+ });
615
+
616
+ it("should limit max rules evaluated", async () => {
617
+ engine.addRules([
618
+ {
619
+ name: "rule-1",
620
+ priority: 100,
621
+ conditions: cond("g1", []),
622
+ consequences: [
623
+ {
624
+ type: "setFlag",
625
+ payload: { name: "r1", value: true },
626
+ },
627
+ ],
628
+ },
629
+ {
630
+ name: "rule-2",
631
+ priority: 90,
632
+ conditions: cond("g2", []),
633
+ consequences: [
634
+ {
635
+ type: "setFlag",
636
+ payload: { name: "r2", value: true },
637
+ },
638
+ ],
639
+ },
640
+ {
641
+ name: "rule-3",
642
+ priority: 80,
643
+ conditions: cond("g3", []),
644
+ consequences: [
645
+ {
646
+ type: "setFlag",
647
+ payload: { name: "r3", value: true },
648
+ },
649
+ ],
650
+ },
651
+ ]);
652
+
653
+ const result = await engine.evaluate(
654
+ {
655
+ age: 25,
656
+ country: "US",
657
+ premium: false,
658
+ score: 100,
659
+ },
660
+ { maxRules: 2 },
661
+ );
662
+
663
+ expect(result.totalRulesEvaluated).toBe(2);
664
+ });
665
+
666
+ it("should evaluate only rules in specified rule set", async () => {
667
+ const rule1 = engine.addRule({
668
+ name: "in-set",
669
+ conditions: cond("g1", []),
670
+ consequences: [
671
+ {
672
+ type: "setFlag",
673
+ payload: { name: "inSet", value: true },
674
+ },
675
+ ],
676
+ });
677
+ engine.addRule({
678
+ name: "not-in-set",
679
+ conditions: cond("g2", []),
680
+ consequences: [
681
+ {
682
+ type: "setFlag",
683
+ payload: { name: "notInSet", value: true },
684
+ },
685
+ ],
686
+ });
687
+
688
+ const ruleSet = engine.addRuleSet({
689
+ name: "test-set",
690
+ ruleIds: [rule1.id],
691
+ });
692
+
693
+ const result = await engine.evaluate(
694
+ {
695
+ age: 25,
696
+ country: "US",
697
+ premium: false,
698
+ score: 100,
699
+ },
700
+ { ruleSetId: ruleSet.id },
701
+ );
702
+
703
+ expect(result.totalRulesEvaluated).toBe(1);
704
+ expect(result.matchedRules[0]?.name).toBe("in-set");
705
+ });
706
+ });
707
+
708
+ describe("caching", () => {
709
+ it("should cache evaluation results", async () => {
710
+ engine.addRule({
711
+ name: "cached-rule",
712
+ conditions: cond("g1", []),
713
+ consequences: [
714
+ {
715
+ type: "setFlag",
716
+ payload: { name: "cached", value: true },
717
+ },
718
+ ],
719
+ });
720
+
721
+ const context = {
722
+ age: 25,
723
+ country: "US",
724
+ premium: false,
725
+ score: 100,
726
+ };
727
+
728
+ const result1 = await engine.evaluate(context);
729
+ const result2 = await engine.evaluate(context);
730
+
731
+ expect(result1.cacheHit).toBe(false);
732
+ expect(result2.cacheHit).toBe(true);
733
+ });
734
+
735
+ it("should bypass cache when requested", async () => {
736
+ engine.addRule({
737
+ name: "rule",
738
+ conditions: cond("g1", []),
739
+ consequences: [
740
+ {
741
+ type: "setFlag",
742
+ payload: { name: "test", value: true },
743
+ },
744
+ ],
745
+ });
746
+
747
+ const context = {
748
+ age: 25,
749
+ country: "US",
750
+ premium: false,
751
+ score: 100,
752
+ };
753
+
754
+ await engine.evaluate(context);
755
+ const result = await engine.evaluate(context, { bypassCache: true });
756
+
757
+ expect(result.cacheHit).toBe(false);
758
+ });
759
+
760
+ it("should clear cache", async () => {
761
+ engine.addRule({
762
+ name: "rule",
763
+ conditions: cond("g1", []),
764
+ consequences: [
765
+ {
766
+ type: "setFlag",
767
+ payload: { name: "test", value: true },
768
+ },
769
+ ],
770
+ });
771
+
772
+ const context = {
773
+ age: 25,
774
+ country: "US",
775
+ premium: false,
776
+ score: 100,
777
+ };
778
+
779
+ await engine.evaluate(context);
780
+ engine.clearCache();
781
+ const result = await engine.evaluate(context);
782
+
783
+ expect(result.cacheHit).toBe(false);
784
+ });
785
+
786
+ it("should clear cache when rules are modified", async () => {
787
+ const rule = engine.addRule({
788
+ name: "rule",
789
+ conditions: cond("g1", []),
790
+ consequences: [
791
+ {
792
+ type: "setFlag",
793
+ payload: { name: "test", value: true },
794
+ },
795
+ ],
796
+ });
797
+
798
+ const context = {
799
+ age: 25,
800
+ country: "US",
801
+ premium: false,
802
+ score: 100,
803
+ };
804
+
805
+ await engine.evaluate(context);
806
+
807
+ engine.updateRule(rule.id, { priority: 100 });
808
+ const result = await engine.evaluate(context);
809
+
810
+ expect(result.cacheHit).toBe(false);
811
+ });
812
+
813
+ it("should work without cache", async () => {
814
+ const noCacheEngine = createEngine<TestContext, TestConsequences>({
815
+ consequences: TestConsequences,
816
+ cache: { enabled: false },
817
+ evaluator: createEvaluator(),
818
+ });
819
+
820
+ noCacheEngine.addRule({
821
+ name: "rule",
822
+ conditions: cond("g1", []),
823
+ consequences: [
824
+ {
825
+ type: "setFlag",
826
+ payload: { name: "test", value: true },
827
+ },
828
+ ],
829
+ });
830
+
831
+ const context = {
832
+ age: 25,
833
+ country: "US",
834
+ premium: false,
835
+ score: 100,
836
+ };
837
+
838
+ const result1 = await noCacheEngine.evaluate(context);
839
+ const result2 = await noCacheEngine.evaluate(context);
840
+
841
+ expect(result1.cacheHit).toBe(false);
842
+ expect(result2.cacheHit).toBe(false);
843
+ });
844
+ });
845
+
846
+ describe("statistics", () => {
847
+ it("should track evaluation stats", async () => {
848
+ engine.addRule({
849
+ name: "rule",
850
+ conditions: cond("g1", []),
851
+ consequences: [
852
+ {
853
+ type: "setFlag",
854
+ payload: { name: "test", value: true },
855
+ },
856
+ ],
857
+ });
858
+
859
+ await engine.evaluate(
860
+ { age: 25, country: "US", premium: false, score: 100 },
861
+ { bypassCache: true },
862
+ );
863
+ await engine.evaluate(
864
+ { age: 30, country: "US", premium: false, score: 100 },
865
+ { bypassCache: true },
866
+ );
867
+
868
+ const stats = engine.getStats();
869
+
870
+ expect(stats.totalRules).toBe(1);
871
+ expect(stats.enabledRules).toBe(1);
872
+ expect(stats.disabledRules).toBe(0);
873
+ expect(stats.totalEvaluations).toBe(2);
874
+ expect(stats.totalMatches).toBe(2);
875
+ expect(stats.avgEvaluationTimeMs).toBeGreaterThanOrEqual(0);
876
+ });
877
+
878
+ it("should track cache hit rate", async () => {
879
+ engine.addRule({
880
+ name: "rule",
881
+ conditions: cond("g1", []),
882
+ consequences: [
883
+ {
884
+ type: "setFlag",
885
+ payload: { name: "test", value: true },
886
+ },
887
+ ],
888
+ });
889
+
890
+ const context = {
891
+ age: 25,
892
+ country: "US",
893
+ premium: false,
894
+ score: 100,
895
+ };
896
+
897
+ await engine.evaluate(context);
898
+ await engine.evaluate(context);
899
+ await engine.evaluate(context);
900
+
901
+ const stats = engine.getStats();
902
+
903
+ expect(stats.cacheHits).toBe(2);
904
+ expect(stats.cacheMisses).toBe(1);
905
+ expect(stats.cacheHitRate).toBeCloseTo(2 / 3, 2);
906
+ });
907
+ });
908
+
909
+ describe("hooks", () => {
910
+ it("should call beforeEvaluation hook", async () => {
911
+ let hookCalled = false;
912
+
913
+ const hookEngine = createEngine<TestContext, TestConsequences>({
914
+ consequences: TestConsequences,
915
+ evaluator: createEvaluator(),
916
+ hooks: {
917
+ beforeEvaluation: (context) => {
918
+ hookCalled = true;
919
+ expect(context.data.age).toBe(25);
920
+ },
921
+ },
922
+ });
923
+
924
+ hookEngine.addRule({
925
+ name: "rule",
926
+ conditions: cond("g1", []),
927
+ consequences: [
928
+ {
929
+ type: "setFlag",
930
+ payload: { name: "test", value: true },
931
+ },
932
+ ],
933
+ });
934
+
935
+ await hookEngine.evaluate({
936
+ age: 25,
937
+ country: "US",
938
+ premium: false,
939
+ score: 100,
940
+ });
941
+
942
+ expect(hookCalled).toBe(true);
943
+ });
944
+
945
+ it("should call afterEvaluation hook", async () => {
946
+ let hookCalled = false;
947
+
948
+ const hookEngine = createEngine<TestContext, TestConsequences>({
949
+ consequences: TestConsequences,
950
+ evaluator: createEvaluator(),
951
+ hooks: {
952
+ afterEvaluation: (result) => {
953
+ hookCalled = true;
954
+ expect(result.totalRulesMatched).toBe(1);
955
+ },
956
+ },
957
+ });
958
+
959
+ hookEngine.addRule({
960
+ name: "rule",
961
+ conditions: cond("g1", []),
962
+ consequences: [
963
+ {
964
+ type: "setFlag",
965
+ payload: { name: "test", value: true },
966
+ },
967
+ ],
968
+ });
969
+
970
+ await hookEngine.evaluate({
971
+ age: 25,
972
+ country: "US",
973
+ premium: false,
974
+ score: 100,
975
+ });
976
+
977
+ expect(hookCalled).toBe(true);
978
+ });
979
+
980
+ it("should call onRuleMatch hook", async () => {
981
+ const matchedRuleNames: string[] = [];
982
+
983
+ const hookEngine = createEngine<TestContext, TestConsequences>({
984
+ consequences: TestConsequences,
985
+ evaluator: createEvaluator(),
986
+ hooks: {
987
+ onRuleMatch: (rule) => {
988
+ matchedRuleNames.push(rule.name);
989
+ },
990
+ },
991
+ });
992
+
993
+ hookEngine.addRule({
994
+ name: "match-me",
995
+ conditions: cond("g1", []),
996
+ consequences: [
997
+ {
998
+ type: "setFlag",
999
+ payload: { name: "test", value: true },
1000
+ },
1001
+ ],
1002
+ });
1003
+
1004
+ await hookEngine.evaluate({
1005
+ age: 25,
1006
+ country: "US",
1007
+ premium: false,
1008
+ score: 100,
1009
+ });
1010
+
1011
+ expect(matchedRuleNames).toContain("match-me");
1012
+ });
1013
+
1014
+ it("should call onConsequenceCollected hook", async () => {
1015
+ const collectedConsequences: string[] = [];
1016
+
1017
+ const hookEngine = createEngine<TestContext, TestConsequences>({
1018
+ consequences: TestConsequences,
1019
+ evaluator: createEvaluator(),
1020
+ hooks: {
1021
+ onConsequenceCollected: (_rule, consequence) => {
1022
+ collectedConsequences.push(consequence.type as string);
1023
+ },
1024
+ },
1025
+ });
1026
+
1027
+ hookEngine.addRule({
1028
+ name: "rule",
1029
+ conditions: cond("g1", []),
1030
+ consequences: [
1031
+ {
1032
+ type: "applyDiscount",
1033
+ payload: { percentage: 10, reason: "Test" },
1034
+ },
1035
+ {
1036
+ type: "sendNotification",
1037
+ payload: { type: "email", message: "Discount applied" },
1038
+ },
1039
+ ],
1040
+ });
1041
+
1042
+ await hookEngine.evaluate({
1043
+ age: 25,
1044
+ country: "US",
1045
+ premium: false,
1046
+ score: 100,
1047
+ });
1048
+
1049
+ expect(collectedConsequences).toContain("applyDiscount");
1050
+ expect(collectedConsequences).toContain("sendNotification");
1051
+ });
1052
+ });
1053
+
1054
+ describe("state", () => {
1055
+ it("should return engine state", () => {
1056
+ const rule = engine.addRule({
1057
+ name: "rule",
1058
+ conditions: cond("g1", []),
1059
+ consequences: [
1060
+ {
1061
+ type: "setFlag",
1062
+ payload: { name: "test", value: true },
1063
+ },
1064
+ ],
1065
+ });
1066
+
1067
+ engine.addRuleSet({
1068
+ name: "set",
1069
+ ruleIds: [rule.id],
1070
+ });
1071
+
1072
+ const state = engine.getState();
1073
+
1074
+ expect(state.rules.size).toBe(1);
1075
+ expect(state.ruleSets.size).toBe(1);
1076
+ expect(state.ruleOrder).toHaveLength(1);
1077
+ });
1078
+ });
1079
+ });
1080
+
1081
+ describe("complex conditions", () => {
1082
+ it("should handle nested AND/OR conditions", async () => {
1083
+ const engine = createEngine<TestContext, TestConsequences>({
1084
+ consequences: TestConsequences,
1085
+ evaluator: createEvaluator(),
1086
+ });
1087
+
1088
+ engine.addRule({
1089
+ name: "complex-rule",
1090
+ conditions: cond("g1", [
1091
+ numCond("c1", "age", "gte", 18),
1092
+ condOr("g2", [
1093
+ boolCond("c2", "premium", "eq", true),
1094
+ numCond("c3", "score", "gte", 90),
1095
+ ]),
1096
+ ]),
1097
+ consequences: [
1098
+ {
1099
+ type: "applyDiscount",
1100
+ payload: { percentage: 15, reason: "Complex condition met" },
1101
+ },
1102
+ ],
1103
+ });
1104
+
1105
+ const adult_premium = await engine.evaluate({
1106
+ age: 25,
1107
+ country: "US",
1108
+ premium: true,
1109
+ score: 50,
1110
+ });
1111
+
1112
+ const adult_high_score = await engine.evaluate(
1113
+ { age: 25, country: "US", premium: false, score: 95 },
1114
+ { bypassCache: true },
1115
+ );
1116
+
1117
+ const adult_low_score = await engine.evaluate(
1118
+ { age: 25, country: "US", premium: false, score: 50 },
1119
+ { bypassCache: true },
1120
+ );
1121
+
1122
+ const minor_premium = await engine.evaluate(
1123
+ { age: 16, country: "US", premium: true, score: 50 },
1124
+ { bypassCache: true },
1125
+ );
1126
+
1127
+ expect(adult_premium.totalRulesMatched).toBe(1);
1128
+ expect(adult_high_score.totalRulesMatched).toBe(1);
1129
+ expect(adult_low_score.totalRulesMatched).toBe(0);
1130
+ expect(minor_premium.totalRulesMatched).toBe(0);
1131
+ });
1132
+ });
1133
+
1134
+ describe("Engine with custom operators", () => {
1135
+ it("should throw error when neither evaluator nor operators provided", () => {
1136
+ expect(() => {
1137
+ createEngine<TestContext, TestConsequences>({
1138
+ consequences: TestConsequences,
1139
+ });
1140
+ }).toThrow("Engine requires either 'evaluator' or 'operators' config");
1141
+ });
1142
+
1143
+ it("should accept evaluator in config", () => {
1144
+ const evaluator = createEvaluator();
1145
+
1146
+ const engine = createEngine<TestContext, TestConsequences>({
1147
+ consequences: TestConsequences,
1148
+ evaluator,
1149
+ });
1150
+
1151
+ expect(engine).toBeDefined();
1152
+ });
1153
+
1154
+ it("should create evaluator from operators config", () => {
1155
+ const customOp = createOperator({
1156
+ name: "always_match",
1157
+ type: "custom",
1158
+ evaluate: () => true,
1159
+ });
1160
+
1161
+ const engine = createEngine<TestContext, TestConsequences>({
1162
+ consequences: TestConsequences,
1163
+ operators: { always_match: customOp },
1164
+ });
1165
+
1166
+ expect(engine).toBeDefined();
1167
+ });
1168
+
1169
+ it("should use custom operator in rule evaluation", async () => {
1170
+ const customOp = createOperator({
1171
+ name: "always_match",
1172
+ type: "custom",
1173
+ evaluate: () => true,
1174
+ });
1175
+
1176
+ const engine = createEngine<TestContext, TestConsequences>({
1177
+ consequences: TestConsequences,
1178
+ operators: { always_match: customOp },
1179
+ });
1180
+
1181
+ engine.addRule({
1182
+ name: "custom-op-rule",
1183
+ conditions: {
1184
+ id: "g1",
1185
+ operator: "AND",
1186
+ conditions: [
1187
+ {
1188
+ id: "c1",
1189
+ type: "custom",
1190
+ field: "anything",
1191
+ operator: "always_match",
1192
+ },
1193
+ ],
1194
+ },
1195
+ consequences: [
1196
+ {
1197
+ type: "setFlag",
1198
+ payload: { name: "matched", value: true },
1199
+ },
1200
+ ],
1201
+ });
1202
+
1203
+ const result = await engine.evaluate({
1204
+ age: 25,
1205
+ country: "BR",
1206
+ premium: false,
1207
+ score: 100
1208
+ });
1209
+
1210
+ expect(result.matchedRules).toHaveLength(1);
1211
+ expect(result.matchedRules[0]?.name).toBe("custom-op-rule");
1212
+ });
1213
+ });