@startsimpli/funnels 0.1.3 → 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 -3243
- 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 -3196
- 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 -21
- 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 -19
- 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 -391
- 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 -388
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/funnels - FunnelEngine Tests
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive test suite for the core funnel execution engine.
|
|
5
|
+
* Tests generic entity processing with arbitrary field structures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { FunnelEngine } from './engine';
|
|
10
|
+
import type { Funnel, FunnelStage } from '../types';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Mock Entities (Generic Objects)
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
// Mock investor entities
|
|
17
|
+
interface MockInvestor {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
firm: {
|
|
21
|
+
name: string;
|
|
22
|
+
stage: string;
|
|
23
|
+
aum_usd: number;
|
|
24
|
+
};
|
|
25
|
+
location: string;
|
|
26
|
+
tags?: string[];
|
|
27
|
+
active: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Mock recipe entities
|
|
31
|
+
interface MockRecipe {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
cuisine: string;
|
|
35
|
+
difficulty: string;
|
|
36
|
+
prep_time_minutes: number;
|
|
37
|
+
tags?: string[];
|
|
38
|
+
vegetarian: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generic arbitrary entity
|
|
42
|
+
interface GenericEntity {
|
|
43
|
+
id: string;
|
|
44
|
+
[key: string]: any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Test Helpers
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
function createMockInvestors(): MockInvestor[] {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
id: '1',
|
|
55
|
+
name: 'Alice Johnson',
|
|
56
|
+
firm: { name: 'Acme Ventures', stage: 'Series A', aum_usd: 100_000_000 },
|
|
57
|
+
location: 'San Francisco',
|
|
58
|
+
active: true,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: '2',
|
|
62
|
+
name: 'Bob Smith',
|
|
63
|
+
firm: { name: 'Beta Capital', stage: 'Seed', aum_usd: 50_000_000 },
|
|
64
|
+
location: 'New York',
|
|
65
|
+
active: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: '3',
|
|
69
|
+
name: 'Carol Davis',
|
|
70
|
+
firm: { name: 'Gamma Fund', stage: 'Series B', aum_usd: 500_000_000 },
|
|
71
|
+
location: 'Boston',
|
|
72
|
+
active: false,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createMockRecipes(): MockRecipe[] {
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
id: '1',
|
|
81
|
+
title: 'Pasta Carbonara',
|
|
82
|
+
cuisine: 'Italian',
|
|
83
|
+
difficulty: 'easy',
|
|
84
|
+
prep_time_minutes: 30,
|
|
85
|
+
vegetarian: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: '2',
|
|
89
|
+
title: 'Vegetable Curry',
|
|
90
|
+
cuisine: 'Indian',
|
|
91
|
+
difficulty: 'medium',
|
|
92
|
+
prep_time_minutes: 45,
|
|
93
|
+
vegetarian: true,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: '3',
|
|
97
|
+
title: 'Chocolate Cake',
|
|
98
|
+
cuisine: 'American',
|
|
99
|
+
difficulty: 'hard',
|
|
100
|
+
prep_time_minutes: 90,
|
|
101
|
+
vegetarian: true,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Tests: Empty Funnel
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
describe('FunnelEngine - Empty Funnel', () => {
|
|
111
|
+
it('should handle funnel with no stages', () => {
|
|
112
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
113
|
+
const entities = createMockInvestors();
|
|
114
|
+
|
|
115
|
+
const funnel: Funnel<MockInvestor> = {
|
|
116
|
+
id: 'funnel-1',
|
|
117
|
+
name: 'Empty Funnel',
|
|
118
|
+
status: 'active',
|
|
119
|
+
input_type: 'any',
|
|
120
|
+
stages: [],
|
|
121
|
+
created_at: new Date().toISOString(),
|
|
122
|
+
updated_at: new Date().toISOString(),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = engine.execute(funnel, entities);
|
|
126
|
+
|
|
127
|
+
expect(result.total_input).toBe(3);
|
|
128
|
+
expect(result.total_matched).toBe(3); // No exclusions
|
|
129
|
+
expect(result.total_excluded).toBe(0);
|
|
130
|
+
expect(result.matched.length).toBe(3);
|
|
131
|
+
expect(result.excluded.length).toBe(0);
|
|
132
|
+
expect(Object.keys(result.stage_stats).length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle empty entity array', () => {
|
|
136
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
137
|
+
|
|
138
|
+
const funnel: Funnel<MockInvestor> = {
|
|
139
|
+
id: 'funnel-1',
|
|
140
|
+
name: 'Test Funnel',
|
|
141
|
+
status: 'active',
|
|
142
|
+
input_type: 'any',
|
|
143
|
+
stages: [
|
|
144
|
+
{
|
|
145
|
+
id: 'stage-1',
|
|
146
|
+
order: 0,
|
|
147
|
+
name: 'Filter Stage',
|
|
148
|
+
filter_logic: 'AND',
|
|
149
|
+
rules: [],
|
|
150
|
+
match_action: 'continue',
|
|
151
|
+
no_match_action: 'exclude',
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
created_at: new Date().toISOString(),
|
|
155
|
+
updated_at: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = engine.execute(funnel, []);
|
|
159
|
+
|
|
160
|
+
expect(result.total_input).toBe(0);
|
|
161
|
+
expect(result.total_matched).toBe(0);
|
|
162
|
+
expect(result.total_excluded).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// Tests: Single Stage
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
describe('FunnelEngine - Single Stage', () => {
|
|
171
|
+
it('should process single stage with no rules (always match)', () => {
|
|
172
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
173
|
+
const entities = createMockInvestors();
|
|
174
|
+
|
|
175
|
+
const funnel: Funnel<MockInvestor> = {
|
|
176
|
+
id: 'funnel-1',
|
|
177
|
+
name: 'Single Stage Funnel',
|
|
178
|
+
status: 'active',
|
|
179
|
+
input_type: 'any',
|
|
180
|
+
stages: [
|
|
181
|
+
{
|
|
182
|
+
id: 'stage-1',
|
|
183
|
+
order: 0,
|
|
184
|
+
name: 'Pass All',
|
|
185
|
+
filter_logic: 'AND',
|
|
186
|
+
rules: [], // No rules = always match
|
|
187
|
+
match_action: 'continue',
|
|
188
|
+
no_match_action: 'exclude',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
created_at: new Date().toISOString(),
|
|
192
|
+
updated_at: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const result = engine.execute(funnel, entities);
|
|
196
|
+
|
|
197
|
+
expect(result.total_matched).toBe(3);
|
|
198
|
+
expect(result.stage_stats['stage-1'].input_count).toBe(3);
|
|
199
|
+
expect(result.stage_stats['stage-1'].matched_count).toBe(3);
|
|
200
|
+
expect(result.stage_stats['stage-1'].not_matched_count).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should apply match_action: output (stop processing)', () => {
|
|
204
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
205
|
+
const entities = createMockInvestors();
|
|
206
|
+
|
|
207
|
+
const funnel: Funnel<MockInvestor> = {
|
|
208
|
+
id: 'funnel-1',
|
|
209
|
+
name: 'Output Stage',
|
|
210
|
+
status: 'active',
|
|
211
|
+
input_type: 'any',
|
|
212
|
+
stages: [
|
|
213
|
+
{
|
|
214
|
+
id: 'stage-1',
|
|
215
|
+
order: 0,
|
|
216
|
+
name: 'Output All',
|
|
217
|
+
filter_logic: 'AND',
|
|
218
|
+
rules: [],
|
|
219
|
+
match_action: 'output', // Stop processing
|
|
220
|
+
no_match_action: 'exclude',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
created_at: new Date().toISOString(),
|
|
224
|
+
updated_at: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const result = engine.execute(funnel, entities);
|
|
228
|
+
|
|
229
|
+
expect(result.total_matched).toBe(3);
|
|
230
|
+
expect(result.matched.every(r => r.stage_results?.length === 1)).toBe(true);
|
|
231
|
+
expect(result.matched.every(r => r.stage_results![0].continued === false)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should apply match_action: tag (add tags and exclude)', () => {
|
|
235
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
236
|
+
const entities = createMockInvestors();
|
|
237
|
+
|
|
238
|
+
const funnel: Funnel<MockInvestor> = {
|
|
239
|
+
id: 'funnel-1',
|
|
240
|
+
name: 'Tag Stage',
|
|
241
|
+
status: 'active',
|
|
242
|
+
input_type: 'any',
|
|
243
|
+
stages: [
|
|
244
|
+
{
|
|
245
|
+
id: 'stage-1',
|
|
246
|
+
order: 0,
|
|
247
|
+
name: 'Tag All',
|
|
248
|
+
filter_logic: 'AND',
|
|
249
|
+
rules: [],
|
|
250
|
+
match_action: 'tag',
|
|
251
|
+
no_match_action: 'exclude',
|
|
252
|
+
match_tags: ['qualified'],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
created_at: new Date().toISOString(),
|
|
256
|
+
updated_at: new Date().toISOString(),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const result = engine.execute(funnel, entities);
|
|
260
|
+
|
|
261
|
+
// Entities are tagged but excluded (match_action: tag stops processing)
|
|
262
|
+
expect(result.total_excluded).toBe(3);
|
|
263
|
+
expect(result.excluded.every(r => r.accumulated_tags.includes('qualified'))).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should apply match_action: tag_continue (add tags and continue)', () => {
|
|
267
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
268
|
+
const entities = createMockInvestors();
|
|
269
|
+
|
|
270
|
+
const funnel: Funnel<MockInvestor> = {
|
|
271
|
+
id: 'funnel-1',
|
|
272
|
+
name: 'Tag Continue Stage',
|
|
273
|
+
status: 'active',
|
|
274
|
+
input_type: 'any',
|
|
275
|
+
stages: [
|
|
276
|
+
{
|
|
277
|
+
id: 'stage-1',
|
|
278
|
+
order: 0,
|
|
279
|
+
name: 'Tag and Continue',
|
|
280
|
+
filter_logic: 'AND',
|
|
281
|
+
rules: [],
|
|
282
|
+
match_action: 'tag_continue',
|
|
283
|
+
no_match_action: 'exclude',
|
|
284
|
+
match_tags: ['processed'],
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
created_at: new Date().toISOString(),
|
|
288
|
+
updated_at: new Date().toISOString(),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const result = engine.execute(funnel, entities);
|
|
292
|
+
|
|
293
|
+
expect(result.total_matched).toBe(3);
|
|
294
|
+
expect(result.matched.every(r => r.accumulated_tags.includes('processed'))).toBe(true);
|
|
295
|
+
expect(result.matched.every(r => r.stage_results![0].continued === true)).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should apply no_match_action: exclude', () => {
|
|
299
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
300
|
+
const entities = createMockInvestors();
|
|
301
|
+
|
|
302
|
+
const funnel: Funnel<MockInvestor> = {
|
|
303
|
+
id: 'funnel-1',
|
|
304
|
+
name: 'Exclude Stage',
|
|
305
|
+
status: 'active',
|
|
306
|
+
input_type: 'any',
|
|
307
|
+
stages: [
|
|
308
|
+
{
|
|
309
|
+
id: 'stage-1',
|
|
310
|
+
order: 0,
|
|
311
|
+
name: 'Never Match',
|
|
312
|
+
filter_logic: 'AND',
|
|
313
|
+
rules: [],
|
|
314
|
+
match_action: 'continue',
|
|
315
|
+
no_match_action: 'exclude',
|
|
316
|
+
custom_evaluator: () => false, // Always false
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
created_at: new Date().toISOString(),
|
|
320
|
+
updated_at: new Date().toISOString(),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const result = engine.execute(funnel, entities);
|
|
324
|
+
|
|
325
|
+
expect(result.total_excluded).toBe(3);
|
|
326
|
+
expect(result.excluded.every(r => r.excluded_at_stage === 'stage-1')).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should apply no_match_action: tag_exclude', () => {
|
|
330
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
331
|
+
const entities = createMockInvestors();
|
|
332
|
+
|
|
333
|
+
const funnel: Funnel<MockInvestor> = {
|
|
334
|
+
id: 'funnel-1',
|
|
335
|
+
name: 'Tag Exclude Stage',
|
|
336
|
+
status: 'active',
|
|
337
|
+
input_type: 'any',
|
|
338
|
+
stages: [
|
|
339
|
+
{
|
|
340
|
+
id: 'stage-1',
|
|
341
|
+
order: 0,
|
|
342
|
+
name: 'Tag on No Match',
|
|
343
|
+
filter_logic: 'AND',
|
|
344
|
+
rules: [],
|
|
345
|
+
match_action: 'continue',
|
|
346
|
+
no_match_action: 'tag_exclude',
|
|
347
|
+
no_match_tags: ['rejected'],
|
|
348
|
+
custom_evaluator: () => false,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
created_at: new Date().toISOString(),
|
|
352
|
+
updated_at: new Date().toISOString(),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const result = engine.execute(funnel, entities);
|
|
356
|
+
|
|
357
|
+
expect(result.total_excluded).toBe(3);
|
|
358
|
+
expect(result.excluded.every(r => r.accumulated_tags.includes('rejected'))).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should add match_context when rules match', () => {
|
|
362
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
363
|
+
const entities = createMockInvestors();
|
|
364
|
+
|
|
365
|
+
const funnel: Funnel<MockInvestor> = {
|
|
366
|
+
id: 'funnel-1',
|
|
367
|
+
name: 'Context Stage',
|
|
368
|
+
status: 'active',
|
|
369
|
+
input_type: 'any',
|
|
370
|
+
stages: [
|
|
371
|
+
{
|
|
372
|
+
id: 'stage-1',
|
|
373
|
+
order: 0,
|
|
374
|
+
name: 'Add Context',
|
|
375
|
+
filter_logic: 'AND',
|
|
376
|
+
rules: [],
|
|
377
|
+
match_action: 'continue',
|
|
378
|
+
no_match_action: 'exclude',
|
|
379
|
+
match_context: { tier: 'premium', score: 100 },
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
created_at: new Date().toISOString(),
|
|
383
|
+
updated_at: new Date().toISOString(),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const result = engine.execute(funnel, entities);
|
|
387
|
+
|
|
388
|
+
expect(result.matched.every(r => r.context.tier === 'premium')).toBe(true);
|
|
389
|
+
expect(result.matched.every(r => r.context.score === 100)).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// Tests: Multi-Stage
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
describe('FunnelEngine - Multi-Stage', () => {
|
|
398
|
+
it('should process multiple stages in order', () => {
|
|
399
|
+
const engine = new FunnelEngine<MockRecipe>();
|
|
400
|
+
const entities = createMockRecipes();
|
|
401
|
+
|
|
402
|
+
const funnel: Funnel<MockRecipe> = {
|
|
403
|
+
id: 'funnel-1',
|
|
404
|
+
name: 'Multi-Stage Funnel',
|
|
405
|
+
status: 'active',
|
|
406
|
+
input_type: 'any',
|
|
407
|
+
stages: [
|
|
408
|
+
{
|
|
409
|
+
id: 'stage-1',
|
|
410
|
+
order: 0,
|
|
411
|
+
name: 'Stage 1',
|
|
412
|
+
filter_logic: 'AND',
|
|
413
|
+
rules: [],
|
|
414
|
+
match_action: 'continue',
|
|
415
|
+
no_match_action: 'exclude',
|
|
416
|
+
match_tags: ['stage-1'],
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
id: 'stage-2',
|
|
420
|
+
order: 1,
|
|
421
|
+
name: 'Stage 2',
|
|
422
|
+
filter_logic: 'AND',
|
|
423
|
+
rules: [],
|
|
424
|
+
match_action: 'continue',
|
|
425
|
+
no_match_action: 'exclude',
|
|
426
|
+
match_tags: ['stage-2'],
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
id: 'stage-3',
|
|
430
|
+
order: 2,
|
|
431
|
+
name: 'Stage 3',
|
|
432
|
+
filter_logic: 'AND',
|
|
433
|
+
rules: [],
|
|
434
|
+
match_action: 'output',
|
|
435
|
+
no_match_action: 'exclude',
|
|
436
|
+
match_tags: ['stage-3'],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
created_at: new Date().toISOString(),
|
|
440
|
+
updated_at: new Date().toISOString(),
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const result = engine.execute(funnel, entities);
|
|
444
|
+
|
|
445
|
+
expect(result.total_matched).toBe(3);
|
|
446
|
+
expect(result.matched.every(r => r.stage_results?.length === 3)).toBe(true);
|
|
447
|
+
expect(result.matched.every(r =>
|
|
448
|
+
r.accumulated_tags.includes('stage-1') &&
|
|
449
|
+
r.accumulated_tags.includes('stage-2') &&
|
|
450
|
+
r.accumulated_tags.includes('stage-3')
|
|
451
|
+
)).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should exclude entities at intermediate stages', () => {
|
|
455
|
+
const engine = new FunnelEngine<MockInvestor>();
|
|
456
|
+
const entities = createMockInvestors();
|
|
457
|
+
|
|
458
|
+
const funnel: Funnel<MockInvestor> = {
|
|
459
|
+
id: 'funnel-1',
|
|
460
|
+
name: 'Filtering Funnel',
|
|
461
|
+
status: 'active',
|
|
462
|
+
input_type: 'any',
|
|
463
|
+
stages: [
|
|
464
|
+
{
|
|
465
|
+
id: 'stage-1',
|
|
466
|
+
order: 0,
|
|
467
|
+
name: 'Pass All',
|
|
468
|
+
filter_logic: 'AND',
|
|
469
|
+
rules: [],
|
|
470
|
+
match_action: 'continue',
|
|
471
|
+
no_match_action: 'exclude',
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
id: 'stage-2',
|
|
475
|
+
order: 1,
|
|
476
|
+
name: 'Filter Some',
|
|
477
|
+
filter_logic: 'AND',
|
|
478
|
+
rules: [],
|
|
479
|
+
match_action: 'continue',
|
|
480
|
+
no_match_action: 'exclude',
|
|
481
|
+
custom_evaluator: (entity) => entity.id === '1' || entity.id === '2',
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
id: 'stage-3',
|
|
485
|
+
order: 2,
|
|
486
|
+
name: 'Final Filter',
|
|
487
|
+
filter_logic: 'AND',
|
|
488
|
+
rules: [],
|
|
489
|
+
match_action: 'output',
|
|
490
|
+
no_match_action: 'exclude',
|
|
491
|
+
custom_evaluator: (entity) => entity.id === '1',
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
created_at: new Date().toISOString(),
|
|
495
|
+
updated_at: new Date().toISOString(),
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const result = engine.execute(funnel, entities);
|
|
499
|
+
|
|
500
|
+
expect(result.total_matched).toBe(1);
|
|
501
|
+
expect(result.matched[0].entity.id).toBe('1');
|
|
502
|
+
expect(result.total_excluded).toBe(2);
|
|
503
|
+
|
|
504
|
+
// Check exclusion stages
|
|
505
|
+
const excludedAtStage2 = result.excluded.find(r => r.excluded_at_stage === 'stage-2');
|
|
506
|
+
const excludedAtStage3 = result.excluded.find(r => r.excluded_at_stage === 'stage-3');
|
|
507
|
+
|
|
508
|
+
expect(excludedAtStage2?.entity.id).toBe('3');
|
|
509
|
+
expect(excludedAtStage3?.entity.id).toBe('2');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should respect stage order even if stages are unsorted', () => {
|
|
513
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
514
|
+
const entities: GenericEntity[] = [
|
|
515
|
+
{ id: '1', value: 10 },
|
|
516
|
+
{ id: '2', value: 20 },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
const funnel: Funnel<GenericEntity> = {
|
|
520
|
+
id: 'funnel-1',
|
|
521
|
+
name: 'Unsorted Stages',
|
|
522
|
+
status: 'active',
|
|
523
|
+
input_type: 'any',
|
|
524
|
+
stages: [
|
|
525
|
+
{
|
|
526
|
+
id: 'stage-3',
|
|
527
|
+
order: 2,
|
|
528
|
+
name: 'Third Stage',
|
|
529
|
+
filter_logic: 'AND',
|
|
530
|
+
rules: [],
|
|
531
|
+
match_action: 'continue',
|
|
532
|
+
no_match_action: 'exclude',
|
|
533
|
+
match_tags: ['third'],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
id: 'stage-1',
|
|
537
|
+
order: 0,
|
|
538
|
+
name: 'First Stage',
|
|
539
|
+
filter_logic: 'AND',
|
|
540
|
+
rules: [],
|
|
541
|
+
match_action: 'continue',
|
|
542
|
+
no_match_action: 'exclude',
|
|
543
|
+
match_tags: ['first'],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: 'stage-2',
|
|
547
|
+
order: 1,
|
|
548
|
+
name: 'Second Stage',
|
|
549
|
+
filter_logic: 'AND',
|
|
550
|
+
rules: [],
|
|
551
|
+
match_action: 'continue',
|
|
552
|
+
no_match_action: 'exclude',
|
|
553
|
+
match_tags: ['second'],
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
created_at: new Date().toISOString(),
|
|
557
|
+
updated_at: new Date().toISOString(),
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const result = engine.execute(funnel, entities);
|
|
561
|
+
|
|
562
|
+
// Tags should be added in order: first, second, third
|
|
563
|
+
expect(result.matched[0].accumulated_tags).toEqual(['first', 'second', 'third']);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ============================================================================
|
|
568
|
+
// Tests: AND Logic
|
|
569
|
+
// ============================================================================
|
|
570
|
+
|
|
571
|
+
describe('FunnelEngine - AND Logic', () => {
|
|
572
|
+
it('should require all rules to match with AND logic', () => {
|
|
573
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
574
|
+
const entities: GenericEntity[] = [
|
|
575
|
+
{ id: '1', value: 10, active: true },
|
|
576
|
+
{ id: '2', value: 20, active: false },
|
|
577
|
+
{ id: '3', value: 30, active: true },
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
const funnel: Funnel<GenericEntity> = {
|
|
581
|
+
id: 'funnel-1',
|
|
582
|
+
name: 'AND Logic Funnel',
|
|
583
|
+
status: 'active',
|
|
584
|
+
input_type: 'any',
|
|
585
|
+
stages: [
|
|
586
|
+
{
|
|
587
|
+
id: 'stage-1',
|
|
588
|
+
order: 0,
|
|
589
|
+
name: 'AND Stage',
|
|
590
|
+
filter_logic: 'AND',
|
|
591
|
+
rules: [
|
|
592
|
+
{ field_path: 'value', operator: 'gte', value: 20 },
|
|
593
|
+
{ field_path: 'active', operator: 'is_true', value: null },
|
|
594
|
+
],
|
|
595
|
+
match_action: 'output',
|
|
596
|
+
no_match_action: 'exclude',
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
created_at: new Date().toISOString(),
|
|
600
|
+
updated_at: new Date().toISOString(),
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Mock rule evaluator to use custom_evaluator
|
|
604
|
+
const stageWithEvaluator: FunnelStage<GenericEntity> = {
|
|
605
|
+
...funnel.stages[0],
|
|
606
|
+
custom_evaluator: (entity) => entity.value >= 20 && entity.active === true,
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
funnel.stages[0] = stageWithEvaluator;
|
|
610
|
+
|
|
611
|
+
const result = engine.execute(funnel, entities);
|
|
612
|
+
|
|
613
|
+
// Only entity 3 matches both conditions
|
|
614
|
+
expect(result.total_matched).toBe(1);
|
|
615
|
+
expect(result.matched[0].entity.id).toBe('3');
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ============================================================================
|
|
620
|
+
// Tests: OR Logic
|
|
621
|
+
// ============================================================================
|
|
622
|
+
|
|
623
|
+
describe('FunnelEngine - OR Logic', () => {
|
|
624
|
+
it('should require at least one rule to match with OR logic', () => {
|
|
625
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
626
|
+
const entities: GenericEntity[] = [
|
|
627
|
+
{ id: '1', value: 10, active: true },
|
|
628
|
+
{ id: '2', value: 20, active: false },
|
|
629
|
+
{ id: '3', value: 5, active: false },
|
|
630
|
+
];
|
|
631
|
+
|
|
632
|
+
const funnel: Funnel<GenericEntity> = {
|
|
633
|
+
id: 'funnel-1',
|
|
634
|
+
name: 'OR Logic Funnel',
|
|
635
|
+
status: 'active',
|
|
636
|
+
input_type: 'any',
|
|
637
|
+
stages: [
|
|
638
|
+
{
|
|
639
|
+
id: 'stage-1',
|
|
640
|
+
order: 0,
|
|
641
|
+
name: 'OR Stage',
|
|
642
|
+
filter_logic: 'OR',
|
|
643
|
+
rules: [
|
|
644
|
+
{ field_path: 'value', operator: 'gte', value: 20 },
|
|
645
|
+
{ field_path: 'active', operator: 'is_true', value: null },
|
|
646
|
+
],
|
|
647
|
+
match_action: 'output',
|
|
648
|
+
no_match_action: 'exclude',
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
created_at: new Date().toISOString(),
|
|
652
|
+
updated_at: new Date().toISOString(),
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Mock rule evaluator
|
|
656
|
+
const stageWithEvaluator: FunnelStage<GenericEntity> = {
|
|
657
|
+
...funnel.stages[0],
|
|
658
|
+
custom_evaluator: (entity) => entity.value >= 20 || entity.active === true,
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
funnel.stages[0] = stageWithEvaluator;
|
|
662
|
+
|
|
663
|
+
const result = engine.execute(funnel, entities);
|
|
664
|
+
|
|
665
|
+
// Entities 1 and 2 match (1 has active=true, 2 has value>=20)
|
|
666
|
+
expect(result.total_matched).toBe(2);
|
|
667
|
+
expect(result.matched.map(r => r.entity.id).sort()).toEqual(['1', '2']);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// ============================================================================
|
|
672
|
+
// Tests: Tags
|
|
673
|
+
// ============================================================================
|
|
674
|
+
|
|
675
|
+
describe('FunnelEngine - Tags', () => {
|
|
676
|
+
it('should accumulate tags across stages', () => {
|
|
677
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
678
|
+
const entities: GenericEntity[] = [{ id: '1', value: 10 }];
|
|
679
|
+
|
|
680
|
+
const funnel: Funnel<GenericEntity> = {
|
|
681
|
+
id: 'funnel-1',
|
|
682
|
+
name: 'Tag Accumulation',
|
|
683
|
+
status: 'active',
|
|
684
|
+
input_type: 'any',
|
|
685
|
+
stages: [
|
|
686
|
+
{
|
|
687
|
+
id: 'stage-1',
|
|
688
|
+
order: 0,
|
|
689
|
+
name: 'Stage 1',
|
|
690
|
+
filter_logic: 'AND',
|
|
691
|
+
rules: [],
|
|
692
|
+
match_action: 'continue',
|
|
693
|
+
no_match_action: 'exclude',
|
|
694
|
+
match_tags: ['tag-1', 'tag-2'],
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
id: 'stage-2',
|
|
698
|
+
order: 1,
|
|
699
|
+
name: 'Stage 2',
|
|
700
|
+
filter_logic: 'AND',
|
|
701
|
+
rules: [],
|
|
702
|
+
match_action: 'continue',
|
|
703
|
+
no_match_action: 'exclude',
|
|
704
|
+
match_tags: ['tag-3'],
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
created_at: new Date().toISOString(),
|
|
708
|
+
updated_at: new Date().toISOString(),
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const result = engine.execute(funnel, entities);
|
|
712
|
+
|
|
713
|
+
expect(result.matched[0].accumulated_tags).toEqual(['tag-1', 'tag-2', 'tag-3']);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('should apply completion tags to matched entities', () => {
|
|
717
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
718
|
+
const entities: GenericEntity[] = [
|
|
719
|
+
{ id: '1', value: 10 },
|
|
720
|
+
{ id: '2', value: 20 },
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
const funnel: Funnel<GenericEntity> = {
|
|
724
|
+
id: 'funnel-1',
|
|
725
|
+
name: 'Completion Tags',
|
|
726
|
+
status: 'active',
|
|
727
|
+
input_type: 'any',
|
|
728
|
+
stages: [
|
|
729
|
+
{
|
|
730
|
+
id: 'stage-1',
|
|
731
|
+
order: 0,
|
|
732
|
+
name: 'Filter',
|
|
733
|
+
filter_logic: 'AND',
|
|
734
|
+
rules: [],
|
|
735
|
+
match_action: 'output',
|
|
736
|
+
no_match_action: 'exclude',
|
|
737
|
+
custom_evaluator: (entity) => entity.value >= 20,
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
completion_tags: ['completed', 'qualified'],
|
|
741
|
+
created_at: new Date().toISOString(),
|
|
742
|
+
updated_at: new Date().toISOString(),
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const result = engine.execute(funnel, entities);
|
|
746
|
+
|
|
747
|
+
expect(result.total_matched).toBe(1);
|
|
748
|
+
expect(result.matched[0].accumulated_tags).toContain('completed');
|
|
749
|
+
expect(result.matched[0].accumulated_tags).toContain('qualified');
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('should count tagged entities correctly', () => {
|
|
753
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
754
|
+
const entities: GenericEntity[] = [
|
|
755
|
+
{ id: '1', value: 10 },
|
|
756
|
+
{ id: '2', value: 20 },
|
|
757
|
+
{ id: '3', value: 30 },
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
const funnel: Funnel<GenericEntity> = {
|
|
761
|
+
id: 'funnel-1',
|
|
762
|
+
name: 'Tag Counting',
|
|
763
|
+
status: 'active',
|
|
764
|
+
input_type: 'any',
|
|
765
|
+
stages: [
|
|
766
|
+
{
|
|
767
|
+
id: 'stage-1',
|
|
768
|
+
order: 0,
|
|
769
|
+
name: 'Tag Some',
|
|
770
|
+
filter_logic: 'AND',
|
|
771
|
+
rules: [],
|
|
772
|
+
match_action: 'continue',
|
|
773
|
+
no_match_action: 'continue',
|
|
774
|
+
match_tags: ['tagged'],
|
|
775
|
+
custom_evaluator: (entity) => entity.value >= 20,
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
created_at: new Date().toISOString(),
|
|
779
|
+
updated_at: new Date().toISOString(),
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const result = engine.execute(funnel, entities);
|
|
783
|
+
|
|
784
|
+
expect(result.total_tagged).toBe(2); // Entities 2 and 3
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// ============================================================================
|
|
789
|
+
// Tests: Exclusions
|
|
790
|
+
// ============================================================================
|
|
791
|
+
|
|
792
|
+
describe('FunnelEngine - Exclusions', () => {
|
|
793
|
+
it('should track which stage excluded each entity', () => {
|
|
794
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
795
|
+
const entities: GenericEntity[] = [
|
|
796
|
+
{ id: '1', value: 10 },
|
|
797
|
+
{ id: '2', value: 20 },
|
|
798
|
+
{ id: '3', value: 30 },
|
|
799
|
+
];
|
|
800
|
+
|
|
801
|
+
const funnel: Funnel<GenericEntity> = {
|
|
802
|
+
id: 'funnel-1',
|
|
803
|
+
name: 'Exclusion Tracking',
|
|
804
|
+
status: 'active',
|
|
805
|
+
input_type: 'any',
|
|
806
|
+
stages: [
|
|
807
|
+
{
|
|
808
|
+
id: 'stage-1',
|
|
809
|
+
order: 0,
|
|
810
|
+
name: 'Stage 1',
|
|
811
|
+
filter_logic: 'AND',
|
|
812
|
+
rules: [],
|
|
813
|
+
match_action: 'continue',
|
|
814
|
+
no_match_action: 'exclude',
|
|
815
|
+
custom_evaluator: (entity) => entity.value >= 20,
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
id: 'stage-2',
|
|
819
|
+
order: 1,
|
|
820
|
+
name: 'Stage 2',
|
|
821
|
+
filter_logic: 'AND',
|
|
822
|
+
rules: [],
|
|
823
|
+
match_action: 'output',
|
|
824
|
+
no_match_action: 'exclude',
|
|
825
|
+
custom_evaluator: (entity) => entity.value >= 30,
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
created_at: new Date().toISOString(),
|
|
829
|
+
updated_at: new Date().toISOString(),
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const result = engine.execute(funnel, entities);
|
|
833
|
+
|
|
834
|
+
expect(result.total_matched).toBe(1);
|
|
835
|
+
expect(result.matched[0].entity.id).toBe('3');
|
|
836
|
+
|
|
837
|
+
const excludedAtStage1 = result.excluded.find(r => r.excluded_at_stage === 'stage-1');
|
|
838
|
+
const excludedAtStage2 = result.excluded.find(r => r.excluded_at_stage === 'stage-2');
|
|
839
|
+
|
|
840
|
+
expect(excludedAtStage1?.entity.id).toBe('1');
|
|
841
|
+
expect(excludedAtStage2?.entity.id).toBe('2');
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should stop processing excluded entities', () => {
|
|
845
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
846
|
+
const entities: GenericEntity[] = [{ id: '1', value: 10 }];
|
|
847
|
+
|
|
848
|
+
const funnel: Funnel<GenericEntity> = {
|
|
849
|
+
id: 'funnel-1',
|
|
850
|
+
name: 'Stop After Exclusion',
|
|
851
|
+
status: 'active',
|
|
852
|
+
input_type: 'any',
|
|
853
|
+
stages: [
|
|
854
|
+
{
|
|
855
|
+
id: 'stage-1',
|
|
856
|
+
order: 0,
|
|
857
|
+
name: 'Exclude',
|
|
858
|
+
filter_logic: 'AND',
|
|
859
|
+
rules: [],
|
|
860
|
+
match_action: 'continue',
|
|
861
|
+
no_match_action: 'exclude',
|
|
862
|
+
custom_evaluator: () => false,
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
id: 'stage-2',
|
|
866
|
+
order: 1,
|
|
867
|
+
name: 'Should Not Reach',
|
|
868
|
+
filter_logic: 'AND',
|
|
869
|
+
rules: [],
|
|
870
|
+
match_action: 'output',
|
|
871
|
+
no_match_action: 'exclude',
|
|
872
|
+
match_tags: ['unreachable'],
|
|
873
|
+
},
|
|
874
|
+
],
|
|
875
|
+
created_at: new Date().toISOString(),
|
|
876
|
+
updated_at: new Date().toISOString(),
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
const result = engine.execute(funnel, entities);
|
|
880
|
+
|
|
881
|
+
expect(result.excluded[0].stage_results?.length).toBe(1); // Only stage-1
|
|
882
|
+
expect(result.excluded[0].accumulated_tags).not.toContain('unreachable');
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// ============================================================================
|
|
887
|
+
// Tests: Statistics
|
|
888
|
+
// ============================================================================
|
|
889
|
+
|
|
890
|
+
describe('FunnelEngine - Statistics', () => {
|
|
891
|
+
it('should track stage statistics correctly', () => {
|
|
892
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
893
|
+
const entities: GenericEntity[] = [
|
|
894
|
+
{ id: '1', value: 10 },
|
|
895
|
+
{ id: '2', value: 20 },
|
|
896
|
+
{ id: '3', value: 30 },
|
|
897
|
+
];
|
|
898
|
+
|
|
899
|
+
const funnel: Funnel<GenericEntity> = {
|
|
900
|
+
id: 'funnel-1',
|
|
901
|
+
name: 'Stats Funnel',
|
|
902
|
+
status: 'active',
|
|
903
|
+
input_type: 'any',
|
|
904
|
+
stages: [
|
|
905
|
+
{
|
|
906
|
+
id: 'stage-1',
|
|
907
|
+
order: 0,
|
|
908
|
+
name: 'Filter Stage',
|
|
909
|
+
filter_logic: 'AND',
|
|
910
|
+
rules: [],
|
|
911
|
+
match_action: 'continue',
|
|
912
|
+
no_match_action: 'exclude',
|
|
913
|
+
match_tags: ['qualified'],
|
|
914
|
+
custom_evaluator: (entity) => entity.value >= 20,
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
created_at: new Date().toISOString(),
|
|
918
|
+
updated_at: new Date().toISOString(),
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const result = engine.execute(funnel, entities);
|
|
922
|
+
|
|
923
|
+
const stats = result.stage_stats['stage-1'];
|
|
924
|
+
|
|
925
|
+
expect(stats.input_count).toBe(3);
|
|
926
|
+
expect(stats.matched_count).toBe(2);
|
|
927
|
+
expect(stats.not_matched_count).toBe(1);
|
|
928
|
+
expect(stats.excluded_count).toBe(1);
|
|
929
|
+
expect(stats.tagged_count).toBe(2);
|
|
930
|
+
expect(stats.continued_count).toBe(2);
|
|
931
|
+
expect(stats.duration_ms).toBeGreaterThanOrEqual(0);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('should track execution duration', () => {
|
|
935
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
936
|
+
const entities: GenericEntity[] = [{ id: '1', value: 10 }];
|
|
937
|
+
|
|
938
|
+
const funnel: Funnel<GenericEntity> = {
|
|
939
|
+
id: 'funnel-1',
|
|
940
|
+
name: 'Duration Test',
|
|
941
|
+
status: 'active',
|
|
942
|
+
input_type: 'any',
|
|
943
|
+
stages: [
|
|
944
|
+
{
|
|
945
|
+
id: 'stage-1',
|
|
946
|
+
order: 0,
|
|
947
|
+
name: 'Test Stage',
|
|
948
|
+
filter_logic: 'AND',
|
|
949
|
+
rules: [],
|
|
950
|
+
match_action: 'output',
|
|
951
|
+
no_match_action: 'exclude',
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
created_at: new Date().toISOString(),
|
|
955
|
+
updated_at: new Date().toISOString(),
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const result = engine.execute(funnel, entities);
|
|
959
|
+
|
|
960
|
+
expect(result.duration_ms).toBeGreaterThanOrEqual(0);
|
|
961
|
+
expect(typeof result.duration_ms).toBe('number');
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// ============================================================================
|
|
966
|
+
// Tests: Custom Evaluators
|
|
967
|
+
// ============================================================================
|
|
968
|
+
|
|
969
|
+
describe('FunnelEngine - Custom Evaluators', () => {
|
|
970
|
+
it('should use custom_evaluator when provided', () => {
|
|
971
|
+
const engine = new FunnelEngine<MockRecipe>();
|
|
972
|
+
const entities = createMockRecipes();
|
|
973
|
+
|
|
974
|
+
const funnel: Funnel<MockRecipe> = {
|
|
975
|
+
id: 'funnel-1',
|
|
976
|
+
name: 'Custom Eval Funnel',
|
|
977
|
+
status: 'active',
|
|
978
|
+
input_type: 'any',
|
|
979
|
+
stages: [
|
|
980
|
+
{
|
|
981
|
+
id: 'stage-1',
|
|
982
|
+
order: 0,
|
|
983
|
+
name: 'Quick Recipes',
|
|
984
|
+
filter_logic: 'AND',
|
|
985
|
+
rules: [],
|
|
986
|
+
match_action: 'output',
|
|
987
|
+
no_match_action: 'exclude',
|
|
988
|
+
custom_evaluator: (recipe) => recipe.prep_time_minutes <= 45,
|
|
989
|
+
},
|
|
990
|
+
],
|
|
991
|
+
created_at: new Date().toISOString(),
|
|
992
|
+
updated_at: new Date().toISOString(),
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const result = engine.execute(funnel, entities);
|
|
996
|
+
|
|
997
|
+
expect(result.total_matched).toBe(2); // Pasta and Curry
|
|
998
|
+
expect(result.matched.every(r => r.entity.prep_time_minutes <= 45)).toBe(true);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('should handle custom_evaluator errors gracefully', () => {
|
|
1002
|
+
const engine = new FunnelEngine<GenericEntity>();
|
|
1003
|
+
const entities: GenericEntity[] = [{ id: '1', value: 10 }];
|
|
1004
|
+
|
|
1005
|
+
const funnel: Funnel<GenericEntity> = {
|
|
1006
|
+
id: 'funnel-1',
|
|
1007
|
+
name: 'Error Handling',
|
|
1008
|
+
status: 'active',
|
|
1009
|
+
input_type: 'any',
|
|
1010
|
+
stages: [
|
|
1011
|
+
{
|
|
1012
|
+
id: 'stage-1',
|
|
1013
|
+
order: 0,
|
|
1014
|
+
name: 'Throw Error',
|
|
1015
|
+
filter_logic: 'AND',
|
|
1016
|
+
rules: [],
|
|
1017
|
+
match_action: 'output',
|
|
1018
|
+
no_match_action: 'exclude',
|
|
1019
|
+
custom_evaluator: () => {
|
|
1020
|
+
throw new Error('Evaluator failed');
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
],
|
|
1024
|
+
created_at: new Date().toISOString(),
|
|
1025
|
+
updated_at: new Date().toISOString(),
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
const result = engine.execute(funnel, entities);
|
|
1029
|
+
|
|
1030
|
+
// Should treat error as no match
|
|
1031
|
+
expect(result.total_matched).toBe(0);
|
|
1032
|
+
expect(result.total_excluded).toBe(1);
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// ============================================================================
|
|
1037
|
+
// Tests: Generic Entity Types
|
|
1038
|
+
// ============================================================================
|
|
1039
|
+
|
|
1040
|
+
describe('FunnelEngine - Generic Entity Types', () => {
|
|
1041
|
+
it('should work with arbitrary entity structures', () => {
|
|
1042
|
+
interface ArbitraryEntity {
|
|
1043
|
+
uuid: string;
|
|
1044
|
+
nested: {
|
|
1045
|
+
deep: {
|
|
1046
|
+
value: number;
|
|
1047
|
+
};
|
|
1048
|
+
};
|
|
1049
|
+
list: string[];
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const engine = new FunnelEngine<ArbitraryEntity>();
|
|
1053
|
+
const entities: ArbitraryEntity[] = [
|
|
1054
|
+
{
|
|
1055
|
+
uuid: 'abc-123',
|
|
1056
|
+
nested: { deep: { value: 42 } },
|
|
1057
|
+
list: ['a', 'b', 'c'],
|
|
1058
|
+
},
|
|
1059
|
+
];
|
|
1060
|
+
|
|
1061
|
+
const funnel: Funnel<ArbitraryEntity> = {
|
|
1062
|
+
id: 'funnel-1',
|
|
1063
|
+
name: 'Arbitrary Funnel',
|
|
1064
|
+
status: 'active',
|
|
1065
|
+
input_type: 'any',
|
|
1066
|
+
stages: [
|
|
1067
|
+
{
|
|
1068
|
+
id: 'stage-1',
|
|
1069
|
+
order: 0,
|
|
1070
|
+
name: 'Test Stage',
|
|
1071
|
+
filter_logic: 'AND',
|
|
1072
|
+
rules: [],
|
|
1073
|
+
match_action: 'output',
|
|
1074
|
+
no_match_action: 'exclude',
|
|
1075
|
+
},
|
|
1076
|
+
],
|
|
1077
|
+
created_at: new Date().toISOString(),
|
|
1078
|
+
updated_at: new Date().toISOString(),
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const result = engine.execute(funnel, entities);
|
|
1082
|
+
|
|
1083
|
+
expect(result.total_matched).toBe(1);
|
|
1084
|
+
expect(result.matched[0].entity.uuid).toBe('abc-123');
|
|
1085
|
+
expect(result.matched[0].entity.nested.deep.value).toBe(42);
|
|
1086
|
+
});
|
|
1087
|
+
});
|