@startsimpli/funnels 0.1.4 → 0.1.5

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 (151) hide show
  1. package/package.json +9 -31
  2. package/src/api/README.md +507 -0
  3. package/src/api/adapter.ts +106 -0
  4. package/src/api/client.test.ts +640 -0
  5. package/src/api/client.ts +385 -0
  6. package/src/api/default-adapter.ts +243 -0
  7. package/src/api/index.ts +24 -0
  8. package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
  9. package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
  13. package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
  15. package/src/components/FilterRuleEditor/README.md +291 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
  17. package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
  18. package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
  19. package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
  20. package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
  21. package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
  22. package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
  23. package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
  24. package/src/components/FilterRuleEditor/constants.ts +64 -0
  25. package/src/components/FilterRuleEditor/index.ts +14 -0
  26. package/src/components/FunnelCard/DESIGN.md +447 -0
  27. package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
  28. package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
  29. package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
  30. package/src/components/FunnelCard/FunnelCard.tsx +204 -0
  31. package/src/components/FunnelCard/FunnelStats.tsx +68 -0
  32. package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
  33. package/src/components/FunnelCard/INSTALLATION.md +304 -0
  34. package/src/components/FunnelCard/MatchBar.tsx +49 -0
  35. package/src/components/FunnelCard/README.md +294 -0
  36. package/src/components/FunnelCard/StageIndicator.tsx +62 -0
  37. package/src/components/FunnelCard/StatusBadge.tsx +52 -0
  38. package/src/components/FunnelCard/index.ts +14 -0
  39. package/src/components/FunnelPreview/EntityCard.tsx +72 -0
  40. package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
  41. package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
  42. package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
  43. package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
  44. package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
  45. package/src/components/FunnelPreview/README.md +337 -0
  46. package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
  47. package/src/components/FunnelPreview/example.tsx +286 -0
  48. package/src/components/FunnelPreview/index.ts +14 -0
  49. package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
  50. package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
  51. package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
  52. package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
  53. package/src/components/FunnelRunHistory/README.md +325 -0
  54. package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
  55. package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
  56. package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
  57. package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
  58. package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
  59. package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
  60. package/src/components/FunnelRunHistory/index.ts +51 -0
  61. package/src/components/FunnelRunHistory/types.ts +40 -0
  62. package/src/components/FunnelRunHistory/utils.test.ts +126 -0
  63. package/src/components/FunnelRunHistory/utils.ts +100 -0
  64. package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
  65. package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
  66. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
  67. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
  68. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
  69. package/src/components/FunnelStageBuilder/README.md +341 -0
  70. package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
  71. package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
  72. package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
  73. package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
  74. package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
  75. package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
  76. package/src/components/FunnelStageBuilder/index.ts +21 -0
  77. package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
  78. package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
  79. package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
  80. package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
  81. package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
  82. package/src/components/FunnelVisualFlow/README.md +323 -0
  83. package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
  84. package/src/components/FunnelVisualFlow/example.tsx +227 -0
  85. package/src/components/FunnelVisualFlow/index.ts +10 -0
  86. package/src/components/index.ts +102 -0
  87. package/src/core/README.md +307 -0
  88. package/src/core/engine.test.ts +1087 -0
  89. package/src/core/engine.ts +329 -0
  90. package/src/core/evaluator.example.ts +353 -0
  91. package/src/core/evaluator.test.ts +639 -0
  92. package/src/core/evaluator.ts +261 -0
  93. package/src/core/field-resolver.example.ts +175 -0
  94. package/src/core/field-resolver.test.ts +541 -0
  95. package/src/core/field-resolver.ts +247 -0
  96. package/src/core/index.ts +34 -0
  97. package/src/core/operators.test.ts +539 -0
  98. package/src/core/operators.ts +241 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/useDebouncedValue.ts +28 -0
  101. package/src/index.ts +155 -0
  102. package/src/store/README.md +342 -0
  103. package/src/store/create-funnel-store.test.ts +686 -0
  104. package/src/store/create-funnel-store.ts +538 -0
  105. package/src/store/index.ts +9 -0
  106. package/src/store/types.ts +294 -0
  107. package/src/stories/CrossDomain.stories.tsx +149 -0
  108. package/src/stories/Welcome.stories.tsx +81 -0
  109. package/src/stories/demo-data/index.ts +3 -0
  110. package/src/stories/demo-data/investors.ts +216 -0
  111. package/src/stories/demo-data/leads.ts +223 -0
  112. package/src/stories/demo-data/recipes.ts +217 -0
  113. package/src/test/setup.ts +5 -0
  114. package/src/types/index.ts +843 -0
  115. package/dist/client-3ESO2NHy.d.ts +0 -310
  116. package/dist/client-CZu03ACp.d.cts +0 -310
  117. package/dist/components/index.cjs +0 -3241
  118. package/dist/components/index.cjs.map +0 -1
  119. package/dist/components/index.css.map +0 -1
  120. package/dist/components/index.d.cts +0 -726
  121. package/dist/components/index.d.ts +0 -726
  122. package/dist/components/index.js +0 -3194
  123. package/dist/components/index.js.map +0 -1
  124. package/dist/core/index.cjs +0 -500
  125. package/dist/core/index.cjs.map +0 -1
  126. package/dist/core/index.d.cts +0 -359
  127. package/dist/core/index.d.ts +0 -359
  128. package/dist/core/index.js +0 -486
  129. package/dist/core/index.js.map +0 -1
  130. package/dist/hooks/index.cjs +0 -20
  131. package/dist/hooks/index.cjs.map +0 -1
  132. package/dist/hooks/index.d.cts +0 -11
  133. package/dist/hooks/index.d.ts +0 -11
  134. package/dist/hooks/index.js +0 -18
  135. package/dist/hooks/index.js.map +0 -1
  136. package/dist/index-BGDEXbuz.d.cts +0 -434
  137. package/dist/index-BGDEXbuz.d.ts +0 -434
  138. package/dist/index.cjs +0 -4499
  139. package/dist/index.cjs.map +0 -1
  140. package/dist/index.css +0 -198
  141. package/dist/index.css.map +0 -1
  142. package/dist/index.d.cts +0 -99
  143. package/dist/index.d.ts +0 -99
  144. package/dist/index.js +0 -4421
  145. package/dist/index.js.map +0 -1
  146. package/dist/store/index.cjs +0 -389
  147. package/dist/store/index.cjs.map +0 -1
  148. package/dist/store/index.d.cts +0 -225
  149. package/dist/store/index.d.ts +0 -225
  150. package/dist/store/index.js +0 -386
  151. package/dist/store/index.js.map +0 -1
@@ -0,0 +1,639 @@
1
+ /**
2
+ * @startsimpli/funnels - Rule Evaluator Tests
3
+ *
4
+ * Comprehensive test suite for rule evaluation engine.
5
+ * Tests ALL operators with different data types.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import {
10
+ evaluateRule,
11
+ evaluateRuleWithResult,
12
+ evaluateRules,
13
+ evaluateRulesAND,
14
+ evaluateRulesOR,
15
+ evaluateRulesWithResults,
16
+ filterEntities,
17
+ } from './evaluator';
18
+ import type { FilterRule } from '../types';
19
+
20
+ // ============================================================================
21
+ // Test Data
22
+ // ============================================================================
23
+
24
+ interface TestEntity {
25
+ id?: number;
26
+ name?: string;
27
+ email?: string | null;
28
+ age?: number;
29
+ score?: number;
30
+ active?: boolean;
31
+ tags?: string[];
32
+ created_at?: Date | string;
33
+ firm?: {
34
+ name?: string;
35
+ stage?: string;
36
+ aum?: number;
37
+ };
38
+ profile?: {
39
+ linkedin_url?: string | null;
40
+ twitter_handle?: string;
41
+ };
42
+ metrics?: {
43
+ arr_usd?: number;
44
+ employees?: number;
45
+ };
46
+ }
47
+
48
+ const sampleEntity: TestEntity = {
49
+ id: 1,
50
+ name: 'John Doe',
51
+ email: 'john@example.com',
52
+ age: 35,
53
+ score: 85.5,
54
+ active: true,
55
+ tags: ['qualified', 'active', 'premium'],
56
+ created_at: new Date('2024-01-15'),
57
+ firm: {
58
+ name: 'Acme Ventures',
59
+ stage: 'Series A',
60
+ aum: 100000000,
61
+ },
62
+ profile: {
63
+ linkedin_url: 'https://linkedin.com/in/johndoe',
64
+ twitter_handle: '@johndoe',
65
+ },
66
+ metrics: {
67
+ arr_usd: 5000000,
68
+ employees: 50,
69
+ },
70
+ };
71
+
72
+ // ============================================================================
73
+ // Equality Operators
74
+ // ============================================================================
75
+
76
+ describe('Equality Operators', () => {
77
+ it('eq: should match equal values', () => {
78
+ const rule: FilterRule = { field_path: 'name', operator: 'eq', value: 'John Doe' };
79
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
80
+ });
81
+
82
+ it('eq: should not match different values', () => {
83
+ const rule: FilterRule = { field_path: 'name', operator: 'eq', value: 'Jane Doe' };
84
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
85
+ });
86
+
87
+ it('eq: should match numbers', () => {
88
+ const rule: FilterRule = { field_path: 'age', operator: 'eq', value: 35 };
89
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
90
+ });
91
+
92
+ it('eq: should match nested fields', () => {
93
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'eq', value: 'Series A' };
94
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
95
+ });
96
+
97
+ it('ne: should match different values', () => {
98
+ const rule: FilterRule = { field_path: 'name', operator: 'ne', value: 'Jane Doe' };
99
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
100
+ });
101
+
102
+ it('ne: should not match equal values', () => {
103
+ const rule: FilterRule = { field_path: 'name', operator: 'ne', value: 'John Doe' };
104
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
105
+ });
106
+ });
107
+
108
+ // ============================================================================
109
+ // Comparison Operators
110
+ // ============================================================================
111
+
112
+ describe('Comparison Operators', () => {
113
+ it('gt: should match greater values', () => {
114
+ const rule: FilterRule = { field_path: 'age', operator: 'gt', value: 30 };
115
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
116
+ });
117
+
118
+ it('gt: should not match equal or lesser values', () => {
119
+ const rule: FilterRule = { field_path: 'age', operator: 'gt', value: 35 };
120
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
121
+ });
122
+
123
+ it('lt: should match lesser values', () => {
124
+ const rule: FilterRule = { field_path: 'age', operator: 'lt', value: 40 };
125
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
126
+ });
127
+
128
+ it('lt: should not match equal or greater values', () => {
129
+ const rule: FilterRule = { field_path: 'age', operator: 'lt', value: 35 };
130
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
131
+ });
132
+
133
+ it('gte: should match greater or equal values', () => {
134
+ const rule1: FilterRule = { field_path: 'age', operator: 'gte', value: 35 };
135
+ const rule2: FilterRule = { field_path: 'age', operator: 'gte', value: 30 };
136
+ expect(evaluateRule(sampleEntity, rule1)).toBe(true);
137
+ expect(evaluateRule(sampleEntity, rule2)).toBe(true);
138
+ });
139
+
140
+ it('gte: should not match lesser values', () => {
141
+ const rule: FilterRule = { field_path: 'age', operator: 'gte', value: 40 };
142
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
143
+ });
144
+
145
+ it('lte: should match lesser or equal values', () => {
146
+ const rule1: FilterRule = { field_path: 'age', operator: 'lte', value: 35 };
147
+ const rule2: FilterRule = { field_path: 'age', operator: 'lte', value: 40 };
148
+ expect(evaluateRule(sampleEntity, rule1)).toBe(true);
149
+ expect(evaluateRule(sampleEntity, rule2)).toBe(true);
150
+ });
151
+
152
+ it('lte: should not match greater values', () => {
153
+ const rule: FilterRule = { field_path: 'age', operator: 'lte', value: 30 };
154
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
155
+ });
156
+
157
+ it('should compare decimal numbers', () => {
158
+ const rule: FilterRule = { field_path: 'score', operator: 'gt', value: 85 };
159
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
160
+ });
161
+
162
+ it('should compare nested numbers', () => {
163
+ const rule: FilterRule = { field_path: 'firm.aum', operator: 'gte', value: 100000000 };
164
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
165
+ });
166
+ });
167
+
168
+ // ============================================================================
169
+ // String Operators
170
+ // ============================================================================
171
+
172
+ describe('String Operators', () => {
173
+ it('contains: should match substring (case-insensitive)', () => {
174
+ const rule: FilterRule = { field_path: 'name', operator: 'contains', value: 'john' };
175
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
176
+ });
177
+
178
+ it('contains: should not match non-existent substring', () => {
179
+ const rule: FilterRule = { field_path: 'name', operator: 'contains', value: 'jane' };
180
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
181
+ });
182
+
183
+ it('contains: should match nested string', () => {
184
+ const rule: FilterRule = { field_path: 'firm.name', operator: 'contains', value: 'acme' };
185
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
186
+ });
187
+
188
+ it('not_contains: should match when substring absent', () => {
189
+ const rule: FilterRule = { field_path: 'name', operator: 'not_contains', value: 'jane' };
190
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
191
+ });
192
+
193
+ it('not_contains: should not match when substring present', () => {
194
+ const rule: FilterRule = { field_path: 'name', operator: 'not_contains', value: 'john' };
195
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
196
+ });
197
+
198
+ it('startswith: should match prefix (case-insensitive)', () => {
199
+ const rule: FilterRule = { field_path: 'name', operator: 'startswith', value: 'john' };
200
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
201
+ });
202
+
203
+ it('startswith: should not match non-prefix', () => {
204
+ const rule: FilterRule = { field_path: 'name', operator: 'startswith', value: 'doe' };
205
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
206
+ });
207
+
208
+ it('endswith: should match suffix (case-insensitive)', () => {
209
+ const rule: FilterRule = { field_path: 'name', operator: 'endswith', value: 'doe' };
210
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
211
+ });
212
+
213
+ it('endswith: should not match non-suffix', () => {
214
+ const rule: FilterRule = { field_path: 'name', operator: 'endswith', value: 'john' };
215
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
216
+ });
217
+
218
+ it('matches: should match regex pattern', () => {
219
+ const rule: FilterRule = { field_path: 'email', operator: 'matches', value: '^[a-z]+@example\\.com$' };
220
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
221
+ });
222
+
223
+ it('matches: should not match non-matching pattern', () => {
224
+ const rule: FilterRule = { field_path: 'email', operator: 'matches', value: '^[0-9]+@' };
225
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
226
+ });
227
+ });
228
+
229
+ // ============================================================================
230
+ // Array Operators
231
+ // ============================================================================
232
+
233
+ describe('Array Operators', () => {
234
+ it('in: should match value in array', () => {
235
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'in', value: ['Seed', 'Series A', 'Series B'] };
236
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
237
+ });
238
+
239
+ it('in: should not match value not in array', () => {
240
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'in', value: ['Seed', 'Series B'] };
241
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
242
+ });
243
+
244
+ it('not_in: should match value not in array', () => {
245
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'not_in', value: ['Seed', 'Series B'] };
246
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
247
+ });
248
+
249
+ it('not_in: should not match value in array', () => {
250
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'not_in', value: ['Series A', 'Series B'] };
251
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
252
+ });
253
+
254
+ it('has_any: should match when array has any value', () => {
255
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_any', value: ['qualified', 'inactive'] };
256
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
257
+ });
258
+
259
+ it('has_any: should not match when array has no values', () => {
260
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_any', value: ['inactive', 'suspended'] };
261
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
262
+ });
263
+
264
+ it('has_all: should match when array has all values', () => {
265
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_all', value: ['qualified', 'active'] };
266
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
267
+ });
268
+
269
+ it('has_all: should not match when array missing some values', () => {
270
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_all', value: ['qualified', 'inactive'] };
271
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
272
+ });
273
+ });
274
+
275
+ // ============================================================================
276
+ // Null Operators
277
+ // ============================================================================
278
+
279
+ describe('Null Operators', () => {
280
+ const entityWithNulls: TestEntity = {
281
+ name: 'John',
282
+ email: null,
283
+ profile: {
284
+ linkedin_url: null,
285
+ },
286
+ };
287
+
288
+ it('isnull: should match null values', () => {
289
+ const rule: FilterRule = { field_path: 'email', operator: 'isnull', value: null };
290
+ expect(evaluateRule(entityWithNulls, rule)).toBe(true);
291
+ });
292
+
293
+ it('isnull: should match undefined values', () => {
294
+ const rule: FilterRule = { field_path: 'age', operator: 'isnull', value: null };
295
+ expect(evaluateRule(entityWithNulls, rule)).toBe(true);
296
+ });
297
+
298
+ it('isnull: should not match defined values', () => {
299
+ const rule: FilterRule = { field_path: 'name', operator: 'isnull', value: null };
300
+ expect(evaluateRule(entityWithNulls, rule)).toBe(false);
301
+ });
302
+
303
+ it('isnull: should match nested null', () => {
304
+ const rule: FilterRule = { field_path: 'profile.linkedin_url', operator: 'isnull', value: null };
305
+ expect(evaluateRule(entityWithNulls, rule)).toBe(true);
306
+ });
307
+
308
+ it('isnotnull: should match defined values', () => {
309
+ const rule: FilterRule = { field_path: 'name', operator: 'isnotnull', value: null };
310
+ expect(evaluateRule(entityWithNulls, rule)).toBe(true);
311
+ });
312
+
313
+ it('isnotnull: should not match null values', () => {
314
+ const rule: FilterRule = { field_path: 'email', operator: 'isnotnull', value: null };
315
+ expect(evaluateRule(entityWithNulls, rule)).toBe(false);
316
+ });
317
+
318
+ it('isnotnull: should not match undefined values', () => {
319
+ const rule: FilterRule = { field_path: 'age', operator: 'isnotnull', value: null };
320
+ expect(evaluateRule(entityWithNulls, rule)).toBe(false);
321
+ });
322
+ });
323
+
324
+ // ============================================================================
325
+ // Tag Operators
326
+ // ============================================================================
327
+
328
+ describe('Tag Operators', () => {
329
+ it('has_tag: should match tag in array (case-insensitive)', () => {
330
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_tag', value: 'QUALIFIED' };
331
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
332
+ });
333
+
334
+ it('has_tag: should not match missing tag', () => {
335
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_tag', value: 'inactive' };
336
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
337
+ });
338
+
339
+ it('not_has_tag: should match when tag absent', () => {
340
+ const rule: FilterRule = { field_path: 'tags', operator: 'not_has_tag', value: 'inactive' };
341
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
342
+ });
343
+
344
+ it('not_has_tag: should not match when tag present', () => {
345
+ const rule: FilterRule = { field_path: 'tags', operator: 'not_has_tag', value: 'qualified' };
346
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
347
+ });
348
+ });
349
+
350
+ // ============================================================================
351
+ // Boolean Operators
352
+ // ============================================================================
353
+
354
+ describe('Boolean Operators', () => {
355
+ it('is_true: should match true values', () => {
356
+ const rule: FilterRule = { field_path: 'active', operator: 'is_true', value: null };
357
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
358
+ });
359
+
360
+ it('is_true: should not match false values', () => {
361
+ const entity = { ...sampleEntity, active: false };
362
+ const rule: FilterRule = { field_path: 'active', operator: 'is_true', value: null };
363
+ expect(evaluateRule(entity, rule)).toBe(false);
364
+ });
365
+
366
+ it('is_false: should match false values', () => {
367
+ const entity = { ...sampleEntity, active: false };
368
+ const rule: FilterRule = { field_path: 'active', operator: 'is_false', value: null };
369
+ expect(evaluateRule(entity, rule)).toBe(true);
370
+ });
371
+
372
+ it('is_false: should not match true values', () => {
373
+ const rule: FilterRule = { field_path: 'active', operator: 'is_false', value: null };
374
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
375
+ });
376
+ });
377
+
378
+ // ============================================================================
379
+ // Date Operators
380
+ // ============================================================================
381
+
382
+ describe('Date Operators', () => {
383
+ it('should compare Date objects', () => {
384
+ const rule: FilterRule = {
385
+ field_path: 'created_at',
386
+ operator: 'gt',
387
+ value: new Date('2024-01-01'),
388
+ };
389
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
390
+ });
391
+
392
+ it('should compare date strings', () => {
393
+ const entity = { ...sampleEntity, created_at: '2024-01-15' };
394
+ const rule: FilterRule = {
395
+ field_path: 'created_at',
396
+ operator: 'gte',
397
+ value: '2024-01-15',
398
+ };
399
+ expect(evaluateRule(entity, rule)).toBe(true);
400
+ });
401
+
402
+ it('should handle date equality', () => {
403
+ const entity = { ...sampleEntity, created_at: '2024-01-15' };
404
+ const rule: FilterRule = {
405
+ field_path: 'created_at',
406
+ operator: 'eq',
407
+ value: new Date('2024-01-15'),
408
+ };
409
+ expect(evaluateRule(entity, rule)).toBe(true);
410
+ });
411
+ });
412
+
413
+ // ============================================================================
414
+ // Negate Flag
415
+ // ============================================================================
416
+
417
+ describe('Negate Flag', () => {
418
+ it('should negate equality', () => {
419
+ const rule: FilterRule = {
420
+ field_path: 'name',
421
+ operator: 'eq',
422
+ value: 'John Doe',
423
+ negate: true,
424
+ };
425
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
426
+ });
427
+
428
+ it('should negate inequality', () => {
429
+ const rule: FilterRule = {
430
+ field_path: 'name',
431
+ operator: 'ne',
432
+ value: 'John Doe',
433
+ negate: true,
434
+ };
435
+ expect(evaluateRule(sampleEntity, rule)).toBe(true);
436
+ });
437
+
438
+ it('should negate contains', () => {
439
+ const rule: FilterRule = {
440
+ field_path: 'name',
441
+ operator: 'contains',
442
+ value: 'john',
443
+ negate: true,
444
+ };
445
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
446
+ });
447
+ });
448
+
449
+ // ============================================================================
450
+ // Edge Cases
451
+ // ============================================================================
452
+
453
+ describe('Edge Cases', () => {
454
+ it('should handle missing nested paths', () => {
455
+ const rule: FilterRule = { field_path: 'firm.missing.field', operator: 'eq', value: 'test' };
456
+ expect(evaluateRule(sampleEntity, rule)).toBe(false);
457
+ });
458
+
459
+ it('should handle empty strings', () => {
460
+ const entity = { name: '' };
461
+ const rule: FilterRule = { field_path: 'name', operator: 'eq', value: '' };
462
+ expect(evaluateRule(entity, rule)).toBe(true);
463
+ });
464
+
465
+ it('should handle empty arrays', () => {
466
+ const entity = { tags: [] };
467
+ const rule: FilterRule = { field_path: 'tags', operator: 'has_tag', value: 'test' };
468
+ expect(evaluateRule(entity, rule)).toBe(false);
469
+ });
470
+
471
+ it('should handle zero values', () => {
472
+ const entity = { score: 0 };
473
+ const rule: FilterRule = { field_path: 'score', operator: 'eq', value: 0 };
474
+ expect(evaluateRule(entity, rule)).toBe(true);
475
+ });
476
+
477
+ it('should handle numeric strings in comparisons', () => {
478
+ const entity = { age: '35' };
479
+ const rule: FilterRule = { field_path: 'age', operator: 'gt', value: 30 };
480
+ expect(evaluateRule(entity, rule)).toBe(true);
481
+ });
482
+ });
483
+
484
+ // ============================================================================
485
+ // Multiple Rules (AND/OR)
486
+ // ============================================================================
487
+
488
+ describe('Multiple Rules', () => {
489
+ const rules: FilterRule[] = [
490
+ { field_path: 'age', operator: 'gte', value: 30 },
491
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series A' },
492
+ { field_path: 'tags', operator: 'has_tag', value: 'qualified' },
493
+ ];
494
+
495
+ it('AND: should match when all rules pass', () => {
496
+ expect(evaluateRulesAND(sampleEntity, rules)).toBe(true);
497
+ });
498
+
499
+ it('AND: should not match when any rule fails', () => {
500
+ const failingRules = [
501
+ ...rules,
502
+ { field_path: 'age', operator: 'lt', value: 30 } as FilterRule,
503
+ ];
504
+ expect(evaluateRulesAND(sampleEntity, failingRules)).toBe(false);
505
+ });
506
+
507
+ it('OR: should match when any rule passes', () => {
508
+ const orRules: FilterRule[] = [
509
+ { field_path: 'age', operator: 'lt', value: 30 }, // false
510
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series A' }, // true
511
+ ];
512
+ expect(evaluateRulesOR(sampleEntity, orRules)).toBe(true);
513
+ });
514
+
515
+ it('OR: should not match when all rules fail', () => {
516
+ const orRules: FilterRule[] = [
517
+ { field_path: 'age', operator: 'lt', value: 30 },
518
+ { field_path: 'firm.stage', operator: 'eq', value: 'Seed' },
519
+ ];
520
+ expect(evaluateRulesOR(sampleEntity, orRules)).toBe(false);
521
+ });
522
+
523
+ it('evaluateRules: should use AND logic by default', () => {
524
+ expect(evaluateRules(sampleEntity, rules)).toBe(true);
525
+ });
526
+
527
+ it('evaluateRules: should use specified logic', () => {
528
+ const orRules: FilterRule[] = [
529
+ { field_path: 'age', operator: 'lt', value: 30 },
530
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series A' },
531
+ ];
532
+ expect(evaluateRules(sampleEntity, orRules, 'OR')).toBe(true);
533
+ });
534
+
535
+ it('should handle empty rules (match all)', () => {
536
+ expect(evaluateRulesAND(sampleEntity, [])).toBe(true);
537
+ expect(evaluateRulesOR(sampleEntity, [])).toBe(true);
538
+ });
539
+ });
540
+
541
+ // ============================================================================
542
+ // Detailed Results
543
+ // ============================================================================
544
+
545
+ describe('Detailed Results', () => {
546
+ it('should return detailed rule result', () => {
547
+ const rule: FilterRule = { field_path: 'firm.stage', operator: 'eq', value: 'Series A' };
548
+ const result = evaluateRuleWithResult(sampleEntity, rule);
549
+
550
+ expect(result).toEqual({
551
+ field_path: 'firm.stage',
552
+ operator: 'eq',
553
+ value: 'Series A',
554
+ actual_value: 'Series A',
555
+ matched: true,
556
+ });
557
+ });
558
+
559
+ it('should return detailed results for multiple rules', () => {
560
+ const rules: FilterRule[] = [
561
+ { field_path: 'age', operator: 'gte', value: 30 },
562
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series A' },
563
+ ];
564
+
565
+ const result = evaluateRulesWithResults(sampleEntity, rules, 'AND');
566
+
567
+ expect(result.matched).toBe(true);
568
+ expect(result.logic).toBe('AND');
569
+ expect(result.rule_results).toHaveLength(2);
570
+ expect(result.rule_results[0].matched).toBe(true);
571
+ expect(result.rule_results[1].matched).toBe(true);
572
+ });
573
+
574
+ it('should handle errors in rule evaluation', () => {
575
+ const rule: FilterRule = { field_path: 'name', operator: 'matches' as any, value: '[invalid-regex' };
576
+ const result = evaluateRuleWithResult(sampleEntity, rule);
577
+
578
+ expect(result.matched).toBe(false);
579
+ expect(result.error).toBeUndefined(); // Invalid regex returns false, doesn't throw
580
+ });
581
+ });
582
+
583
+ // ============================================================================
584
+ // Filter Entities
585
+ // ============================================================================
586
+
587
+ describe('Filter Entities', () => {
588
+ const entities: TestEntity[] = [
589
+ { id: 1, name: 'John', age: 35, firm: { stage: 'Series A' } },
590
+ { id: 2, name: 'Jane', age: 28, firm: { stage: 'Seed' } },
591
+ { id: 3, name: 'Bob', age: 42, firm: { stage: 'Series B' } },
592
+ ];
593
+
594
+ it('should filter entities with single rule', () => {
595
+ const rules: FilterRule[] = [
596
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series A' },
597
+ ];
598
+
599
+ const result = filterEntities(entities, rules);
600
+ expect(result).toHaveLength(1);
601
+ expect(result[0].name).toBe('John');
602
+ });
603
+
604
+ it('should filter entities with multiple AND rules', () => {
605
+ const rules: FilterRule[] = [
606
+ { field_path: 'age', operator: 'gte', value: 30 },
607
+ { field_path: 'firm.stage', operator: 'in', value: ['Series A', 'Series B'] },
608
+ ];
609
+
610
+ const result = filterEntities(entities, rules, 'AND');
611
+ expect(result).toHaveLength(2);
612
+ expect(result.map(e => e.name)).toEqual(['John', 'Bob']);
613
+ });
614
+
615
+ it('should filter entities with OR rules', () => {
616
+ const rules: FilterRule[] = [
617
+ { field_path: 'age', operator: 'lt', value: 30 },
618
+ { field_path: 'firm.stage', operator: 'eq', value: 'Series B' },
619
+ ];
620
+
621
+ const result = filterEntities(entities, rules, 'OR');
622
+ expect(result).toHaveLength(2);
623
+ expect(result.map(e => e.name)).toEqual(['Jane', 'Bob']);
624
+ });
625
+
626
+ it('should return all entities with no rules', () => {
627
+ const result = filterEntities(entities, []);
628
+ expect(result).toHaveLength(3);
629
+ });
630
+
631
+ it('should return empty array for no matches', () => {
632
+ const rules: FilterRule[] = [
633
+ { field_path: 'age', operator: 'gt', value: 100 },
634
+ ];
635
+
636
+ const result = filterEntities(entities, rules);
637
+ expect(result).toHaveLength(0);
638
+ });
639
+ });