@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.
- package/package.json +9 -31
- package/src/api/README.md +507 -0
- package/src/api/adapter.ts +106 -0
- package/src/api/client.test.ts +640 -0
- package/src/api/client.ts +385 -0
- package/src/api/default-adapter.ts +243 -0
- package/src/api/index.ts +24 -0
- package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
- package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
- package/src/components/FilterRuleEditor/README.md +291 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
- package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
- package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
- package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
- package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
- package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
- package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
- package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
- package/src/components/FilterRuleEditor/constants.ts +64 -0
- package/src/components/FilterRuleEditor/index.ts +14 -0
- package/src/components/FunnelCard/DESIGN.md +447 -0
- package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
- package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
- package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
- package/src/components/FunnelCard/FunnelCard.tsx +204 -0
- package/src/components/FunnelCard/FunnelStats.tsx +68 -0
- package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
- package/src/components/FunnelCard/INSTALLATION.md +304 -0
- package/src/components/FunnelCard/MatchBar.tsx +49 -0
- package/src/components/FunnelCard/README.md +294 -0
- package/src/components/FunnelCard/StageIndicator.tsx +62 -0
- package/src/components/FunnelCard/StatusBadge.tsx +52 -0
- package/src/components/FunnelCard/index.ts +14 -0
- package/src/components/FunnelPreview/EntityCard.tsx +72 -0
- package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
- package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
- package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
- package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
- package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
- package/src/components/FunnelPreview/README.md +337 -0
- package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
- package/src/components/FunnelPreview/example.tsx +286 -0
- package/src/components/FunnelPreview/index.ts +14 -0
- package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
- package/src/components/FunnelRunHistory/README.md +325 -0
- package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
- package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
- package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
- package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
- package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
- package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
- package/src/components/FunnelRunHistory/index.ts +51 -0
- package/src/components/FunnelRunHistory/types.ts +40 -0
- package/src/components/FunnelRunHistory/utils.test.ts +126 -0
- package/src/components/FunnelRunHistory/utils.ts +100 -0
- package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
- package/src/components/FunnelStageBuilder/README.md +341 -0
- package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
- package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
- package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
- package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
- package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
- package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
- package/src/components/FunnelStageBuilder/index.ts +21 -0
- package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
- package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
- package/src/components/FunnelVisualFlow/README.md +323 -0
- package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
- package/src/components/FunnelVisualFlow/example.tsx +227 -0
- package/src/components/FunnelVisualFlow/index.ts +10 -0
- package/src/components/index.ts +102 -0
- package/src/core/README.md +307 -0
- package/src/core/engine.test.ts +1087 -0
- package/src/core/engine.ts +329 -0
- package/src/core/evaluator.example.ts +353 -0
- package/src/core/evaluator.test.ts +639 -0
- package/src/core/evaluator.ts +261 -0
- package/src/core/field-resolver.example.ts +175 -0
- package/src/core/field-resolver.test.ts +541 -0
- package/src/core/field-resolver.ts +247 -0
- package/src/core/index.ts +34 -0
- package/src/core/operators.test.ts +539 -0
- package/src/core/operators.ts +241 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useDebouncedValue.ts +28 -0
- package/src/index.ts +155 -0
- package/src/store/README.md +342 -0
- package/src/store/create-funnel-store.test.ts +686 -0
- package/src/store/create-funnel-store.ts +538 -0
- package/src/store/index.ts +9 -0
- package/src/store/types.ts +294 -0
- package/src/stories/CrossDomain.stories.tsx +149 -0
- package/src/stories/Welcome.stories.tsx +81 -0
- package/src/stories/demo-data/index.ts +3 -0
- package/src/stories/demo-data/investors.ts +216 -0
- package/src/stories/demo-data/leads.ts +223 -0
- package/src/stories/demo-data/recipes.ts +217 -0
- package/src/test/setup.ts +5 -0
- package/src/types/index.ts +843 -0
- package/dist/client-3ESO2NHy.d.ts +0 -310
- package/dist/client-CZu03ACp.d.cts +0 -310
- package/dist/components/index.cjs +0 -3241
- package/dist/components/index.cjs.map +0 -1
- package/dist/components/index.css.map +0 -1
- package/dist/components/index.d.cts +0 -726
- package/dist/components/index.d.ts +0 -726
- package/dist/components/index.js +0 -3194
- package/dist/components/index.js.map +0 -1
- package/dist/core/index.cjs +0 -500
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -359
- package/dist/core/index.d.ts +0 -359
- package/dist/core/index.js +0 -486
- package/dist/core/index.js.map +0 -1
- package/dist/hooks/index.cjs +0 -20
- package/dist/hooks/index.cjs.map +0 -1
- package/dist/hooks/index.d.cts +0 -11
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.js +0 -18
- package/dist/hooks/index.js.map +0 -1
- package/dist/index-BGDEXbuz.d.cts +0 -434
- package/dist/index-BGDEXbuz.d.ts +0 -434
- package/dist/index.cjs +0 -4499
- package/dist/index.cjs.map +0 -1
- package/dist/index.css +0 -198
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -99
- package/dist/index.d.ts +0 -99
- package/dist/index.js +0 -4421
- package/dist/index.js.map +0 -1
- package/dist/store/index.cjs +0 -389
- package/dist/store/index.cjs.map +0 -1
- package/dist/store/index.d.cts +0 -225
- package/dist/store/index.d.ts +0 -225
- package/dist/store/index.js +0 -386
- 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
|
+
});
|