@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.
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 -3243
  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 -3196
  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 -21
  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 -19
  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 -391
  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 -388
  151. 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
+ });