@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,257 @@
1
+ /**
2
+ * FunnelCard Component Tests
3
+ *
4
+ * Test Coverage:
5
+ * - Rendering with different funnel states (active, draft, paused, archived)
6
+ * - Rendering with different stage counts (0, 1, 5, 10+)
7
+ * - Rendering with/without latest run data
8
+ * - Callback invocations (onViewFlow, onEdit)
9
+ * - Accessibility attributes
10
+ * - Edge cases (empty description, no stages, failed run)
11
+ *
12
+ * NOTE: These tests require React and @testing-library/react to run.
13
+ * Install with: npm install --save-dev react @types/react @testing-library/react @testing-library/jest-dom
14
+ */
15
+
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import { Funnel, FunnelRun, FunnelStatus } from '../../types';
18
+
19
+ // Test data factories
20
+ function createFunnel(overrides?: Partial<Funnel>): Funnel {
21
+ return {
22
+ id: 'funnel-1',
23
+ name: 'Test Funnel',
24
+ description: 'Test funnel description',
25
+ status: 'active',
26
+ input_type: 'any',
27
+ stages: [
28
+ {
29
+ id: 'stage-1',
30
+ order: 0,
31
+ name: 'Stage 1',
32
+ description: 'First stage',
33
+ filter_logic: 'AND',
34
+ rules: [
35
+ { field_path: 'name', operator: 'eq', value: 'test' }
36
+ ],
37
+ match_action: 'continue',
38
+ no_match_action: 'exclude',
39
+ },
40
+ {
41
+ id: 'stage-2',
42
+ order: 1,
43
+ name: 'Stage 2',
44
+ description: 'Second stage',
45
+ filter_logic: 'OR',
46
+ rules: [
47
+ { field_path: 'value', operator: 'gt', value: 100 },
48
+ { field_path: 'type', operator: 'in', value: ['A', 'B'] },
49
+ { field_path: 'active', operator: 'is_true', value: null },
50
+ ],
51
+ match_action: 'output',
52
+ no_match_action: 'exclude',
53
+ },
54
+ ],
55
+ created_at: '2024-01-01T00:00:00Z',
56
+ updated_at: '2024-01-01T00:00:00Z',
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ function createFunnelRun(overrides?: Partial<FunnelRun>): FunnelRun {
62
+ return {
63
+ id: 'run-1',
64
+ funnel_id: 'funnel-1',
65
+ status: 'completed',
66
+ trigger_type: 'manual',
67
+ started_at: '2024-01-01T00:00:00Z',
68
+ completed_at: '2024-01-01T00:01:00Z',
69
+ duration_ms: 60000,
70
+ total_input: 83061,
71
+ total_matched: 235,
72
+ total_excluded: 82826,
73
+ total_tagged: 235,
74
+ stage_stats: {},
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe('FunnelCard', () => {
80
+ describe('Test Data Factories', () => {
81
+ it('creates valid funnel', () => {
82
+ const funnel = createFunnel();
83
+
84
+ expect(funnel.id).toBe('funnel-1');
85
+ expect(funnel.name).toBe('Test Funnel');
86
+ expect(funnel.status).toBe('active');
87
+ expect(funnel.stages).toHaveLength(2);
88
+ });
89
+
90
+ it('creates funnel with overrides', () => {
91
+ const funnel = createFunnel({ name: 'Custom Funnel', status: 'draft' });
92
+
93
+ expect(funnel.name).toBe('Custom Funnel');
94
+ expect(funnel.status).toBe('draft');
95
+ });
96
+
97
+ it('creates valid funnel run', () => {
98
+ const run = createFunnelRun();
99
+
100
+ expect(run.id).toBe('run-1');
101
+ expect(run.status).toBe('completed');
102
+ expect(run.total_input).toBe(83061);
103
+ expect(run.total_matched).toBe(235);
104
+ });
105
+
106
+ it('creates funnel run with overrides', () => {
107
+ const run = createFunnelRun({ total_matched: 1000 });
108
+
109
+ expect(run.total_matched).toBe(1000);
110
+ });
111
+ });
112
+
113
+ describe('Funnel Validation', () => {
114
+ it('validates funnel with 0 stages', () => {
115
+ const funnel = createFunnel({ stages: [] });
116
+
117
+ expect(funnel.stages).toHaveLength(0);
118
+ });
119
+
120
+ it('validates funnel with 5 stages', () => {
121
+ const stages = Array.from({ length: 5 }, (_, i) => ({
122
+ id: `stage-${i}`,
123
+ order: i,
124
+ name: `Stage ${i + 1}`,
125
+ filter_logic: 'AND' as const,
126
+ rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
127
+ match_action: 'continue' as const,
128
+ no_match_action: 'exclude' as const,
129
+ }));
130
+ const funnel = createFunnel({ stages });
131
+
132
+ expect(funnel.stages).toHaveLength(5);
133
+ expect(funnel.stages[0].order).toBe(0);
134
+ expect(funnel.stages[4].order).toBe(4);
135
+ });
136
+
137
+ it('validates different status values', () => {
138
+ const statuses: FunnelStatus[] = ['active', 'draft', 'paused', 'archived'];
139
+
140
+ statuses.forEach((status) => {
141
+ const funnel = createFunnel({ status });
142
+ expect(funnel.status).toBe(status);
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('Run Stats Calculation', () => {
148
+ it('calculates match percentage correctly', () => {
149
+ const run = createFunnelRun();
150
+ const percentage = (run.total_matched / run.total_input) * 100;
151
+
152
+ expect(percentage).toBeCloseTo(0.28, 2); // 235/83061 ≈ 0.28%
153
+ });
154
+
155
+ it('handles zero input', () => {
156
+ const run = createFunnelRun({ total_input: 0, total_matched: 0 });
157
+ const percentage = run.total_input > 0 ? (run.total_matched / run.total_input) * 100 : 0;
158
+
159
+ expect(percentage).toBe(0);
160
+ });
161
+
162
+ it('handles 100% match rate', () => {
163
+ const run = createFunnelRun({ total_input: 1000, total_matched: 1000, total_excluded: 0 });
164
+ const percentage = (run.total_matched / run.total_input) * 100;
165
+
166
+ expect(percentage).toBe(100);
167
+ });
168
+ });
169
+
170
+ describe('Stage Rule Counting', () => {
171
+ it('counts rules correctly', () => {
172
+ const funnel = createFunnel();
173
+
174
+ expect(funnel.stages[0].rules).toHaveLength(1);
175
+ expect(funnel.stages[1].rules).toHaveLength(3);
176
+ });
177
+
178
+ it('handles stages with no rules', () => {
179
+ const funnel = createFunnel({
180
+ stages: [
181
+ {
182
+ id: 'stage-1',
183
+ order: 0,
184
+ name: 'Empty Stage',
185
+ filter_logic: 'AND',
186
+ rules: [],
187
+ match_action: 'continue',
188
+ no_match_action: 'exclude',
189
+ },
190
+ ],
191
+ });
192
+
193
+ expect(funnel.stages[0].rules).toHaveLength(0);
194
+ });
195
+ });
196
+
197
+ describe('Run Status Validation', () => {
198
+ it('validates completed run', () => {
199
+ const run = createFunnelRun({ status: 'completed' });
200
+
201
+ expect(run.status).toBe('completed');
202
+ expect(run.completed_at).toBeDefined();
203
+ });
204
+
205
+ it('validates running state', () => {
206
+ const run = createFunnelRun({ status: 'running', completed_at: undefined });
207
+
208
+ expect(run.status).toBe('running');
209
+ expect(run.completed_at).toBeUndefined();
210
+ });
211
+
212
+ it('validates failed run', () => {
213
+ const run = createFunnelRun({ status: 'failed', error: 'Database error' });
214
+
215
+ expect(run.status).toBe('failed');
216
+ expect(run.error).toBe('Database error');
217
+ });
218
+ });
219
+
220
+ describe('Edge Cases', () => {
221
+ it('handles very long funnel names', () => {
222
+ const longName = 'This is a very long funnel name that should still be valid';
223
+ const funnel = createFunnel({ name: longName });
224
+
225
+ expect(funnel.name).toBe(longName);
226
+ expect(funnel.name.length).toBeGreaterThan(50);
227
+ });
228
+
229
+ it('handles undefined description', () => {
230
+ const funnel = createFunnel({ description: undefined });
231
+
232
+ expect(funnel.description).toBeUndefined();
233
+ });
234
+
235
+ it('handles large numbers in stats', () => {
236
+ const run = createFunnelRun({
237
+ total_input: 10000000,
238
+ total_matched: 500000,
239
+ total_excluded: 9500000,
240
+ });
241
+
242
+ expect(run.total_input).toBe(10000000);
243
+ expect(run.total_matched + run.total_excluded).toBe(run.total_input);
244
+ });
245
+
246
+ it('validates stats add up correctly', () => {
247
+ const run = createFunnelRun();
248
+
249
+ expect(run.total_matched + run.total_excluded).toBe(run.total_input);
250
+ });
251
+ });
252
+ });
253
+
254
+ // TODO: Add React component tests when React is installed
255
+ // Import these and uncomment when React dependencies are added:
256
+ // import { render, screen, fireEvent } from '@testing-library/react';
257
+ // import { FunnelCard } from './FunnelCard';
@@ -0,0 +1,336 @@
1
+ /**
2
+ * FunnelCard Component Tests
3
+ *
4
+ * Test Coverage:
5
+ * - Rendering with different funnel states (active, draft, paused, archived)
6
+ * - Rendering with different stage counts (0, 1, 5, 10+)
7
+ * - Rendering with/without latest run data
8
+ * - Callback invocations (onViewFlow, onEdit)
9
+ * - Accessibility attributes
10
+ * - Edge cases (empty description, no stages, failed run)
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { render, screen, fireEvent } from '@testing-library/react';
15
+ import { FunnelCard } from './FunnelCard';
16
+ import { Funnel, FunnelRun, FunnelStatus } from '../../types';
17
+
18
+ // Test data factories
19
+ function createFunnel(overrides?: Partial<Funnel>): Funnel {
20
+ return {
21
+ id: 'funnel-1',
22
+ name: 'Test Funnel',
23
+ description: 'Test funnel description',
24
+ status: 'active',
25
+ input_type: 'any',
26
+ stages: [
27
+ {
28
+ id: 'stage-1',
29
+ order: 0,
30
+ name: 'Stage 1',
31
+ description: 'First stage',
32
+ filter_logic: 'AND',
33
+ rules: [
34
+ { field_path: 'name', operator: 'eq', value: 'test' }
35
+ ],
36
+ match_action: 'continue',
37
+ no_match_action: 'exclude',
38
+ },
39
+ {
40
+ id: 'stage-2',
41
+ order: 1,
42
+ name: 'Stage 2',
43
+ description: 'Second stage',
44
+ filter_logic: 'OR',
45
+ rules: [
46
+ { field_path: 'value', operator: 'gt', value: 100 },
47
+ { field_path: 'type', operator: 'in', value: ['A', 'B'] },
48
+ { field_path: 'active', operator: 'is_true', value: null },
49
+ ],
50
+ match_action: 'output',
51
+ no_match_action: 'exclude',
52
+ },
53
+ ],
54
+ created_at: '2024-01-01T00:00:00Z',
55
+ updated_at: '2024-01-01T00:00:00Z',
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ function createFunnelRun(overrides?: Partial<FunnelRun>): FunnelRun {
61
+ return {
62
+ id: 'run-1',
63
+ funnel_id: 'funnel-1',
64
+ status: 'completed',
65
+ trigger_type: 'manual',
66
+ started_at: '2024-01-01T00:00:00Z',
67
+ completed_at: '2024-01-01T00:01:00Z',
68
+ duration_ms: 60000,
69
+ total_input: 83061,
70
+ total_matched: 235,
71
+ total_excluded: 82826,
72
+ total_tagged: 235,
73
+ stage_stats: {},
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ describe('FunnelCard', () => {
79
+ describe('Basic Rendering', () => {
80
+ it('renders funnel name and description', () => {
81
+ const funnel = createFunnel();
82
+ render(<FunnelCard funnel={funnel} />);
83
+
84
+ expect(screen.getByText('Test Funnel')).toBeInTheDocument();
85
+ expect(screen.getByText('Test funnel description')).toBeInTheDocument();
86
+ });
87
+
88
+ it('renders without description', () => {
89
+ const funnel = createFunnel({ description: undefined });
90
+ render(<FunnelCard funnel={funnel} />);
91
+
92
+ expect(screen.getByText('Test Funnel')).toBeInTheDocument();
93
+ expect(screen.queryByText('Test funnel description')).not.toBeInTheDocument();
94
+ });
95
+
96
+ it('renders all stages', () => {
97
+ const funnel = createFunnel();
98
+ render(<FunnelCard funnel={funnel} />);
99
+
100
+ expect(screen.getByText('Stage 1')).toBeInTheDocument();
101
+ expect(screen.getByText('Stage 2')).toBeInTheDocument();
102
+ expect(screen.getByText('1 rule')).toBeInTheDocument();
103
+ expect(screen.getByText('3 rules')).toBeInTheDocument();
104
+ });
105
+
106
+ it('renders empty state for funnel with no stages', () => {
107
+ const funnel = createFunnel({ stages: [] });
108
+ render(<FunnelCard funnel={funnel} />);
109
+
110
+ expect(screen.getByText('No stages defined')).toBeInTheDocument();
111
+ });
112
+ });
113
+
114
+ describe('Status Badge', () => {
115
+ const statuses: FunnelStatus[] = ['active', 'draft', 'paused', 'archived'];
116
+
117
+ it.each(statuses)('renders %s status badge', (status) => {
118
+ const funnel = createFunnel({ status });
119
+ render(<FunnelCard funnel={funnel} />);
120
+
121
+ expect(screen.getByText(status.toUpperCase())).toBeInTheDocument();
122
+ });
123
+ });
124
+
125
+ describe('Latest Run Data', () => {
126
+ it('renders stats when run is completed', () => {
127
+ const funnel = createFunnel();
128
+ const latestRun = createFunnelRun();
129
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
130
+
131
+ expect(screen.getByText('83,061')).toBeInTheDocument(); // INPUT
132
+ expect(screen.getByText('235')).toBeInTheDocument(); // MATCHED
133
+ expect(screen.getByText('82,826')).toBeInTheDocument(); // EXCLUDED
134
+ expect(screen.getByText('235 matched')).toBeInTheDocument(); // Match bar label
135
+ });
136
+
137
+ it('renders "No runs yet" when no run data', () => {
138
+ const funnel = createFunnel();
139
+ render(<FunnelCard funnel={funnel} />);
140
+
141
+ expect(screen.getByText('No runs yet')).toBeInTheDocument();
142
+ expect(screen.queryByText('INPUT')).not.toBeInTheDocument();
143
+ });
144
+
145
+ it('renders "Running..." for in-progress run', () => {
146
+ const funnel = createFunnel();
147
+ const latestRun = createFunnelRun({ status: 'running' });
148
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
149
+
150
+ expect(screen.getByText('Running...')).toBeInTheDocument();
151
+ });
152
+
153
+ it('renders "Last run failed" for failed run', () => {
154
+ const funnel = createFunnel();
155
+ const latestRun = createFunnelRun({ status: 'failed' });
156
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
157
+
158
+ expect(screen.getByText('Last run failed')).toBeInTheDocument();
159
+ });
160
+ });
161
+
162
+ describe('Stage Counts', () => {
163
+ it('renders funnel with 1 stage', () => {
164
+ const funnel = createFunnel({
165
+ stages: [
166
+ {
167
+ id: 'stage-1',
168
+ order: 0,
169
+ name: 'Only Stage',
170
+ filter_logic: 'AND',
171
+ rules: [{ field_path: 'x', operator: 'eq', value: 1 }],
172
+ match_action: 'output',
173
+ no_match_action: 'exclude',
174
+ },
175
+ ],
176
+ });
177
+ render(<FunnelCard funnel={funnel} />);
178
+
179
+ expect(screen.getByText('Only Stage')).toBeInTheDocument();
180
+ expect(screen.getByText('1 rule')).toBeInTheDocument();
181
+ });
182
+
183
+ it('renders funnel with 5 stages', () => {
184
+ const stages = Array.from({ length: 5 }, (_, i) => ({
185
+ id: `stage-${i}`,
186
+ order: i,
187
+ name: `Stage ${i + 1}`,
188
+ filter_logic: 'AND' as const,
189
+ rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
190
+ match_action: 'continue' as const,
191
+ no_match_action: 'exclude' as const,
192
+ }));
193
+ const funnel = createFunnel({ stages });
194
+ render(<FunnelCard funnel={funnel} />);
195
+
196
+ stages.forEach((stage) => {
197
+ expect(screen.getByText(stage.name)).toBeInTheDocument();
198
+ });
199
+ });
200
+
201
+ it('renders funnel with 10+ stages', () => {
202
+ const stages = Array.from({ length: 15 }, (_, i) => ({
203
+ id: `stage-${i}`,
204
+ order: i,
205
+ name: `Stage ${i + 1}`,
206
+ filter_logic: 'AND' as const,
207
+ rules: [{ field_path: 'x', operator: 'eq' as const, value: i }],
208
+ match_action: 'continue' as const,
209
+ no_match_action: 'exclude' as const,
210
+ }));
211
+ const funnel = createFunnel({ stages });
212
+ render(<FunnelCard funnel={funnel} />);
213
+
214
+ // Check first and last stage
215
+ expect(screen.getByText('Stage 1')).toBeInTheDocument();
216
+ expect(screen.getByText('Stage 15')).toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ describe('Callbacks', () => {
221
+ it('calls onViewFlow when button clicked', () => {
222
+ const onViewFlow = vi.fn();
223
+ const funnel = createFunnel();
224
+ render(<FunnelCard funnel={funnel} onViewFlow={onViewFlow} />);
225
+
226
+ const button = screen.getByRole('button', { name: /view flow/i });
227
+ fireEvent.click(button);
228
+
229
+ expect(onViewFlow).toHaveBeenCalledTimes(1);
230
+ expect(onViewFlow).toHaveBeenCalledWith(funnel);
231
+ });
232
+
233
+ it('does not error when onViewFlow is undefined', () => {
234
+ const funnel = createFunnel();
235
+ render(<FunnelCard funnel={funnel} />);
236
+
237
+ const button = screen.getByRole('button', { name: /view flow/i });
238
+ expect(() => fireEvent.click(button)).not.toThrow();
239
+ });
240
+ });
241
+
242
+ describe('Accessibility', () => {
243
+ it('has proper ARIA labels', () => {
244
+ const funnel = createFunnel();
245
+ const latestRun = createFunnelRun();
246
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
247
+
248
+ expect(screen.getByRole('article')).toHaveAttribute(
249
+ 'aria-label',
250
+ 'Funnel: Test Funnel'
251
+ );
252
+
253
+ expect(screen.getByLabelText(/funnel stages/i)).toBeInTheDocument();
254
+ expect(screen.getByLabelText(/match results/i)).toBeInTheDocument();
255
+ expect(screen.getByLabelText(/funnel statistics/i)).toBeInTheDocument();
256
+
257
+ const button = screen.getByRole('button');
258
+ expect(button).toHaveAttribute(
259
+ 'aria-label',
260
+ 'View flow details for Test Funnel'
261
+ );
262
+ });
263
+
264
+ it('has proper progressbar role for match bar', () => {
265
+ const funnel = createFunnel();
266
+ const latestRun = createFunnelRun();
267
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
268
+
269
+ const progressBar = screen.getByRole('progressbar');
270
+ expect(progressBar).toHaveAttribute('aria-valuemin', '0');
271
+ expect(progressBar).toHaveAttribute('aria-valuemax', '100');
272
+ expect(progressBar).toHaveAttribute('aria-label', '235 of 83061 matched');
273
+ });
274
+ });
275
+
276
+ describe('Custom className', () => {
277
+ it('applies custom className', () => {
278
+ const funnel = createFunnel();
279
+ const { container } = render(
280
+ <FunnelCard funnel={funnel} className="custom-class" />
281
+ );
282
+
283
+ const article = container.querySelector('article');
284
+ expect(article).toHaveClass('custom-class');
285
+ });
286
+ });
287
+
288
+ describe('Edge Cases', () => {
289
+ it('handles zero matched entities', () => {
290
+ const funnel = createFunnel();
291
+ const latestRun = createFunnelRun({
292
+ total_matched: 0,
293
+ total_excluded: 100,
294
+ });
295
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
296
+
297
+ expect(screen.getByText('0 matched')).toBeInTheDocument();
298
+ expect(screen.getByText('100')).toBeInTheDocument(); // EXCLUDED
299
+ });
300
+
301
+ it('handles zero input entities', () => {
302
+ const funnel = createFunnel();
303
+ const latestRun = createFunnelRun({
304
+ total_input: 0,
305
+ total_matched: 0,
306
+ total_excluded: 0,
307
+ });
308
+ render(<FunnelCard funnel={funnel} latestRun={latestRun} />);
309
+
310
+ expect(screen.getByText('0 matched')).toBeInTheDocument();
311
+ expect(screen.getAllByText('0')).toHaveLength(3); // INPUT, MATCHED, EXCLUDED
312
+ });
313
+
314
+ it('handles long funnel names', () => {
315
+ const funnel = createFunnel({
316
+ name: 'This is a very long funnel name that should still render correctly without breaking the layout',
317
+ });
318
+ render(<FunnelCard funnel={funnel} />);
319
+
320
+ expect(
321
+ screen.getByText(
322
+ 'This is a very long funnel name that should still render correctly without breaking the layout'
323
+ )
324
+ ).toBeInTheDocument();
325
+ });
326
+
327
+ it('handles long descriptions', () => {
328
+ const funnel = createFunnel({
329
+ description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
330
+ });
331
+ render(<FunnelCard funnel={funnel} />);
332
+
333
+ expect(screen.getByText(/Lorem ipsum dolor/)).toBeInTheDocument();
334
+ });
335
+ });
336
+ });