Skip to content

Commit 966c7d1

Browse files
committed
add ContainmentPattern<T> type for type-safe condition options in DSL
1 parent 8863a35 commit 966c7d1

2 files changed

Lines changed: 365 additions & 6 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { Flow, type ContainmentPattern } from '../../src/index.js';
2+
import { describe, it, expectTypeOf } from 'vitest';
3+
4+
describe('ContainmentPattern<T> utility type', () => {
5+
describe('primitive types', () => {
6+
it('should allow exact value match for string', () => {
7+
type Pattern = ContainmentPattern<string>;
8+
expectTypeOf<Pattern>().toEqualTypeOf<string>();
9+
});
10+
11+
it('should allow exact value match for number', () => {
12+
type Pattern = ContainmentPattern<number>;
13+
expectTypeOf<Pattern>().toEqualTypeOf<number>();
14+
});
15+
16+
it('should allow exact value match for boolean', () => {
17+
type Pattern = ContainmentPattern<boolean>;
18+
expectTypeOf<Pattern>().toEqualTypeOf<boolean>();
19+
});
20+
21+
it('should allow exact value match for null', () => {
22+
type Pattern = ContainmentPattern<null>;
23+
expectTypeOf<Pattern>().toEqualTypeOf<null>();
24+
});
25+
});
26+
27+
describe('object types', () => {
28+
it('should make all keys optional for simple objects', () => {
29+
type Input = { name: string; age: number };
30+
type Pattern = ContainmentPattern<Input>;
31+
32+
// All keys should be optional
33+
expectTypeOf<Pattern>().toEqualTypeOf<{ name?: string; age?: number }>();
34+
});
35+
36+
it('should allow empty object pattern (always matches)', () => {
37+
type Input = { name: string; age: number };
38+
type Pattern = ContainmentPattern<Input>;
39+
40+
// Empty object should be assignable to pattern
41+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
42+
expectTypeOf<{}>().toMatchTypeOf<Pattern>();
43+
});
44+
45+
it('should handle nested objects recursively', () => {
46+
type Input = { user: { name: string; role: string } };
47+
type Pattern = ContainmentPattern<Input>;
48+
49+
// Nested object should have optional keys
50+
expectTypeOf<Pattern>().toEqualTypeOf<{
51+
user?: { name?: string; role?: string };
52+
}>();
53+
});
54+
55+
it('should allow partial patterns for nested objects', () => {
56+
type Input = { user: { name: string; role: string; age: number } };
57+
type Pattern = ContainmentPattern<Input>;
58+
59+
// Should be able to specify only some nested keys
60+
const validPattern: Pattern = { user: { role: 'admin' } };
61+
expectTypeOf(validPattern).toMatchTypeOf<Pattern>();
62+
});
63+
});
64+
65+
describe('array types', () => {
66+
it('should allow array containment patterns', () => {
67+
type Input = string[];
68+
type Pattern = ContainmentPattern<Input>;
69+
70+
// Array pattern should be ContainmentPattern<element>[]
71+
expectTypeOf<Pattern>().toEqualTypeOf<string[]>();
72+
});
73+
74+
it('should handle arrays of objects', () => {
75+
type Input = { type: string; value: number }[];
76+
type Pattern = ContainmentPattern<Input>;
77+
78+
// Should allow partial object patterns in array
79+
expectTypeOf<Pattern>().toEqualTypeOf<{ type?: string; value?: number }[]>();
80+
});
81+
82+
it('should allow array pattern with specific elements', () => {
83+
type Input = { type: string; value: number }[];
84+
type Pattern = ContainmentPattern<Input>;
85+
86+
// Should be able to check for specific elements
87+
const validPattern: Pattern = [{ type: 'error' }];
88+
expectTypeOf(validPattern).toMatchTypeOf<Pattern>();
89+
});
90+
91+
it('should handle readonly arrays', () => {
92+
type Input = readonly string[];
93+
type Pattern = ContainmentPattern<Input>;
94+
95+
// Should work with readonly arrays
96+
expectTypeOf<Pattern>().toEqualTypeOf<string[]>();
97+
});
98+
});
99+
100+
describe('complex nested structures', () => {
101+
it('should handle deeply nested objects', () => {
102+
type Input = {
103+
level1: {
104+
level2: {
105+
level3: { value: string };
106+
};
107+
};
108+
};
109+
type Pattern = ContainmentPattern<Input>;
110+
111+
// All levels should have optional keys
112+
expectTypeOf<Pattern>().toEqualTypeOf<{
113+
level1?: {
114+
level2?: {
115+
level3?: { value?: string };
116+
};
117+
};
118+
}>();
119+
});
120+
121+
it('should handle objects with array properties', () => {
122+
type Input = {
123+
items: { id: number; name: string }[];
124+
meta: { count: number };
125+
};
126+
type Pattern = ContainmentPattern<Input>;
127+
128+
expectTypeOf<Pattern>().toEqualTypeOf<{
129+
items?: { id?: number; name?: string }[];
130+
meta?: { count?: number };
131+
}>();
132+
});
133+
});
134+
});
135+
136+
describe('condition option typing in step methods', () => {
137+
describe('root step condition', () => {
138+
it('should type condition as ContainmentPattern<FlowInput>', () => {
139+
type FlowInput = { userId: string; role: string };
140+
141+
// This should compile - valid partial pattern
142+
const flow = new Flow<FlowInput>({ slug: 'test_flow' }).step(
143+
{ slug: 'check', condition: { role: 'admin' } },
144+
(input) => input.userId
145+
);
146+
147+
expectTypeOf(flow).toBeObject();
148+
});
149+
150+
it('should reject invalid keys in condition', () => {
151+
type FlowInput = { userId: string; role: string };
152+
153+
// @ts-expect-error - 'invalidKey' does not exist on FlowInput
154+
new Flow<FlowInput>({ slug: 'test_flow' }).step(
155+
{ slug: 'check', condition: { invalidKey: 'value' } },
156+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
157+
(input: any) => input.userId
158+
);
159+
});
160+
161+
it('should reject wrong value types in condition', () => {
162+
type FlowInput = { userId: string; role: string };
163+
164+
// @ts-expect-error - role should be string, not number
165+
new Flow<FlowInput>({ slug: 'test_flow' }).step(
166+
{ slug: 'check', condition: { role: 123 } },
167+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
168+
(input: any) => input.userId
169+
);
170+
});
171+
172+
it('should allow empty object condition (always matches)', () => {
173+
type FlowInput = { userId: string; role: string };
174+
175+
// Empty object should be valid
176+
const flow = new Flow<FlowInput>({ slug: 'test_flow' }).step(
177+
{ slug: 'check', condition: {} },
178+
(input) => input.userId
179+
);
180+
181+
expectTypeOf(flow).toBeObject();
182+
});
183+
184+
it('should allow nested object patterns', () => {
185+
type FlowInput = { user: { name: string; role: string } };
186+
187+
const flow = new Flow<FlowInput>({ slug: 'test_flow' }).step(
188+
{ slug: 'check', condition: { user: { role: 'admin' } } },
189+
(input) => input.user.name
190+
);
191+
192+
expectTypeOf(flow).toBeObject();
193+
});
194+
});
195+
196+
describe('dependent step condition', () => {
197+
it('should type condition as ContainmentPattern<DepsObject>', () => {
198+
const flow = new Flow<{ initial: string }>({ slug: 'test_flow' })
199+
.step({ slug: 'fetch' }, () => ({ status: 'ok', data: 'result' }))
200+
.step(
201+
{
202+
slug: 'process',
203+
dependsOn: ['fetch'],
204+
condition: { fetch: { status: 'ok' } },
205+
},
206+
(deps) => deps.fetch.data
207+
);
208+
209+
expectTypeOf(flow).toBeObject();
210+
});
211+
212+
it('should reject invalid dep slug in condition', () => {
213+
new Flow<{ initial: string }>({ slug: 'test_flow' })
214+
.step({ slug: 'fetch' }, () => ({ status: 'ok' }))
215+
.step(
216+
{
217+
slug: 'process',
218+
dependsOn: ['fetch'],
219+
// @ts-expect-error - 'nonexistent' is not a dependency
220+
condition: { nonexistent: { status: 'ok' } },
221+
},
222+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
223+
(deps: any) => deps.fetch.status
224+
);
225+
});
226+
227+
it('should reject invalid keys within dep output', () => {
228+
new Flow<{ initial: string }>({ slug: 'test_flow' })
229+
.step({ slug: 'fetch' }, () => ({ status: 'ok' }))
230+
.step(
231+
{
232+
slug: 'process',
233+
dependsOn: ['fetch'],
234+
// @ts-expect-error - 'invalidField' does not exist on fetch output
235+
condition: { fetch: { invalidField: 'value' } },
236+
},
237+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
238+
(deps: any) => deps.fetch.status
239+
);
240+
});
241+
242+
it('should handle multiple dependencies in condition', () => {
243+
const flow = new Flow<{ initial: string }>({ slug: 'test_flow' })
244+
.step({ slug: 'step1' }, () => ({ ready: true }))
245+
.step({ slug: 'step2' }, () => ({ valid: true }))
246+
.step(
247+
{
248+
slug: 'final',
249+
dependsOn: ['step1', 'step2'],
250+
condition: { step1: { ready: true }, step2: { valid: true } },
251+
},
252+
(deps) => deps.step1.ready && deps.step2.valid
253+
);
254+
255+
expectTypeOf(flow).toBeObject();
256+
});
257+
});
258+
259+
describe('array step condition', () => {
260+
it('should type condition for root array step', () => {
261+
type FlowInput = { items: string[]; enabled: boolean };
262+
263+
const flow = new Flow<FlowInput>({ slug: 'test_flow' }).array(
264+
{ slug: 'getItems', condition: { enabled: true } },
265+
(input) => input.items
266+
);
267+
268+
expectTypeOf(flow).toBeObject();
269+
});
270+
271+
it('should type condition for dependent array step', () => {
272+
const flow = new Flow<{ initial: string }>({ slug: 'test_flow' })
273+
.step({ slug: 'fetch' }, () => ({ ready: true, items: ['a', 'b'] }))
274+
.array(
275+
{
276+
slug: 'process',
277+
dependsOn: ['fetch'],
278+
condition: { fetch: { ready: true } },
279+
},
280+
(deps) => deps.fetch.items
281+
);
282+
283+
expectTypeOf(flow).toBeObject();
284+
});
285+
});
286+
287+
describe('map step condition', () => {
288+
it('should type condition for root map step', () => {
289+
type FlowInput = { type: string; value: number }[];
290+
291+
const flow = new Flow<FlowInput>({ slug: 'test_flow' }).map(
292+
// Root map condition checks the array itself
293+
{ slug: 'process', condition: [{ type: 'active' }] },
294+
(item) => item.value * 2
295+
);
296+
297+
expectTypeOf(flow).toBeObject();
298+
});
299+
300+
it('should type condition for dependent map step', () => {
301+
const flow = new Flow<{ initial: string }>({ slug: 'test_flow' })
302+
.step({ slug: 'fetch' }, () => [
303+
{ id: 1, active: true },
304+
{ id: 2, active: false },
305+
])
306+
.map(
307+
{
308+
slug: 'process',
309+
array: 'fetch',
310+
// Condition checks the array dep
311+
condition: { fetch: [{ active: true }] },
312+
},
313+
(item) => item.id
314+
);
315+
316+
expectTypeOf(flow).toBeObject();
317+
});
318+
});
319+
});

0 commit comments

Comments
 (0)