@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,339 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConditionGroup } from "@f-o-t/condition-evaluator";
3
+ import { createEvaluator, createOperator } from "@f-o-t/condition-evaluator";
4
+ import { evaluateRule, evaluateRules } from "../src/core/evaluate";
5
+ import type { DefaultConsequences } from "../src/types/consequence";
6
+ import type { Rule } from "../src/types/rule";
7
+
8
+ type TestRule = Rule<unknown, DefaultConsequences>;
9
+
10
+ const createTestRule = (overrides: Partial<TestRule> = {}): TestRule => ({
11
+ id: "rule-1",
12
+ name: "Test Rule",
13
+ description: "A test rule",
14
+ conditions: {
15
+ id: "group-1",
16
+ operator: "AND",
17
+ conditions: [
18
+ {
19
+ id: "cond-1",
20
+ type: "number",
21
+ field: "amount",
22
+ operator: "gt",
23
+ value: 100,
24
+ },
25
+ ],
26
+ },
27
+ consequences: [
28
+ {
29
+ type: "set_category",
30
+ payload: { categoryId: "high-value" },
31
+ },
32
+ ],
33
+ priority: 10,
34
+ enabled: true,
35
+ stopOnMatch: false,
36
+ tags: ["test"],
37
+ category: "finance",
38
+ createdAt: new Date(),
39
+ updatedAt: new Date(),
40
+ ...overrides,
41
+ });
42
+
43
+ describe("evaluateRule", () => {
44
+ test("should match rule when conditions pass", () => {
45
+ const rule = createTestRule();
46
+ const context = {
47
+ data: { amount: 150 },
48
+ timestamp: new Date(),
49
+ };
50
+
51
+ const evaluator = createEvaluator();
52
+ const result = evaluateRule(rule, context, evaluator);
53
+
54
+ expect(result.matched).toBe(true);
55
+ expect(result.ruleId).toBe("rule-1");
56
+ expect(result.ruleName).toBe("Test Rule");
57
+ expect(result.consequences).toHaveLength(1);
58
+ expect(result.consequences[0]?.type).toBe("set_category");
59
+ expect(result.skipped).toBe(false);
60
+ });
61
+
62
+ test("should not match rule when conditions fail", () => {
63
+ const rule = createTestRule();
64
+ const context = {
65
+ data: { amount: 50 },
66
+ timestamp: new Date(),
67
+ };
68
+
69
+ const evaluator = createEvaluator();
70
+ const result = evaluateRule(rule, context, evaluator);
71
+
72
+ expect(result.matched).toBe(false);
73
+ expect(result.consequences).toHaveLength(0);
74
+ });
75
+
76
+ test("should skip disabled rules when skipDisabled is true", () => {
77
+ const rule = createTestRule({ enabled: false });
78
+ const context = {
79
+ data: { amount: 150 },
80
+ timestamp: new Date(),
81
+ };
82
+
83
+ const evaluator = createEvaluator();
84
+ const result = evaluateRule(rule, context, evaluator, { skipDisabled: true });
85
+
86
+ expect(result.skipped).toBe(true);
87
+ expect(result.skipReason).toBe("Rule is disabled");
88
+ expect(result.matched).toBe(false);
89
+ });
90
+
91
+ test("should evaluate disabled rules when skipDisabled is false", () => {
92
+ const rule = createTestRule({ enabled: false });
93
+ const context = {
94
+ data: { amount: 150 },
95
+ timestamp: new Date(),
96
+ };
97
+
98
+ const evaluator = createEvaluator();
99
+ const result = evaluateRule(rule, context, evaluator, { skipDisabled: false });
100
+
101
+ expect(result.skipped).toBe(false);
102
+ expect(result.matched).toBe(true);
103
+ });
104
+
105
+ test("should include evaluation time", () => {
106
+ const rule = createTestRule();
107
+ const context = {
108
+ data: { amount: 150 },
109
+ timestamp: new Date(),
110
+ };
111
+
112
+ const evaluator = createEvaluator();
113
+ const result = evaluateRule(rule, context, evaluator);
114
+
115
+ expect(result.evaluationTimeMs).toBeGreaterThanOrEqual(0);
116
+ });
117
+
118
+ test("should handle complex condition groups", () => {
119
+ const conditions: ConditionGroup = {
120
+ id: "group-1",
121
+ operator: "AND",
122
+ conditions: [
123
+ {
124
+ id: "cond-1",
125
+ type: "number",
126
+ field: "amount",
127
+ operator: "gt",
128
+ value: 100,
129
+ },
130
+ {
131
+ id: "cond-2",
132
+ type: "string",
133
+ field: "status",
134
+ operator: "eq",
135
+ value: "active",
136
+ },
137
+ ],
138
+ };
139
+
140
+ const rule = createTestRule({ conditions });
141
+ const context = {
142
+ data: { amount: 150, status: "active" },
143
+ timestamp: new Date(),
144
+ };
145
+
146
+ const evaluator = createEvaluator();
147
+ const result = evaluateRule(rule, context, evaluator);
148
+
149
+ expect(result.matched).toBe(true);
150
+ });
151
+
152
+ test("should handle OR condition groups", () => {
153
+ const conditions: ConditionGroup = {
154
+ id: "group-1",
155
+ operator: "OR",
156
+ conditions: [
157
+ {
158
+ id: "cond-1",
159
+ type: "number",
160
+ field: "amount",
161
+ operator: "gt",
162
+ value: 1000,
163
+ },
164
+ {
165
+ id: "cond-2",
166
+ type: "string",
167
+ field: "status",
168
+ operator: "eq",
169
+ value: "premium",
170
+ },
171
+ ],
172
+ };
173
+
174
+ const rule = createTestRule({ conditions });
175
+ const context = {
176
+ data: { amount: 50, status: "premium" },
177
+ timestamp: new Date(),
178
+ };
179
+
180
+ const evaluator = createEvaluator();
181
+ const result = evaluateRule(rule, context, evaluator);
182
+
183
+ expect(result.matched).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("evaluateRules", () => {
188
+ test("should evaluate multiple rules", () => {
189
+ const rules = [
190
+ createTestRule({ id: "rule-1", priority: 10 }),
191
+ createTestRule({ id: "rule-2", priority: 5 }),
192
+ ];
193
+ const context = {
194
+ data: { amount: 150 },
195
+ timestamp: new Date(),
196
+ };
197
+
198
+ const evaluator = createEvaluator();
199
+ const result = evaluateRules(rules, context, evaluator);
200
+
201
+ expect(result.results).toHaveLength(2);
202
+ expect(result.matchedRules).toHaveLength(2);
203
+ expect(result.stoppedEarly).toBe(false);
204
+ });
205
+
206
+ test("should stop on stopOnMatch", () => {
207
+ const rules = [
208
+ createTestRule({ id: "rule-1", stopOnMatch: true }),
209
+ createTestRule({ id: "rule-2" }),
210
+ ];
211
+ const context = {
212
+ data: { amount: 150 },
213
+ timestamp: new Date(),
214
+ };
215
+
216
+ const evaluator = createEvaluator();
217
+ const result = evaluateRules(rules, context, evaluator);
218
+
219
+ expect(result.results).toHaveLength(1);
220
+ expect(result.matchedRules).toHaveLength(1);
221
+ expect(result.stoppedEarly).toBe(true);
222
+ expect(result.stoppedByRuleId).toBe("rule-1");
223
+ });
224
+
225
+ test("should stop on first-match conflict resolution", () => {
226
+ const rules = [
227
+ createTestRule({ id: "rule-1" }),
228
+ createTestRule({ id: "rule-2" }),
229
+ ];
230
+ const context = {
231
+ data: { amount: 150 },
232
+ timestamp: new Date(),
233
+ };
234
+
235
+ const evaluator = createEvaluator();
236
+ const result = evaluateRules(rules, context, evaluator, {
237
+ config: { conflictResolution: "first-match" },
238
+ });
239
+
240
+ expect(result.matchedRules).toHaveLength(1);
241
+ expect(result.stoppedEarly).toBe(true);
242
+ });
243
+
244
+ test("should collect all consequences with priority strategy", () => {
245
+ const rules = [
246
+ createTestRule({
247
+ id: "rule-1",
248
+ consequences: [{ type: "action1", payload: {} }],
249
+ }),
250
+ createTestRule({
251
+ id: "rule-2",
252
+ consequences: [{ type: "action2", payload: {} }],
253
+ }),
254
+ ];
255
+ const context = {
256
+ data: { amount: 150 },
257
+ timestamp: new Date(),
258
+ };
259
+
260
+ const evaluator = createEvaluator();
261
+ const result = evaluateRules(rules, context, evaluator);
262
+
263
+ expect(result.consequences).toHaveLength(2);
264
+ });
265
+
266
+ test("should skip disabled rules", () => {
267
+ const rules = [
268
+ createTestRule({ id: "rule-1", enabled: true }),
269
+ createTestRule({ id: "rule-2", enabled: false }),
270
+ ];
271
+ const context = {
272
+ data: { amount: 150 },
273
+ timestamp: new Date(),
274
+ };
275
+
276
+ const evaluator = createEvaluator();
277
+ const result = evaluateRules(rules, context, evaluator);
278
+
279
+ expect(result.matchedRules).toHaveLength(1);
280
+ expect(result.results[1]?.skipped).toBe(true);
281
+ });
282
+
283
+ test("should call onRuleEvaluated callback", () => {
284
+ const rules = [createTestRule({ id: "rule-1" })];
285
+ const context = {
286
+ data: { amount: 150 },
287
+ timestamp: new Date(),
288
+ };
289
+
290
+ const evaluatedRules: string[] = [];
291
+
292
+ const evaluator = createEvaluator();
293
+ evaluateRules(rules, context, evaluator, {
294
+ onRuleEvaluated: (result) => {
295
+ evaluatedRules.push(result.ruleId);
296
+ },
297
+ });
298
+
299
+ expect(evaluatedRules).toContain("rule-1");
300
+ });
301
+ });
302
+
303
+ describe("evaluateRule with custom evaluator", () => {
304
+ test("should use provided evaluator for custom operators", () => {
305
+ const customOp = createOperator({
306
+ name: "always_true",
307
+ type: "custom",
308
+ evaluate: () => true,
309
+ });
310
+
311
+ const evaluator = createEvaluator({
312
+ operators: { always_true: customOp }
313
+ });
314
+
315
+ const rule = createTestRule({
316
+ conditions: {
317
+ id: "group-1",
318
+ operator: "AND",
319
+ conditions: [
320
+ {
321
+ id: "cond-1",
322
+ type: "custom",
323
+ field: "anything",
324
+ operator: "always_true",
325
+ },
326
+ ],
327
+ },
328
+ });
329
+
330
+ const context = {
331
+ data: { anything: "value" },
332
+ timestamp: new Date(),
333
+ };
334
+
335
+ const result = evaluateRule(rule, context, evaluator);
336
+
337
+ expect(result.matched).toBe(true);
338
+ });
339
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEngine, createEvaluator, createOperator } from "../src/index";
3
+
4
+ describe("Public API exports", () => {
5
+ test("should export createEvaluator", () => {
6
+ expect(createEvaluator).toBeDefined();
7
+ expect(typeof createEvaluator).toBe("function");
8
+ });
9
+
10
+ test("should export createOperator", () => {
11
+ expect(createOperator).toBeDefined();
12
+ expect(typeof createOperator).toBe("function");
13
+ });
14
+
15
+ test("should be able to use all exports together", () => {
16
+ const customOp = createOperator({
17
+ name: "test_op",
18
+ type: "custom",
19
+ evaluate: () => true,
20
+ });
21
+
22
+ const evaluator = createEvaluator({ operators: { test_op: customOp } });
23
+
24
+ const engine = createEngine({
25
+ evaluator,
26
+ });
27
+
28
+ expect(engine).toBeDefined();
29
+ });
30
+ });
@@ -0,0 +1,303 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ filterByCategory,
4
+ filterByEnabled,
5
+ filterByTags,
6
+ filterRules,
7
+ } from "../src/core/filter";
8
+ import { groupByCategory, groupByCustom, groupRules } from "../src/core/group";
9
+ import { sortByName, sortByPriority, sortRules } from "../src/core/sort";
10
+ import type { DefaultConsequences } from "../src/types/consequence";
11
+ import type { Rule } from "../src/types/rule";
12
+
13
+ type TestRule = Rule<unknown, DefaultConsequences>;
14
+
15
+ const createTestRule = (overrides: Partial<TestRule> = {}): TestRule => ({
16
+ id: "rule-1",
17
+ name: "Test Rule",
18
+ description: "A test rule",
19
+ conditions: {
20
+ id: "group-1",
21
+ operator: "AND",
22
+ conditions: [],
23
+ },
24
+ consequences: [],
25
+ priority: 10,
26
+ enabled: true,
27
+ stopOnMatch: false,
28
+ tags: ["test"],
29
+ category: "finance",
30
+ createdAt: new Date("2024-01-01"),
31
+ updatedAt: new Date("2024-01-01"),
32
+ ...overrides,
33
+ });
34
+
35
+ describe("filterRules", () => {
36
+ test("should filter by enabled", () => {
37
+ const rules = [
38
+ createTestRule({ id: "rule-1", enabled: true }),
39
+ createTestRule({ id: "rule-2", enabled: false }),
40
+ ];
41
+
42
+ const result = filterRules({ enabled: true })(rules);
43
+
44
+ expect(result).toHaveLength(1);
45
+ expect(result[0]?.id).toBe("rule-1");
46
+ });
47
+
48
+ test("should filter by tags", () => {
49
+ const rules = [
50
+ createTestRule({ id: "rule-1", tags: ["finance", "alerts"] }),
51
+ createTestRule({ id: "rule-2", tags: ["marketing"] }),
52
+ ];
53
+
54
+ const result = filterRules({ tags: ["finance"] })(rules);
55
+
56
+ expect(result).toHaveLength(1);
57
+ expect(result[0]?.id).toBe("rule-1");
58
+ });
59
+
60
+ test("should filter by category", () => {
61
+ const rules = [
62
+ createTestRule({ id: "rule-1", category: "finance" }),
63
+ createTestRule({ id: "rule-2", category: "marketing" }),
64
+ ];
65
+
66
+ const result = filterRules({ category: "finance" })(rules);
67
+
68
+ expect(result).toHaveLength(1);
69
+ expect(result[0]?.id).toBe("rule-1");
70
+ });
71
+
72
+ test("should filter by ids", () => {
73
+ const rules = [
74
+ createTestRule({ id: "rule-1" }),
75
+ createTestRule({ id: "rule-2" }),
76
+ createTestRule({ id: "rule-3" }),
77
+ ];
78
+
79
+ const result = filterRules({ ids: ["rule-1", "rule-3"] })(rules);
80
+
81
+ expect(result).toHaveLength(2);
82
+ });
83
+
84
+ test("should combine multiple filters", () => {
85
+ const rules = [
86
+ createTestRule({ id: "rule-1", enabled: true, category: "finance" }),
87
+ createTestRule({ id: "rule-2", enabled: false, category: "finance" }),
88
+ createTestRule({ id: "rule-3", enabled: true, category: "marketing" }),
89
+ ];
90
+
91
+ const result = filterRules({ enabled: true, category: "finance" })(rules);
92
+
93
+ expect(result).toHaveLength(1);
94
+ expect(result[0]?.id).toBe("rule-1");
95
+ });
96
+ });
97
+
98
+ describe("filterByEnabled", () => {
99
+ test("should filter enabled rules", () => {
100
+ const rules = [
101
+ createTestRule({ id: "rule-1", enabled: true }),
102
+ createTestRule({ id: "rule-2", enabled: false }),
103
+ ];
104
+
105
+ const result = filterByEnabled(true)(rules);
106
+
107
+ expect(result).toHaveLength(1);
108
+ expect(result[0]?.id).toBe("rule-1");
109
+ });
110
+ });
111
+
112
+ describe("filterByTags", () => {
113
+ test("should filter by tags", () => {
114
+ const rules = [
115
+ createTestRule({ id: "rule-1", tags: ["finance"] }),
116
+ createTestRule({ id: "rule-2", tags: ["marketing"] }),
117
+ ];
118
+
119
+ const result = filterByTags(["finance"])(rules);
120
+
121
+ expect(result).toHaveLength(1);
122
+ });
123
+ });
124
+
125
+ describe("filterByCategory", () => {
126
+ test("should filter by category", () => {
127
+ const rules = [
128
+ createTestRule({ id: "rule-1", category: "finance" }),
129
+ createTestRule({ id: "rule-2", category: "marketing" }),
130
+ ];
131
+
132
+ const result = filterByCategory("finance")(rules);
133
+
134
+ expect(result).toHaveLength(1);
135
+ });
136
+ });
137
+
138
+ describe("sortRules", () => {
139
+ test("should sort by priority descending", () => {
140
+ const rules = [
141
+ createTestRule({ id: "rule-1", priority: 5 }),
142
+ createTestRule({ id: "rule-2", priority: 10 }),
143
+ createTestRule({ id: "rule-3", priority: 1 }),
144
+ ];
145
+
146
+ const result = sortRules("priority")(rules);
147
+
148
+ expect(result[0]?.id).toBe("rule-2");
149
+ expect(result[1]?.id).toBe("rule-1");
150
+ expect(result[2]?.id).toBe("rule-3");
151
+ });
152
+
153
+ test("should sort by priority ascending", () => {
154
+ const rules = [
155
+ createTestRule({ id: "rule-1", priority: 5 }),
156
+ createTestRule({ id: "rule-2", priority: 10 }),
157
+ createTestRule({ id: "rule-3", priority: 1 }),
158
+ ];
159
+
160
+ const result = sortRules({ field: "priority", direction: "asc" })(rules);
161
+
162
+ expect(result[0]?.id).toBe("rule-3");
163
+ expect(result[1]?.id).toBe("rule-1");
164
+ expect(result[2]?.id).toBe("rule-2");
165
+ });
166
+
167
+ test("should sort by name", () => {
168
+ const rules = [
169
+ createTestRule({ id: "rule-1", name: "Zebra" }),
170
+ createTestRule({ id: "rule-2", name: "Alpha" }),
171
+ createTestRule({ id: "rule-3", name: "Beta" }),
172
+ ];
173
+
174
+ const result = sortRules({ field: "name", direction: "asc" })(rules);
175
+
176
+ expect(result[0]?.id).toBe("rule-2");
177
+ expect(result[1]?.id).toBe("rule-3");
178
+ expect(result[2]?.id).toBe("rule-1");
179
+ });
180
+
181
+ test("should sort by createdAt", () => {
182
+ const rules = [
183
+ createTestRule({ id: "rule-1", createdAt: new Date("2024-01-15") }),
184
+ createTestRule({ id: "rule-2", createdAt: new Date("2024-01-01") }),
185
+ createTestRule({ id: "rule-3", createdAt: new Date("2024-01-10") }),
186
+ ];
187
+
188
+ const result = sortRules({ field: "createdAt", direction: "desc" })(
189
+ rules,
190
+ );
191
+
192
+ expect(result[0]?.id).toBe("rule-1");
193
+ expect(result[2]?.id).toBe("rule-2");
194
+ });
195
+ });
196
+
197
+ describe("sortByPriority", () => {
198
+ test("should sort by priority", () => {
199
+ const rules = [
200
+ createTestRule({ id: "rule-1", priority: 1 }),
201
+ createTestRule({ id: "rule-2", priority: 10 }),
202
+ ];
203
+
204
+ const result = sortByPriority()(rules);
205
+
206
+ expect(result[0]?.id).toBe("rule-2");
207
+ });
208
+ });
209
+
210
+ describe("sortByName", () => {
211
+ test("should sort by name", () => {
212
+ const rules = [
213
+ createTestRule({ id: "rule-1", name: "Zebra" }),
214
+ createTestRule({ id: "rule-2", name: "Alpha" }),
215
+ ];
216
+
217
+ const result = sortByName()(rules);
218
+
219
+ expect(result[0]?.id).toBe("rule-2");
220
+ });
221
+ });
222
+
223
+ describe("groupRules", () => {
224
+ test("should group by category", () => {
225
+ const rules = [
226
+ createTestRule({ id: "rule-1", category: "finance" }),
227
+ createTestRule({ id: "rule-2", category: "finance" }),
228
+ createTestRule({ id: "rule-3", category: "marketing" }),
229
+ ];
230
+
231
+ const result = groupRules("category")(rules);
232
+
233
+ expect(result.get("finance")).toHaveLength(2);
234
+ expect(result.get("marketing")).toHaveLength(1);
235
+ });
236
+
237
+ test("should group by priority", () => {
238
+ const rules = [
239
+ createTestRule({ id: "rule-1", priority: 10 }),
240
+ createTestRule({ id: "rule-2", priority: 10 }),
241
+ createTestRule({ id: "rule-3", priority: 5 }),
242
+ ];
243
+
244
+ const result = groupRules("priority")(rules);
245
+
246
+ expect(result.get(10)).toHaveLength(2);
247
+ expect(result.get(5)).toHaveLength(1);
248
+ });
249
+
250
+ test("should group by enabled", () => {
251
+ const rules = [
252
+ createTestRule({ id: "rule-1", enabled: true }),
253
+ createTestRule({ id: "rule-2", enabled: false }),
254
+ createTestRule({ id: "rule-3", enabled: true }),
255
+ ];
256
+
257
+ const result = groupRules("enabled")(rules);
258
+
259
+ expect(result.get(true)).toHaveLength(2);
260
+ expect(result.get(false)).toHaveLength(1);
261
+ });
262
+
263
+ test("should handle uncategorized rules", () => {
264
+ const rules = [
265
+ createTestRule({ id: "rule-1", category: undefined }),
266
+ createTestRule({ id: "rule-2", category: "finance" }),
267
+ ];
268
+
269
+ const result = groupRules("category")(rules);
270
+
271
+ expect(result.get("uncategorized")).toHaveLength(1);
272
+ });
273
+ });
274
+
275
+ describe("groupByCategory", () => {
276
+ test("should group by category", () => {
277
+ const rules = [
278
+ createTestRule({ id: "rule-1", category: "finance" }),
279
+ createTestRule({ id: "rule-2", category: "marketing" }),
280
+ ];
281
+
282
+ const result = groupByCategory()(rules);
283
+
284
+ expect(result.size).toBe(2);
285
+ });
286
+ });
287
+
288
+ describe("groupByCustom", () => {
289
+ test("should group by custom function", () => {
290
+ const rules = [
291
+ createTestRule({ id: "rule-1", priority: 5 }),
292
+ createTestRule({ id: "rule-2", priority: 15 }),
293
+ createTestRule({ id: "rule-3", priority: 3 }),
294
+ ];
295
+
296
+ const result = groupByCustom<unknown, DefaultConsequences, string>(
297
+ (rule) => (rule.priority >= 10 ? "high" : "low"),
298
+ )(rules);
299
+
300
+ expect(result.get("high")).toHaveLength(1);
301
+ expect(result.get("low")).toHaveLength(2);
302
+ });
303
+ });