@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,862 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConditionGroup } from "@f-o-t/condition-evaluator";
3
+ import { z } from "zod";
4
+ import type { Rule } from "../src/types/rule";
5
+ import {
6
+ detectConflicts,
7
+ formatConflicts,
8
+ getConflictsByType,
9
+ hasConflicts,
10
+ hasErrors,
11
+ } from "../src/validation/conflicts";
12
+ import {
13
+ checkIntegrity,
14
+ checkRuleFieldCoverage,
15
+ formatIntegrityResult,
16
+ getUsedFields,
17
+ getUsedOperators,
18
+ } from "../src/validation/integrity";
19
+ import {
20
+ createRuleValidator,
21
+ parseRule,
22
+ safeParseRule,
23
+ validateConditions,
24
+ validateRule,
25
+ validateRules,
26
+ } from "../src/validation/schema";
27
+
28
+ const createValidConditions = (): ConditionGroup => ({
29
+ id: "group-1",
30
+ operator: "AND",
31
+ conditions: [
32
+ {
33
+ id: "cond-1",
34
+ type: "number",
35
+ field: "amount",
36
+ operator: "gt",
37
+ value: 100,
38
+ },
39
+ ],
40
+ });
41
+
42
+ const createValidRule = (overrides: Partial<Rule> = {}): Rule => ({
43
+ id: "rule-1",
44
+ name: "Test Rule",
45
+ conditions: createValidConditions(),
46
+ consequences: [{ type: "apply_discount", payload: { percentage: 10 } }],
47
+ priority: 0,
48
+ enabled: true,
49
+ stopOnMatch: false,
50
+ tags: [],
51
+ createdAt: new Date(),
52
+ updatedAt: new Date(),
53
+ ...overrides,
54
+ });
55
+
56
+ describe("Schema Validation", () => {
57
+ describe("validateRule", () => {
58
+ test("validates a valid rule", () => {
59
+ const rule = createValidRule();
60
+ const result = validateRule(rule);
61
+ expect(result.valid).toBe(true);
62
+ expect(result.errors).toHaveLength(0);
63
+ });
64
+
65
+ test("rejects rule without id", () => {
66
+ const rule = createValidRule({ id: "" });
67
+ const result = validateRule(rule);
68
+ expect(result.valid).toBe(false);
69
+ expect(result.errors.some((e) => e.path.includes("id"))).toBe(true);
70
+ });
71
+
72
+ test("rejects rule without name", () => {
73
+ const rule = createValidRule({ name: "" });
74
+ const result = validateRule(rule);
75
+ expect(result.valid).toBe(false);
76
+ expect(result.errors.some((e) => e.path.includes("name"))).toBe(true);
77
+ });
78
+
79
+ test("validates conditions structure", () => {
80
+ const rule = createValidRule({
81
+ conditions: {
82
+ id: "",
83
+ operator: "AND",
84
+ conditions: [],
85
+ },
86
+ });
87
+ const result = validateRule(rule, { validateConditions: true });
88
+ expect(result.valid).toBe(false);
89
+ expect(result.errors.some((e) => e.code === "MISSING_GROUP_ID")).toBe(
90
+ true,
91
+ );
92
+ });
93
+
94
+ test("validates empty conditions array", () => {
95
+ const rule = createValidRule({
96
+ conditions: {
97
+ id: "group-1",
98
+ operator: "AND",
99
+ conditions: [],
100
+ },
101
+ });
102
+ const result = validateRule(rule, { validateConditions: true });
103
+ expect(result.valid).toBe(false);
104
+ expect(
105
+ result.errors.some((e) => e.code === "EMPTY_CONDITIONS_ARRAY"),
106
+ ).toBe(true);
107
+ });
108
+
109
+ test("validates nested condition groups", () => {
110
+ const rule = createValidRule({
111
+ conditions: {
112
+ id: "group-1",
113
+ operator: "AND",
114
+ conditions: [
115
+ {
116
+ id: "nested-group",
117
+ operator: "OR",
118
+ conditions: [
119
+ {
120
+ id: "cond-1",
121
+ type: "number",
122
+ field: "amount",
123
+ operator: "gt",
124
+ value: 100,
125
+ },
126
+ {
127
+ id: "cond-2",
128
+ type: "string",
129
+ field: "status",
130
+ operator: "eq",
131
+ value: "active",
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ },
137
+ });
138
+ const result = validateRule(rule);
139
+ expect(result.valid).toBe(true);
140
+ });
141
+
142
+ test("skips condition validation when disabled", () => {
143
+ const rule = createValidRule({
144
+ conditions: {
145
+ id: "",
146
+ operator: "AND",
147
+ conditions: [],
148
+ },
149
+ });
150
+ const result = validateRule(rule, { validateConditions: false });
151
+ expect(result.valid).toBe(true);
152
+ });
153
+ });
154
+
155
+ describe("validateRules", () => {
156
+ test("validates multiple rules", () => {
157
+ const rules = [
158
+ createValidRule({ id: "rule-1" }),
159
+ createValidRule({ id: "rule-2", name: "Rule 2" }),
160
+ ];
161
+ const result = validateRules(rules);
162
+ expect(result.valid).toBe(true);
163
+ });
164
+
165
+ test("reports errors with rule index", () => {
166
+ const rules = [
167
+ createValidRule({ id: "rule-1" }),
168
+ createValidRule({ id: "", name: "Invalid Rule" }),
169
+ ];
170
+ const result = validateRules(rules);
171
+ expect(result.valid).toBe(false);
172
+ expect(result.errors.some((e) => e.path.includes("rules[1]"))).toBe(
173
+ true,
174
+ );
175
+ });
176
+ });
177
+
178
+ describe("validateConditions", () => {
179
+ test("validates valid conditions", () => {
180
+ const result = validateConditions(createValidConditions());
181
+ expect(result.valid).toBe(true);
182
+ });
183
+
184
+ test("rejects invalid operator", () => {
185
+ const conditions: ConditionGroup = {
186
+ id: "group-1",
187
+ operator: "INVALID" as "AND",
188
+ conditions: [
189
+ {
190
+ id: "cond-1",
191
+ type: "number",
192
+ field: "amount",
193
+ operator: "gt",
194
+ value: 100,
195
+ },
196
+ ],
197
+ };
198
+ const result = validateConditions(conditions);
199
+ expect(result.valid).toBe(false);
200
+ expect(
201
+ result.errors.some((e) => e.code === "INVALID_GROUP_OPERATOR"),
202
+ ).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("parseRule", () => {
207
+ test("returns rule on valid input", () => {
208
+ const rule = createValidRule();
209
+ const parsed = parseRule(rule);
210
+ expect(parsed.id).toBe(rule.id);
211
+ });
212
+
213
+ test("throws on invalid input", () => {
214
+ const rule = createValidRule({ id: "" });
215
+ expect(() => parseRule(rule)).toThrow();
216
+ });
217
+ });
218
+
219
+ describe("safeParseRule", () => {
220
+ test("returns success on valid input", () => {
221
+ const rule = createValidRule();
222
+ const result = safeParseRule(rule);
223
+ expect(result.success).toBe(true);
224
+ if (result.success) {
225
+ expect(result.data.id).toBe(rule.id);
226
+ }
227
+ });
228
+
229
+ test("returns errors on invalid input", () => {
230
+ const rule = createValidRule({ id: "" });
231
+ const result = safeParseRule(rule);
232
+ expect(result.success).toBe(false);
233
+ if (!result.success) {
234
+ expect(result.errors.length).toBeGreaterThan(0);
235
+ }
236
+ });
237
+ });
238
+
239
+ describe("createRuleValidator", () => {
240
+ const consequenceSchemas = {
241
+ apply_discount: z.object({ percentage: z.number().min(0).max(100) }),
242
+ send_notification: z.object({ message: z.string() }),
243
+ };
244
+
245
+ test("creates validator with custom schemas", () => {
246
+ const validator = createRuleValidator(consequenceSchemas, {
247
+ strictMode: true,
248
+ });
249
+ const rule = createValidRule();
250
+ const result = validator.validate(rule);
251
+ expect(result.valid).toBe(true);
252
+ });
253
+
254
+ test("validates consequence payloads in strict mode", () => {
255
+ const validator = createRuleValidator(consequenceSchemas, {
256
+ strictMode: true,
257
+ });
258
+ const rule = createValidRule({
259
+ consequences: [
260
+ { type: "apply_discount", payload: { percentage: 150 } },
261
+ ],
262
+ });
263
+ const result = validator.validate(rule);
264
+ expect(result.valid).toBe(false);
265
+ });
266
+
267
+ test("rejects unknown consequence types in strict mode", () => {
268
+ const validator = createRuleValidator(consequenceSchemas, {
269
+ strictMode: true,
270
+ });
271
+ const rule = createValidRule({
272
+ consequences: [{ type: "unknown_action", payload: {} }],
273
+ });
274
+ const result = validator.validate(rule);
275
+ expect(result.valid).toBe(false);
276
+ expect(
277
+ result.errors.some((e) => e.code === "UNKNOWN_CONSEQUENCE_TYPE"),
278
+ ).toBe(true);
279
+ });
280
+ });
281
+ });
282
+
283
+ describe("Conflict Detection", () => {
284
+ describe("detectConflicts", () => {
285
+ test("detects duplicate IDs", () => {
286
+ const rules = [
287
+ createValidRule({ id: "duplicate-id" }),
288
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
289
+ ];
290
+ const conflicts = detectConflicts(rules);
291
+ expect(conflicts.some((c) => c.type === "DUPLICATE_ID")).toBe(true);
292
+ });
293
+
294
+ test("detects duplicate conditions", () => {
295
+ const conditions = createValidConditions();
296
+ const rules = [
297
+ createValidRule({ id: "rule-1", conditions }),
298
+ createValidRule({ id: "rule-2", name: "Rule 2", conditions }),
299
+ ];
300
+ const conflicts = detectConflicts(rules);
301
+ expect(conflicts.some((c) => c.type === "DUPLICATE_CONDITIONS")).toBe(
302
+ true,
303
+ );
304
+ });
305
+
306
+ test("detects overlapping conditions", () => {
307
+ const rules = [
308
+ createValidRule({
309
+ id: "rule-1",
310
+ conditions: {
311
+ id: "g1",
312
+ operator: "AND",
313
+ conditions: [
314
+ {
315
+ id: "c1",
316
+ type: "number",
317
+ field: "amount",
318
+ operator: "gt",
319
+ value: 100,
320
+ },
321
+ ],
322
+ },
323
+ }),
324
+ createValidRule({
325
+ id: "rule-2",
326
+ name: "Rule 2",
327
+ conditions: {
328
+ id: "g2",
329
+ operator: "AND",
330
+ conditions: [
331
+ {
332
+ id: "c2",
333
+ type: "number",
334
+ field: "amount",
335
+ operator: "gt",
336
+ value: 100,
337
+ },
338
+ ],
339
+ },
340
+ }),
341
+ ];
342
+ const conflicts = detectConflicts(rules);
343
+ expect(
344
+ conflicts.some((c) => c.type === "OVERLAPPING_CONDITIONS"),
345
+ ).toBe(true);
346
+ });
347
+
348
+ test("detects priority collisions", () => {
349
+ const rules = [
350
+ createValidRule({
351
+ id: "rule-1",
352
+ priority: 10,
353
+ conditions: {
354
+ id: "g1",
355
+ operator: "AND",
356
+ conditions: [
357
+ {
358
+ id: "c1",
359
+ type: "number",
360
+ field: "amount",
361
+ operator: "gt",
362
+ value: 100,
363
+ },
364
+ ],
365
+ },
366
+ }),
367
+ createValidRule({
368
+ id: "rule-2",
369
+ name: "Rule 2",
370
+ priority: 10,
371
+ conditions: {
372
+ id: "g2",
373
+ operator: "AND",
374
+ conditions: [
375
+ {
376
+ id: "c2",
377
+ type: "number",
378
+ field: "amount",
379
+ operator: "gt",
380
+ value: 100,
381
+ },
382
+ ],
383
+ },
384
+ }),
385
+ ];
386
+ const conflicts = detectConflicts(rules);
387
+ expect(conflicts.some((c) => c.type === "PRIORITY_COLLISION")).toBe(
388
+ true,
389
+ );
390
+ });
391
+
392
+ test("detects unreachable rules", () => {
393
+ const conditions = createValidConditions();
394
+ const rules = [
395
+ createValidRule({
396
+ id: "rule-1",
397
+ priority: 100,
398
+ stopOnMatch: true,
399
+ conditions,
400
+ }),
401
+ createValidRule({
402
+ id: "rule-2",
403
+ name: "Rule 2",
404
+ priority: 50,
405
+ conditions,
406
+ }),
407
+ ];
408
+ const conflicts = detectConflicts(rules);
409
+ expect(conflicts.some((c) => c.type === "UNREACHABLE_RULE")).toBe(
410
+ true,
411
+ );
412
+ });
413
+
414
+ test("respects detection options", () => {
415
+ const rules = [
416
+ createValidRule({ id: "duplicate-id" }),
417
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
418
+ ];
419
+ const conflicts = detectConflicts(rules, { checkDuplicateIds: false });
420
+ expect(conflicts.some((c) => c.type === "DUPLICATE_ID")).toBe(false);
421
+ });
422
+ });
423
+
424
+ describe("hasConflicts", () => {
425
+ test("returns true when conflicts exist", () => {
426
+ const rules = [
427
+ createValidRule({ id: "duplicate-id" }),
428
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
429
+ ];
430
+ expect(hasConflicts(rules)).toBe(true);
431
+ });
432
+
433
+ test("returns false when no conflicts", () => {
434
+ const rules = [
435
+ createValidRule({ id: "rule-1", priority: 1 }),
436
+ createValidRule({ id: "rule-2", name: "Rule 2", priority: 2 }),
437
+ ];
438
+ expect(
439
+ hasConflicts(rules, {
440
+ checkDuplicateConditions: false,
441
+ checkOverlappingConditions: false,
442
+ checkPriorityCollisions: false,
443
+ checkUnreachableRules: false,
444
+ }),
445
+ ).toBe(false);
446
+ });
447
+ });
448
+
449
+ describe("hasErrors", () => {
450
+ test("returns true for error severity conflicts", () => {
451
+ const rules = [
452
+ createValidRule({ id: "duplicate-id" }),
453
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
454
+ ];
455
+ expect(hasErrors(rules)).toBe(true);
456
+ });
457
+
458
+ test("returns false for warning-only conflicts", () => {
459
+ const conditions = createValidConditions();
460
+ const rules = [
461
+ createValidRule({ id: "rule-1", conditions }),
462
+ createValidRule({ id: "rule-2", name: "Rule 2", conditions }),
463
+ ];
464
+ const conflicts = detectConflicts(rules, { checkDuplicateIds: false });
465
+ expect(conflicts.every((c) => c.severity !== "error")).toBe(true);
466
+ });
467
+ });
468
+
469
+ describe("getConflictsByType", () => {
470
+ test("filters conflicts by type", () => {
471
+ const rules = [
472
+ createValidRule({ id: "duplicate-id" }),
473
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
474
+ ];
475
+ const conflicts = detectConflicts(rules);
476
+ const duplicateIdConflicts = getConflictsByType(
477
+ conflicts,
478
+ "DUPLICATE_ID",
479
+ );
480
+ expect(
481
+ duplicateIdConflicts.every((c) => c.type === "DUPLICATE_ID"),
482
+ ).toBe(true);
483
+ });
484
+ });
485
+
486
+ describe("formatConflicts", () => {
487
+ test("formats conflicts as string", () => {
488
+ const rules = [
489
+ createValidRule({ id: "duplicate-id" }),
490
+ createValidRule({ id: "duplicate-id", name: "Rule 2" }),
491
+ ];
492
+ const conflicts = detectConflicts(rules);
493
+ const formatted = formatConflicts(conflicts);
494
+ expect(formatted).toContain("conflict");
495
+ expect(formatted).toContain("DUPLICATE_ID");
496
+ });
497
+
498
+ test("returns message when no conflicts", () => {
499
+ const formatted = formatConflicts([]);
500
+ expect(formatted).toBe("No conflicts detected");
501
+ });
502
+ });
503
+ });
504
+
505
+ describe("Integrity Checks", () => {
506
+ describe("checkIntegrity", () => {
507
+ test("passes for valid rules", () => {
508
+ const rules = [createValidRule()];
509
+ const result = checkIntegrity(rules);
510
+ expect(result.valid).toBe(true);
511
+ });
512
+
513
+ test("detects duplicate condition IDs within a rule", () => {
514
+ const rule = createValidRule({
515
+ conditions: {
516
+ id: "group-1",
517
+ operator: "AND",
518
+ conditions: [
519
+ {
520
+ id: "duplicate-id",
521
+ type: "number",
522
+ field: "amount",
523
+ operator: "gt",
524
+ value: 100,
525
+ },
526
+ {
527
+ id: "duplicate-id",
528
+ type: "string",
529
+ field: "status",
530
+ operator: "eq",
531
+ value: "active",
532
+ },
533
+ ],
534
+ },
535
+ });
536
+ const result = checkIntegrity([rule]);
537
+ expect(
538
+ result.issues.some((i) => i.code === "DUPLICATE_CONDITION_ID"),
539
+ ).toBe(true);
540
+ });
541
+
542
+ test("warns about negative priority", () => {
543
+ const rule = createValidRule({ priority: -10 });
544
+ const result = checkIntegrity([rule]);
545
+ expect(result.issues.some((i) => i.code === "NEGATIVE_PRIORITY")).toBe(
546
+ true,
547
+ );
548
+ });
549
+
550
+ test("warns about no consequences", () => {
551
+ const rule = createValidRule({ consequences: [] });
552
+ const result = checkIntegrity([rule]);
553
+ expect(result.issues.some((i) => i.code === "NO_CONSEQUENCES")).toBe(
554
+ true,
555
+ );
556
+ });
557
+
558
+ test("validates allowed categories", () => {
559
+ const rule = createValidRule({ category: "invalid-category" });
560
+ const result = checkIntegrity([rule], [], {
561
+ allowedCategories: ["pricing", "shipping"],
562
+ });
563
+ expect(result.valid).toBe(false);
564
+ expect(result.issues.some((i) => i.code === "INVALID_CATEGORY")).toBe(
565
+ true,
566
+ );
567
+ });
568
+
569
+ test("validates allowed tags", () => {
570
+ const rule = createValidRule({ tags: ["valid-tag", "invalid-tag"] });
571
+ const result = checkIntegrity([rule], [], {
572
+ allowedTags: ["valid-tag"],
573
+ });
574
+ expect(result.issues.some((i) => i.code === "INVALID_TAGS")).toBe(
575
+ true,
576
+ );
577
+ });
578
+
579
+ test("checks field consistency across rules", () => {
580
+ const rules = [
581
+ createValidRule({
582
+ id: "rule-1",
583
+ conditions: {
584
+ id: "g1",
585
+ operator: "AND",
586
+ conditions: [
587
+ {
588
+ id: "c1",
589
+ type: "number",
590
+ field: "amount",
591
+ operator: "gt",
592
+ value: 100,
593
+ },
594
+ ],
595
+ },
596
+ }),
597
+ createValidRule({
598
+ id: "rule-2",
599
+ name: "Rule 2",
600
+ conditions: {
601
+ id: "g2",
602
+ operator: "AND",
603
+ conditions: [
604
+ {
605
+ id: "c2",
606
+ type: "string",
607
+ field: "amount",
608
+ operator: "eq",
609
+ value: "high",
610
+ },
611
+ ],
612
+ },
613
+ }),
614
+ ];
615
+ const result = checkIntegrity(rules);
616
+ expect(
617
+ result.issues.some((i) => i.code === "INCONSISTENT_FIELD_TYPE"),
618
+ ).toBe(true);
619
+ });
620
+
621
+ test("validates rule set references", () => {
622
+ const rules = [createValidRule({ id: "rule-1" })];
623
+ const ruleSets = [
624
+ {
625
+ id: "set-1",
626
+ name: "Test Set",
627
+ ruleIds: ["rule-1", "non-existent-rule"],
628
+ enabled: true,
629
+ },
630
+ ];
631
+ const result = checkIntegrity(rules, ruleSets);
632
+ expect(result.valid).toBe(false);
633
+ expect(
634
+ result.issues.some((i) => i.code === "MISSING_RULE_REFERENCE"),
635
+ ).toBe(true);
636
+ });
637
+
638
+ test("warns about empty rule sets", () => {
639
+ const rules = [createValidRule()];
640
+ const ruleSets = [
641
+ {
642
+ id: "set-1",
643
+ name: "Empty Set",
644
+ ruleIds: [],
645
+ enabled: true,
646
+ },
647
+ ];
648
+ const result = checkIntegrity(rules, ruleSets);
649
+ expect(result.issues.some((i) => i.code === "EMPTY_RULESET")).toBe(
650
+ true,
651
+ );
652
+ });
653
+ });
654
+
655
+ describe("checkRuleFieldCoverage", () => {
656
+ test("reports covered and uncovered fields", () => {
657
+ const rules = [
658
+ createValidRule({
659
+ conditions: {
660
+ id: "g1",
661
+ operator: "AND",
662
+ conditions: [
663
+ {
664
+ id: "c1",
665
+ type: "number",
666
+ field: "amount",
667
+ operator: "gt",
668
+ value: 100,
669
+ },
670
+ {
671
+ id: "c2",
672
+ type: "string",
673
+ field: "status",
674
+ operator: "eq",
675
+ value: "active",
676
+ },
677
+ ],
678
+ },
679
+ }),
680
+ ];
681
+ const expectedFields = ["amount", "status", "category"];
682
+ const coverage = checkRuleFieldCoverage(rules, expectedFields);
683
+ expect(coverage.coveredFields).toContain("amount");
684
+ expect(coverage.coveredFields).toContain("status");
685
+ expect(coverage.uncoveredFields).toContain("category");
686
+ expect(coverage.coveragePercentage).toBeCloseTo(66.67, 0);
687
+ });
688
+
689
+ test("reports extra fields", () => {
690
+ const rules = [
691
+ createValidRule({
692
+ conditions: {
693
+ id: "g1",
694
+ operator: "AND",
695
+ conditions: [
696
+ {
697
+ id: "c1",
698
+ type: "number",
699
+ field: "amount",
700
+ operator: "gt",
701
+ value: 100,
702
+ },
703
+ {
704
+ id: "c2",
705
+ type: "string",
706
+ field: "extra_field",
707
+ operator: "eq",
708
+ value: "test",
709
+ },
710
+ ],
711
+ },
712
+ }),
713
+ ];
714
+ const coverage = checkRuleFieldCoverage(rules, ["amount"]);
715
+ expect(coverage.extraFields).toContain("extra_field");
716
+ });
717
+ });
718
+
719
+ describe("getUsedFields", () => {
720
+ test("returns all fields used in rules", () => {
721
+ const rules = [
722
+ createValidRule({
723
+ conditions: {
724
+ id: "g1",
725
+ operator: "AND",
726
+ conditions: [
727
+ {
728
+ id: "c1",
729
+ type: "number",
730
+ field: "amount",
731
+ operator: "gt",
732
+ value: 100,
733
+ },
734
+ {
735
+ id: "c2",
736
+ type: "string",
737
+ field: "status",
738
+ operator: "eq",
739
+ value: "active",
740
+ },
741
+ ],
742
+ },
743
+ }),
744
+ ];
745
+ const fields = getUsedFields(rules);
746
+ expect(fields).toContain("amount");
747
+ expect(fields).toContain("status");
748
+ });
749
+
750
+ test("returns sorted unique fields", () => {
751
+ const rules = [
752
+ createValidRule({
753
+ id: "rule-1",
754
+ conditions: {
755
+ id: "g1",
756
+ operator: "AND",
757
+ conditions: [
758
+ {
759
+ id: "c1",
760
+ type: "number",
761
+ field: "zebra",
762
+ operator: "gt",
763
+ value: 1,
764
+ },
765
+ ],
766
+ },
767
+ }),
768
+ createValidRule({
769
+ id: "rule-2",
770
+ name: "Rule 2",
771
+ conditions: {
772
+ id: "g2",
773
+ operator: "AND",
774
+ conditions: [
775
+ {
776
+ id: "c2",
777
+ type: "number",
778
+ field: "apple",
779
+ operator: "gt",
780
+ value: 1,
781
+ },
782
+ {
783
+ id: "c3",
784
+ type: "number",
785
+ field: "zebra",
786
+ operator: "lt",
787
+ value: 10,
788
+ },
789
+ ],
790
+ },
791
+ }),
792
+ ];
793
+ const fields = getUsedFields(rules);
794
+ expect(fields).toEqual(["apple", "zebra"]);
795
+ });
796
+ });
797
+
798
+ describe("getUsedOperators", () => {
799
+ test("returns all operators used in rules", () => {
800
+ const rules = [
801
+ createValidRule({
802
+ conditions: {
803
+ id: "g1",
804
+ operator: "AND",
805
+ conditions: [
806
+ {
807
+ id: "c1",
808
+ type: "number",
809
+ field: "amount",
810
+ operator: "gt",
811
+ value: 100,
812
+ },
813
+ {
814
+ id: "c2",
815
+ type: "string",
816
+ field: "status",
817
+ operator: "eq",
818
+ value: "active",
819
+ },
820
+ ],
821
+ },
822
+ }),
823
+ ];
824
+ const operators = getUsedOperators(rules);
825
+ expect(operators).toContainEqual({
826
+ field: "amount",
827
+ operator: "gt",
828
+ type: "number",
829
+ });
830
+ expect(operators).toContainEqual({
831
+ field: "status",
832
+ operator: "eq",
833
+ type: "string",
834
+ });
835
+ });
836
+ });
837
+
838
+ describe("formatIntegrityResult", () => {
839
+ test("formats passing result", () => {
840
+ const result = { valid: true, issues: [] };
841
+ const formatted = formatIntegrityResult(result);
842
+ expect(formatted).toContain("passed");
843
+ expect(formatted).toContain("no issues");
844
+ });
845
+
846
+ test("formats failing result with errors", () => {
847
+ const result = {
848
+ valid: false,
849
+ issues: [
850
+ {
851
+ code: "TEST_ERROR",
852
+ message: "Test error",
853
+ severity: "error" as const,
854
+ },
855
+ ],
856
+ };
857
+ const formatted = formatIntegrityResult(result);
858
+ expect(formatted).toContain("failed");
859
+ expect(formatted).toContain("TEST_ERROR");
860
+ });
861
+ });
862
+ });