@startsimpli/funnels 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -31
- package/src/api/README.md +507 -0
- package/src/api/adapter.ts +106 -0
- package/src/api/client.test.ts +640 -0
- package/src/api/client.ts +385 -0
- package/src/api/default-adapter.ts +243 -0
- package/src/api/index.ts +24 -0
- package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
- package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
- package/src/components/FilterRuleEditor/README.md +291 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
- package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
- package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
- package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
- package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
- package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
- package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
- package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
- package/src/components/FilterRuleEditor/constants.ts +64 -0
- package/src/components/FilterRuleEditor/index.ts +14 -0
- package/src/components/FunnelCard/DESIGN.md +447 -0
- package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
- package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
- package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
- package/src/components/FunnelCard/FunnelCard.tsx +204 -0
- package/src/components/FunnelCard/FunnelStats.tsx +68 -0
- package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
- package/src/components/FunnelCard/INSTALLATION.md +304 -0
- package/src/components/FunnelCard/MatchBar.tsx +49 -0
- package/src/components/FunnelCard/README.md +294 -0
- package/src/components/FunnelCard/StageIndicator.tsx +62 -0
- package/src/components/FunnelCard/StatusBadge.tsx +52 -0
- package/src/components/FunnelCard/index.ts +14 -0
- package/src/components/FunnelPreview/EntityCard.tsx +72 -0
- package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
- package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
- package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
- package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
- package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
- package/src/components/FunnelPreview/README.md +337 -0
- package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
- package/src/components/FunnelPreview/example.tsx +286 -0
- package/src/components/FunnelPreview/index.ts +14 -0
- package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
- package/src/components/FunnelRunHistory/README.md +325 -0
- package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
- package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
- package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
- package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
- package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
- package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
- package/src/components/FunnelRunHistory/index.ts +51 -0
- package/src/components/FunnelRunHistory/types.ts +40 -0
- package/src/components/FunnelRunHistory/utils.test.ts +126 -0
- package/src/components/FunnelRunHistory/utils.ts +100 -0
- package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
- package/src/components/FunnelStageBuilder/README.md +341 -0
- package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
- package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
- package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
- package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
- package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
- package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
- package/src/components/FunnelStageBuilder/index.ts +21 -0
- package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
- package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
- package/src/components/FunnelVisualFlow/README.md +323 -0
- package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
- package/src/components/FunnelVisualFlow/example.tsx +227 -0
- package/src/components/FunnelVisualFlow/index.ts +10 -0
- package/src/components/index.ts +102 -0
- package/src/core/README.md +307 -0
- package/src/core/engine.test.ts +1087 -0
- package/src/core/engine.ts +329 -0
- package/src/core/evaluator.example.ts +353 -0
- package/src/core/evaluator.test.ts +639 -0
- package/src/core/evaluator.ts +261 -0
- package/src/core/field-resolver.example.ts +175 -0
- package/src/core/field-resolver.test.ts +541 -0
- package/src/core/field-resolver.ts +247 -0
- package/src/core/index.ts +34 -0
- package/src/core/operators.test.ts +539 -0
- package/src/core/operators.ts +241 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useDebouncedValue.ts +28 -0
- package/src/index.ts +155 -0
- package/src/store/README.md +342 -0
- package/src/store/create-funnel-store.test.ts +686 -0
- package/src/store/create-funnel-store.ts +538 -0
- package/src/store/index.ts +9 -0
- package/src/store/types.ts +294 -0
- package/src/stories/CrossDomain.stories.tsx +149 -0
- package/src/stories/Welcome.stories.tsx +81 -0
- package/src/stories/demo-data/index.ts +3 -0
- package/src/stories/demo-data/investors.ts +216 -0
- package/src/stories/demo-data/leads.ts +223 -0
- package/src/stories/demo-data/recipes.ts +217 -0
- package/src/test/setup.ts +5 -0
- package/src/types/index.ts +843 -0
- package/dist/client-3ESO2NHy.d.ts +0 -310
- package/dist/client-CZu03ACp.d.cts +0 -310
- package/dist/components/index.cjs +0 -3241
- package/dist/components/index.cjs.map +0 -1
- package/dist/components/index.css.map +0 -1
- package/dist/components/index.d.cts +0 -726
- package/dist/components/index.d.ts +0 -726
- package/dist/components/index.js +0 -3194
- package/dist/components/index.js.map +0 -1
- package/dist/core/index.cjs +0 -500
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -359
- package/dist/core/index.d.ts +0 -359
- package/dist/core/index.js +0 -486
- package/dist/core/index.js.map +0 -1
- package/dist/hooks/index.cjs +0 -20
- package/dist/hooks/index.cjs.map +0 -1
- package/dist/hooks/index.d.cts +0 -11
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.js +0 -18
- package/dist/hooks/index.js.map +0 -1
- package/dist/index-BGDEXbuz.d.cts +0 -434
- package/dist/index-BGDEXbuz.d.ts +0 -434
- package/dist/index.cjs +0 -4499
- package/dist/index.cjs.map +0 -1
- package/dist/index.css +0 -198
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -99
- package/dist/index.d.ts +0 -99
- package/dist/index.js +0 -4421
- package/dist/index.js.map +0 -1
- package/dist/store/index.cjs +0 -389
- package/dist/store/index.cjs.map +0 -1
- package/dist/store/index.d.cts +0 -225
- package/dist/store/index.d.ts +0 -225
- package/dist/store/index.js +0 -386
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/funnels - Field Resolver Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
resolveField,
|
|
8
|
+
setField,
|
|
9
|
+
hasField,
|
|
10
|
+
getFields,
|
|
11
|
+
} from './field-resolver';
|
|
12
|
+
|
|
13
|
+
describe('resolveField', () => {
|
|
14
|
+
describe('simple paths', () => {
|
|
15
|
+
it('resolves top-level string field', () => {
|
|
16
|
+
const entity = { name: 'John Doe' };
|
|
17
|
+
expect(resolveField(entity, 'name')).toBe('John Doe');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('resolves top-level number field', () => {
|
|
21
|
+
const entity = { age: 30, score: 85.5 };
|
|
22
|
+
expect(resolveField(entity, 'age')).toBe(30);
|
|
23
|
+
expect(resolveField(entity, 'score')).toBe(85.5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('resolves top-level boolean field', () => {
|
|
27
|
+
const entity = { active: true, verified: false };
|
|
28
|
+
expect(resolveField(entity, 'active')).toBe(true);
|
|
29
|
+
expect(resolveField(entity, 'verified')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves top-level date field', () => {
|
|
33
|
+
const date = new Date('2024-01-01');
|
|
34
|
+
const entity = { created_at: date };
|
|
35
|
+
expect(resolveField(entity, 'created_at')).toBe(date);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolves top-level null field', () => {
|
|
39
|
+
const entity = { value: null };
|
|
40
|
+
expect(resolveField(entity, 'value')).toBe(null);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('nested paths', () => {
|
|
45
|
+
it('resolves nested object fields', () => {
|
|
46
|
+
const entity = {
|
|
47
|
+
firm: {
|
|
48
|
+
name: 'Sequoia Capital',
|
|
49
|
+
stage: 'Series A',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
expect(resolveField(entity, 'firm.name')).toBe('Sequoia Capital');
|
|
53
|
+
expect(resolveField(entity, 'firm.stage')).toBe('Series A');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves deeply nested paths', () => {
|
|
57
|
+
const entity = {
|
|
58
|
+
company: {
|
|
59
|
+
address: {
|
|
60
|
+
city: {
|
|
61
|
+
name: 'San Francisco',
|
|
62
|
+
zipCode: '94102',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
expect(resolveField(entity, 'company.address.city.name')).toBe('San Francisco');
|
|
68
|
+
expect(resolveField(entity, 'company.address.city.zipCode')).toBe('94102');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves nested number fields preserving type', () => {
|
|
72
|
+
const entity = {
|
|
73
|
+
metrics: {
|
|
74
|
+
revenue: 1000000,
|
|
75
|
+
growth_rate: 0.25,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
expect(resolveField(entity, 'metrics.revenue')).toBe(1000000);
|
|
79
|
+
expect(resolveField(entity, 'metrics.growth_rate')).toBe(0.25);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('array access', () => {
|
|
84
|
+
it('resolves array elements by index', () => {
|
|
85
|
+
const entity = {
|
|
86
|
+
tags: ['active', 'qualified', 'enterprise'],
|
|
87
|
+
};
|
|
88
|
+
expect(resolveField(entity, 'tags[0]')).toBe('active');
|
|
89
|
+
expect(resolveField(entity, 'tags[1]')).toBe('qualified');
|
|
90
|
+
expect(resolveField(entity, 'tags[2]')).toBe('enterprise');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('resolves nested array objects', () => {
|
|
94
|
+
const entity = {
|
|
95
|
+
investors: [
|
|
96
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
97
|
+
{ name: 'Bob', email: 'bob@example.com' },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
expect(resolveField(entity, 'investors[0].name')).toBe('Alice');
|
|
101
|
+
expect(resolveField(entity, 'investors[0].email')).toBe('alice@example.com');
|
|
102
|
+
expect(resolveField(entity, 'investors[1].name')).toBe('Bob');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('resolves deeply nested array paths', () => {
|
|
106
|
+
const entity = {
|
|
107
|
+
data: {
|
|
108
|
+
items: [
|
|
109
|
+
{ values: [10, 20, 30] },
|
|
110
|
+
{ values: [40, 50, 60] },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
expect(resolveField(entity, 'data.items[0].values[1]')).toBe(20);
|
|
115
|
+
expect(resolveField(entity, 'data.items[1].values[2]')).toBe(60);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns undefined for out-of-bounds array index', () => {
|
|
119
|
+
const entity = { tags: ['a', 'b'] };
|
|
120
|
+
expect(resolveField(entity, 'tags[5]')).toBe(undefined);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('missing paths', () => {
|
|
125
|
+
it('returns undefined for missing top-level field', () => {
|
|
126
|
+
const entity = { name: 'John' };
|
|
127
|
+
expect(resolveField(entity, 'doesNotExist')).toBe(undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns undefined for missing nested field', () => {
|
|
131
|
+
const entity = { firm: { name: 'Acme' } };
|
|
132
|
+
expect(resolveField(entity, 'firm.missing')).toBe(undefined);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns undefined for deeply missing field', () => {
|
|
136
|
+
const entity = { a: { b: { c: 'value' } } };
|
|
137
|
+
expect(resolveField(entity, 'a.b.c.d.e.f')).toBe(undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns undefined when intermediate path is null', () => {
|
|
141
|
+
const entity = { firm: null };
|
|
142
|
+
expect(resolveField(entity, 'firm.stage')).toBe(undefined);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns undefined when intermediate path is undefined', () => {
|
|
146
|
+
const entity = { firm: undefined };
|
|
147
|
+
expect(resolveField(entity, 'firm.stage')).toBe(undefined);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('null safety', () => {
|
|
152
|
+
it('returns undefined for null entity', () => {
|
|
153
|
+
expect(resolveField(null, 'name')).toBe(undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns undefined for undefined entity', () => {
|
|
157
|
+
expect(resolveField(undefined, 'name')).toBe(undefined);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns undefined for empty path', () => {
|
|
161
|
+
const entity = { name: 'John' };
|
|
162
|
+
expect(resolveField(entity, '')).toBe(undefined);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('returns undefined for whitespace-only path', () => {
|
|
166
|
+
const entity = { name: 'John' };
|
|
167
|
+
expect(resolveField(entity, ' ')).toBe(undefined);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('type preservation', () => {
|
|
172
|
+
it('preserves number types', () => {
|
|
173
|
+
const entity = { count: 42, pi: 3.14159 };
|
|
174
|
+
expect(resolveField(entity, 'count')).toBe(42);
|
|
175
|
+
expect(typeof resolveField(entity, 'count')).toBe('number');
|
|
176
|
+
expect(resolveField(entity, 'pi')).toBe(3.14159);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('preserves boolean types', () => {
|
|
180
|
+
const entity = { isActive: true, isVerified: false };
|
|
181
|
+
expect(resolveField(entity, 'isActive')).toBe(true);
|
|
182
|
+
expect(typeof resolveField(entity, 'isActive')).toBe('boolean');
|
|
183
|
+
expect(resolveField(entity, 'isVerified')).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('preserves date objects', () => {
|
|
187
|
+
const date = new Date('2024-01-01');
|
|
188
|
+
const entity = { created: date };
|
|
189
|
+
const result = resolveField(entity, 'created');
|
|
190
|
+
expect(result).toBe(date);
|
|
191
|
+
expect(result instanceof Date).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('preserves arrays', () => {
|
|
195
|
+
const entity = { tags: ['a', 'b', 'c'] };
|
|
196
|
+
const result = resolveField(entity, 'tags');
|
|
197
|
+
expect(Array.isArray(result)).toBe(true);
|
|
198
|
+
expect(result).toEqual(['a', 'b', 'c']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('preserves objects', () => {
|
|
202
|
+
const obj = { stage: 'Seed', aum: 100000 };
|
|
203
|
+
const entity = { firm: obj };
|
|
204
|
+
const result = resolveField(entity, 'firm');
|
|
205
|
+
expect(typeof result).toBe('object');
|
|
206
|
+
expect(result).toEqual(obj);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('complex real-world examples', () => {
|
|
211
|
+
it('resolves investor entity fields', () => {
|
|
212
|
+
const investor = {
|
|
213
|
+
id: '123',
|
|
214
|
+
name: 'John Doe',
|
|
215
|
+
email: 'john@example.com',
|
|
216
|
+
firm: {
|
|
217
|
+
id: 'firm-456',
|
|
218
|
+
name: 'Sequoia Capital',
|
|
219
|
+
stage: 'Series A',
|
|
220
|
+
aum_usd: 100000000,
|
|
221
|
+
},
|
|
222
|
+
tags: ['active', 'qualified', 'enterprise'],
|
|
223
|
+
profile: {
|
|
224
|
+
linkedin_url: 'https://linkedin.com/in/johndoe',
|
|
225
|
+
bio: 'Venture capitalist',
|
|
226
|
+
},
|
|
227
|
+
metrics: {
|
|
228
|
+
response_rate: 0.85,
|
|
229
|
+
deal_count: 12,
|
|
230
|
+
},
|
|
231
|
+
created_at: new Date('2024-01-01'),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
expect(resolveField(investor, 'name')).toBe('John Doe');
|
|
235
|
+
expect(resolveField(investor, 'firm.name')).toBe('Sequoia Capital');
|
|
236
|
+
expect(resolveField(investor, 'firm.aum_usd')).toBe(100000000);
|
|
237
|
+
expect(resolveField(investor, 'tags[0]')).toBe('active');
|
|
238
|
+
expect(resolveField(investor, 'profile.linkedin_url')).toBe('https://linkedin.com/in/johndoe');
|
|
239
|
+
expect(resolveField(investor, 'metrics.response_rate')).toBe(0.85);
|
|
240
|
+
expect(resolveField(investor, 'created_at')).toBeInstanceOf(Date);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('resolves recipe entity fields', () => {
|
|
244
|
+
const recipe = {
|
|
245
|
+
id: 'recipe-789',
|
|
246
|
+
title: 'Chocolate Chip Cookies',
|
|
247
|
+
cuisine: 'American',
|
|
248
|
+
ingredients: [
|
|
249
|
+
{ name: 'flour', amount: '2 cups' },
|
|
250
|
+
{ name: 'sugar', amount: '1 cup' },
|
|
251
|
+
{ name: 'chocolate chips', amount: '2 cups' },
|
|
252
|
+
],
|
|
253
|
+
steps: [
|
|
254
|
+
{ order: 1, instruction: 'Mix dry ingredients' },
|
|
255
|
+
{ order: 2, instruction: 'Add wet ingredients' },
|
|
256
|
+
],
|
|
257
|
+
metadata: {
|
|
258
|
+
prep_time_minutes: 15,
|
|
259
|
+
cook_time_minutes: 12,
|
|
260
|
+
servings: 24,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
expect(resolveField(recipe, 'title')).toBe('Chocolate Chip Cookies');
|
|
265
|
+
expect(resolveField(recipe, 'ingredients[0].name')).toBe('flour');
|
|
266
|
+
expect(resolveField(recipe, 'ingredients[2].amount')).toBe('2 cups');
|
|
267
|
+
expect(resolveField(recipe, 'steps[1].instruction')).toBe('Add wet ingredients');
|
|
268
|
+
expect(resolveField(recipe, 'metadata.prep_time_minutes')).toBe(15);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('setField', () => {
|
|
274
|
+
describe('simple paths', () => {
|
|
275
|
+
it('sets top-level field', () => {
|
|
276
|
+
const entity: any = { name: 'John' };
|
|
277
|
+
setField(entity, 'email', 'john@example.com');
|
|
278
|
+
expect(entity.email).toBe('john@example.com');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('updates existing field', () => {
|
|
282
|
+
const entity: any = { name: 'John' };
|
|
283
|
+
setField(entity, 'name', 'Jane');
|
|
284
|
+
expect(entity.name).toBe('Jane');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('nested paths', () => {
|
|
289
|
+
it('creates nested object if missing', () => {
|
|
290
|
+
const entity: any = { name: 'John' };
|
|
291
|
+
setField(entity, 'firm.stage', 'Series A');
|
|
292
|
+
expect(entity.firm).toBeDefined();
|
|
293
|
+
expect(entity.firm.stage).toBe('Series A');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('creates deeply nested path', () => {
|
|
297
|
+
const entity: any = {};
|
|
298
|
+
setField(entity, 'company.address.city.name', 'San Francisco');
|
|
299
|
+
expect(entity.company.address.city.name).toBe('San Francisco');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('updates nested field without affecting siblings', () => {
|
|
303
|
+
const entity: any = {
|
|
304
|
+
firm: { name: 'Acme', stage: 'Seed' },
|
|
305
|
+
};
|
|
306
|
+
setField(entity, 'firm.stage', 'Series A');
|
|
307
|
+
expect(entity.firm.name).toBe('Acme');
|
|
308
|
+
expect(entity.firm.stage).toBe('Series A');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('array paths', () => {
|
|
313
|
+
it('creates array and sets element', () => {
|
|
314
|
+
const entity: any = {};
|
|
315
|
+
setField(entity, 'tags[0]', 'active');
|
|
316
|
+
expect(Array.isArray(entity.tags)).toBe(true);
|
|
317
|
+
expect(entity.tags[0]).toBe('active');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('sets array element in existing array', () => {
|
|
321
|
+
const entity: any = { tags: ['a', 'b', 'c'] };
|
|
322
|
+
setField(entity, 'tags[1]', 'updated');
|
|
323
|
+
expect(entity.tags).toEqual(['a', 'updated', 'c']);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('sets nested array object field', () => {
|
|
327
|
+
const entity: any = {
|
|
328
|
+
investors: [{ name: 'Alice' }, { name: 'Bob' }],
|
|
329
|
+
};
|
|
330
|
+
setField(entity, 'investors[0].email', 'alice@example.com');
|
|
331
|
+
expect(entity.investors[0].email).toBe('alice@example.com');
|
|
332
|
+
expect(entity.investors[1].email).toBe(undefined);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('error handling', () => {
|
|
337
|
+
it('throws on null entity', () => {
|
|
338
|
+
expect(() => setField(null, 'name', 'value')).toThrow(
|
|
339
|
+
'Cannot set field on null or undefined entity'
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('throws on undefined entity', () => {
|
|
344
|
+
expect(() => setField(undefined, 'name', 'value')).toThrow(
|
|
345
|
+
'Cannot set field on null or undefined entity'
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('throws on empty path', () => {
|
|
350
|
+
const entity = {};
|
|
351
|
+
expect(() => setField(entity, '', 'value')).toThrow(
|
|
352
|
+
'Field path cannot be empty'
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('throws on whitespace-only path', () => {
|
|
357
|
+
const entity = {};
|
|
358
|
+
expect(() => setField(entity, ' ', 'value')).toThrow(
|
|
359
|
+
'Field path cannot be empty'
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('type preservation', () => {
|
|
365
|
+
it('sets number values', () => {
|
|
366
|
+
const entity: any = {};
|
|
367
|
+
setField(entity, 'count', 42);
|
|
368
|
+
setField(entity, 'rate', 0.85);
|
|
369
|
+
expect(entity.count).toBe(42);
|
|
370
|
+
expect(entity.rate).toBe(0.85);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('sets boolean values', () => {
|
|
374
|
+
const entity: any = {};
|
|
375
|
+
setField(entity, 'active', true);
|
|
376
|
+
setField(entity, 'verified', false);
|
|
377
|
+
expect(entity.active).toBe(true);
|
|
378
|
+
expect(entity.verified).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('sets date values', () => {
|
|
382
|
+
const entity: any = {};
|
|
383
|
+
const date = new Date('2024-01-01');
|
|
384
|
+
setField(entity, 'created', date);
|
|
385
|
+
expect(entity.created).toBe(date);
|
|
386
|
+
expect(entity.created instanceof Date).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('tag assignment use case', () => {
|
|
391
|
+
it('adds tags to entity', () => {
|
|
392
|
+
const entity: any = { name: 'John' };
|
|
393
|
+
setField(entity, 'tags', ['qualified', 'enterprise']);
|
|
394
|
+
expect(entity.tags).toEqual(['qualified', 'enterprise']);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('updates specific tag in array', () => {
|
|
398
|
+
const entity: any = { tags: ['a', 'b', 'c'] };
|
|
399
|
+
setField(entity, 'tags[1]', 'updated');
|
|
400
|
+
expect(entity.tags).toEqual(['a', 'updated', 'c']);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('hasField', () => {
|
|
406
|
+
it('returns true for existing field', () => {
|
|
407
|
+
const entity = { name: 'John' };
|
|
408
|
+
expect(hasField(entity, 'name')).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('returns true for nested field', () => {
|
|
412
|
+
const entity = { firm: { stage: 'Seed' } };
|
|
413
|
+
expect(hasField(entity, 'firm.stage')).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('returns true for array element', () => {
|
|
417
|
+
const entity = { tags: ['a', 'b'] };
|
|
418
|
+
expect(hasField(entity, 'tags[0]')).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('returns false for missing field', () => {
|
|
422
|
+
const entity = { name: 'John' };
|
|
423
|
+
expect(hasField(entity, 'email')).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('returns false for missing nested field', () => {
|
|
427
|
+
const entity = { firm: { name: 'Acme' } };
|
|
428
|
+
expect(hasField(entity, 'firm.stage')).toBe(false);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('returns false for out-of-bounds array index', () => {
|
|
432
|
+
const entity = { tags: ['a', 'b'] };
|
|
433
|
+
expect(hasField(entity, 'tags[5]')).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('returns false for null field value', () => {
|
|
437
|
+
const entity = { value: null };
|
|
438
|
+
// Note: null is a defined value, not undefined
|
|
439
|
+
expect(hasField(entity, 'value')).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('returns false for undefined field value', () => {
|
|
443
|
+
const entity = { value: undefined };
|
|
444
|
+
expect(hasField(entity, 'value')).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('getFields', () => {
|
|
449
|
+
it('resolves multiple fields at once', () => {
|
|
450
|
+
const entity = {
|
|
451
|
+
name: 'John',
|
|
452
|
+
email: 'john@example.com',
|
|
453
|
+
firm: { stage: 'Seed' },
|
|
454
|
+
tags: ['active'],
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const result = getFields(entity, [
|
|
458
|
+
'name',
|
|
459
|
+
'email',
|
|
460
|
+
'firm.stage',
|
|
461
|
+
'tags[0]',
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
expect(result).toEqual({
|
|
465
|
+
name: 'John',
|
|
466
|
+
email: 'john@example.com',
|
|
467
|
+
'firm.stage': 'Seed',
|
|
468
|
+
'tags[0]': 'active',
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('includes undefined for missing fields', () => {
|
|
473
|
+
const entity = { name: 'John' };
|
|
474
|
+
const result = getFields(entity, ['name', 'email', 'firm.stage']);
|
|
475
|
+
|
|
476
|
+
expect(result).toEqual({
|
|
477
|
+
name: 'John',
|
|
478
|
+
email: undefined,
|
|
479
|
+
'firm.stage': undefined,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('handles empty field list', () => {
|
|
484
|
+
const entity = { name: 'John' };
|
|
485
|
+
const result = getFields(entity, []);
|
|
486
|
+
expect(result).toEqual({});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('preserves types for all fields', () => {
|
|
490
|
+
const entity = {
|
|
491
|
+
str: 'text',
|
|
492
|
+
num: 42,
|
|
493
|
+
bool: true,
|
|
494
|
+
date: new Date('2024-01-01'),
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const result = getFields(entity, ['str', 'num', 'bool', 'date']);
|
|
498
|
+
|
|
499
|
+
expect(result.str).toBe('text');
|
|
500
|
+
expect(result.num).toBe(42);
|
|
501
|
+
expect(result.bool).toBe(true);
|
|
502
|
+
expect(result.date).toBeInstanceOf(Date);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe('Prototype Pollution Prevention', () => {
|
|
507
|
+
it('resolveField blocks __proto__ segment', () => {
|
|
508
|
+
const entity = { name: 'John' };
|
|
509
|
+
const result = resolveField(entity, '__proto__.polluted');
|
|
510
|
+
expect(result).toBeUndefined();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('resolveField blocks constructor segment', () => {
|
|
514
|
+
const entity = { name: 'John' };
|
|
515
|
+
const result = resolveField(entity, 'constructor.name');
|
|
516
|
+
expect(result).toBeUndefined();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('resolveField blocks prototype segment', () => {
|
|
520
|
+
const entity = { name: 'John' };
|
|
521
|
+
const result = resolveField(entity, 'prototype.polluted');
|
|
522
|
+
expect(result).toBeUndefined();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('setField blocks __proto__ from polluting Object prototype', () => {
|
|
526
|
+
const entity: Record<string, any> = {};
|
|
527
|
+
// Attempting __proto__.polluted must not pollute Object.prototype
|
|
528
|
+
expect(() => setField(entity, '__proto__.polluted', 'evil')).toThrow();
|
|
529
|
+
expect((Object.prototype as any).polluted).toBeUndefined();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('setField blocks constructor from prototype pollution', () => {
|
|
533
|
+
const entity: Record<string, any> = {};
|
|
534
|
+
expect(() => setField(entity, 'constructor.prototype.polluted', 'evil')).toThrow();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('hasField returns false for __proto__ path', () => {
|
|
538
|
+
const entity = { name: 'John' };
|
|
539
|
+
expect(hasField(entity, '__proto__')).toBe(false);
|
|
540
|
+
});
|
|
541
|
+
});
|