@structuralists/scaffolding 0.14.0 → 0.15.0
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 +1 -1
- package/src/forms/CLAUDE.md +56 -4
- package/src/forms/plan.md +36 -7
- package/src/forms/state/useFormState/useFormState.composition.test.tsx +342 -0
- package/src/forms/state/useFormState/useFormState.stress.test-d.ts +451 -0
- package/src/forms/state/validations/perField.ts +22 -11
- package/src/forms/state/validations/types.test-d.ts +108 -0
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -63,6 +63,14 @@ type FieldConstraint<F> =
|
|
|
63
63
|
| (F extends FormValueList ? ListConstraint<F[number]> : never); // { each: … }
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
Per field type, that comes out as:
|
|
67
|
+
|
|
68
|
+
| Field type | Legal constraint forms |
|
|
69
|
+
|------------------|---------------------------------------------------------------|
|
|
70
|
+
| scalar leaf | validator, validator array |
|
|
71
|
+
| object section | validator / array (whole-section value), nested `Validations` |
|
|
72
|
+
| list | validator / array (whole-list value), `{ each: spec }` |
|
|
73
|
+
|
|
66
74
|
The array form is the everyday way to stack validators on a field at the
|
|
67
75
|
constraint site; a nested spec addresses the fields of an object section;
|
|
68
76
|
`each` applies a spec to every element of a list. All compose freely:
|
|
@@ -126,10 +134,22 @@ const constraints = perField({
|
|
|
126
134
|
|
|
127
135
|
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
128
136
|
value while preserving the precise types of each individual validator. It
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
the
|
|
137
|
+
admits the full recursive grammar — leaf validators and arrays, nested
|
|
138
|
+
object specs, `each` specs, freely composed. Its own bound is deliberately
|
|
139
|
+
*shape-only* (validators / validator arrays / objects of more of the same):
|
|
140
|
+
perField never sees the form type, so it can only reject obvious garbage;
|
|
141
|
+
the real check — every key a field, every validator input compatible,
|
|
142
|
+
structural forms only where the field type permits — happens at the
|
|
143
|
+
caller's `satisfies Validations<FormType>`. Consequence: wrong-key and
|
|
144
|
+
wrong-shape errors on a perField-built spec anchor at the `satisfies`, not
|
|
145
|
+
at the offending line inside the call.
|
|
146
|
+
|
|
147
|
+
A pre-built spec is also a legal *nested-spec value*: a
|
|
148
|
+
`perField({...}) satisfies Validations<Section>` object can sit under a
|
|
149
|
+
section key (or an `each` key) of a larger constraints object — building a
|
|
150
|
+
section's spec once and reusing it across sections is the intended
|
|
151
|
+
composition pattern (pinned at ~110-leaf scale in
|
|
152
|
+
`state/useFormState/useFormState.stress.test-d.ts`).
|
|
133
153
|
|
|
134
154
|
### The precision-preserving ceremony, by call-site shape
|
|
135
155
|
|
|
@@ -437,6 +457,38 @@ descent into a field.
|
|
|
437
457
|
real values onto validators except as opaque type tags — anything else
|
|
438
458
|
invites consumers to depend on the runtime shape.
|
|
439
459
|
|
|
460
|
+
## Probe suites and the recursion budget
|
|
461
|
+
|
|
462
|
+
The grammar's behavior is pinned by three type-probe tiers (enforced by
|
|
463
|
+
`bun run typecheck`) plus the runtime composition fixture:
|
|
464
|
+
|
|
465
|
+
- `state/validations/types.test-d.ts` — `Refine`/marker semantics at unit
|
|
466
|
+
scale: perField precision, union-member soundness, the widening failure
|
|
467
|
+
modes.
|
|
468
|
+
- `state/useFormState/useFormState.test-d.ts` — the hook boundary at chunky
|
|
469
|
+
(~30-leaf `InsuranceQuoteForm`) scale: the full grammar inline, boundary
|
|
470
|
+
regimes (default `V`, literal `initialValues`, `as const satisfies`),
|
|
471
|
+
union policy, and the negative probes. Authoring gotcha: deep
|
|
472
|
+
wrong-validator-INPUT rejections (TS2322) anchor at the *outermost*
|
|
473
|
+
constraint key, so their `@ts-expect-error` sits on the parent/root key's
|
|
474
|
+
line; wrong-KEY rejections (TS2353) anchor at the leaf.
|
|
475
|
+
- `state/useFormState/useFormState.stress.test-d.ts` — the stress tier
|
|
476
|
+
(~110-leaf `MegaQuoteForm` with the large spec inline twice, a 12-level
|
|
477
|
+
nested-spec ladder, a 4-deep `each` ladder, perField pre-built specs at
|
|
478
|
+
the same scale, deep `Path` bindings). If the recursion budget regresses,
|
|
479
|
+
tsc slows or fails here first.
|
|
480
|
+
- `state/useFormState/useFormState.composition.test.tsx` — the same deep
|
|
481
|
+
mixed composition exercised at *runtime* through the real hook: deep error
|
|
482
|
+
paths, absent-section/null-list skips (and their materialization), deep
|
|
483
|
+
`getFormFieldPropsAt` writes, whole-value leaf validators on structural
|
|
484
|
+
fields, submit gating.
|
|
485
|
+
|
|
486
|
+
Budget baselines live in plan.md's "recursion budget" section. When touching
|
|
487
|
+
the type machinery, run `bunx tsc --noEmit --extendedDiagnostics` and
|
|
488
|
+
compare instantiations against the latest recorded baseline — the failure
|
|
489
|
+
signal is ~10× instantiations or multi-second check time, and regressions
|
|
490
|
+
should be recorded there, not just noticed.
|
|
491
|
+
|
|
440
492
|
## Why we're building it this way from the start
|
|
441
493
|
|
|
442
494
|
The whole carrier-type approach falls apart if it gets retrofitted onto
|
package/src/forms/plan.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Plan: composable, recursive `Validations`
|
|
2
2
|
|
|
3
|
-
Status: **
|
|
4
|
-
`Validations<T>`, one validator per key, shallow `Refine`)
|
|
5
|
-
|
|
3
|
+
Status: **complete** — all working-order items and phases below are done.
|
|
4
|
+
The baseline (flat `Validations<T>`, one validator per key, shallow `Refine`)
|
|
5
|
+
merged as PR #12 (released 0.5.0); the recursive grammar landed over phases
|
|
6
|
+
1–4 (PRs #18, #24, #25, and the composition-hardening close-out). What
|
|
7
|
+
remains in this file is the record: grammar doctrine, decided semantics,
|
|
8
|
+
recorded budgets, and the deliberately-deferred items (per-field meta
|
|
9
|
+
state / stable keys — section 8 — and the open decisions at the end).
|
|
6
10
|
|
|
7
11
|
## Order of operations
|
|
8
12
|
|
|
@@ -42,7 +46,7 @@ TS wall can't strand finished work behind it.
|
|
|
42
46
|
wrapper-style element shorthands prototyped (`state/bindings/`:
|
|
43
47
|
`TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
|
|
44
48
|
baseline note under the recursion budget).
|
|
45
|
-
6. **Type spike, then items 2/3/4** — the recursion risk zone.
|
|
49
|
+
6. **Type spike, then items 2/3/4** — the recursion risk zone. ✅ *done*
|
|
46
50
|
Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
47
51
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
48
52
|
known response. Worst case, everything above still shipped.
|
|
@@ -58,6 +62,7 @@ TS wall can't strand finished work behind it.
|
|
|
58
62
|
- **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
|
|
59
63
|
"Phases" below.
|
|
60
64
|
- **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
|
|
65
|
+
- **Phase 4 (composition hardening): ✅ done** — see "Phases" below.
|
|
61
66
|
|
|
62
67
|
## Goal
|
|
63
68
|
|
|
@@ -254,6 +259,15 @@ multi-second check time:
|
|
|
254
259
|
instantiations, 58,813 types (+1.8% over the pre-phase-3 HEAD at
|
|
255
260
|
134,330/58,034 — mostly the story's new hook call site and deep
|
|
256
261
|
`Path`/`ValueAt` bindings)
|
|
262
|
+
- post-phase-4 / **final** (composition hardening: the stress tier ported
|
|
263
|
+
against the real hook — mega form with the spec inline twice, both depth
|
|
264
|
+
ladders, deep bindings — plus perField at the full grammar with its
|
|
265
|
+
probes, and the runtime composition fixture): check 0.92–0.99 s, 172,040
|
|
266
|
+
instantiations, 67,889 types (+25.8% instantiations over post-phase-3 —
|
|
267
|
+
almost entirely the deliberately-heavy stress fixtures, closely matching
|
|
268
|
+
the spike's measured stress-tier cost of ~28k; check time stays
|
|
269
|
+
sub-second, exactly as the spike's headroom predicted). This is the
|
|
270
|
+
standing baseline for future type-machinery work.
|
|
257
271
|
|
|
258
272
|
## Runtime consequences (can't be dodged)
|
|
259
273
|
|
|
@@ -343,9 +357,24 @@ tests, story updates where visible, probe ratchet.
|
|
|
343
357
|
"object field literally named `each`" disambiguation probe, and the
|
|
344
358
|
`each: <bare validator>` negative (rejected by TypeScript's weak-type
|
|
345
359
|
check — an obscure checker rule worth a canary).
|
|
346
|
-
4. **Composition hardening.** Deep mixed
|
|
347
|
-
|
|
348
|
-
(
|
|
360
|
+
4. **Composition hardening.** ✅ *done* — the closing phase. Deep mixed
|
|
361
|
+
fixtures: the type spike's stress tier ported against the REAL hook
|
|
362
|
+
(`useFormState.stress.test-d.ts` — ~110-leaf `MegaQuoteForm` with the
|
|
363
|
+
large applicant spec inline twice, 12-level nested-spec ladder, 4-deep
|
|
364
|
+
`each` ladder, deep `Path` bindings), plus a runtime composition fixture
|
|
365
|
+
through `renderHook` (`useFormState.composition.test.tsx` — deep error
|
|
366
|
+
paths at two list depths, absent-section/null-list skip *and*
|
|
367
|
+
materialization, deep `getFormFieldPropsAt` writes, whole-list leaf
|
|
368
|
+
validators, submit gating end to end). `perField` extended from
|
|
369
|
+
leaf-forms-only to the full recursive grammar: its bound is a loose
|
|
370
|
+
recursive shape (validators / arrays / objects of the same — deliberately
|
|
371
|
+
form-type-agnostic, so the real shape check stays at the caller's
|
|
372
|
+
`satisfies Validations<T>`), pinned at unit scale in
|
|
373
|
+
`validations/types.test-d.ts` and at mega scale (one pre-built applicant
|
|
374
|
+
spec reused across two sections) in the stress file. forms/CLAUDE.md
|
|
375
|
+
brought fully up to the grammar (constraint-forms-per-field-type table,
|
|
376
|
+
perField's new reach, probe-suite map). No runtime machinery changed —
|
|
377
|
+
the deep fixtures flushed out no correctness fixes.
|
|
349
378
|
|
|
350
379
|
## 5. Split forms into 'form elements' and 'form state' ✅ done
|
|
351
380
|
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFormState } from './useFormState';
|
|
4
|
+
import { errorAt } from './errorAt';
|
|
5
|
+
import { perField } from '../validations/perField';
|
|
6
|
+
import { matches, min, notEmpty } from '../validators/validators';
|
|
7
|
+
import type { Validations } from '../validations/types';
|
|
8
|
+
|
|
9
|
+
// The composition-hardening runtime fixture (plan phase 4): a deep mixed
|
|
10
|
+
// form — lists inside objects inside lists, nested specs inside `each`
|
|
11
|
+
// specs, whole-value leaf validators on structural fields, an absent
|
|
12
|
+
// section and a null list — driven through the REAL useFormState at the
|
|
13
|
+
// hook boundary. walk.test.ts pins each construct's semantics React-free;
|
|
14
|
+
// this file pins that they hold composed, end to end, with the constraints
|
|
15
|
+
// pre-built via perField (the phase-4 entry point for pre-built specs).
|
|
16
|
+
|
|
17
|
+
type UsAddress = {
|
|
18
|
+
line1: string | undefined;
|
|
19
|
+
city: string | undefined;
|
|
20
|
+
postalCode: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Incident = {
|
|
24
|
+
date: string | undefined;
|
|
25
|
+
claimAmountUsd: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Driver = {
|
|
29
|
+
name: string | undefined;
|
|
30
|
+
incidents: Incident[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Vehicle = { vin: string | undefined };
|
|
34
|
+
|
|
35
|
+
type Applicant = {
|
|
36
|
+
email: string | undefined;
|
|
37
|
+
homeAddress: UsAddress;
|
|
38
|
+
mailingAddress: UsAddress | undefined;
|
|
39
|
+
drivers: Driver[];
|
|
40
|
+
vehicles: Vehicle[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type QuoteForm = {
|
|
44
|
+
primary: Applicant;
|
|
45
|
+
secondary: Applicant | undefined;
|
|
46
|
+
household: {
|
|
47
|
+
members: Array<{ name: string | undefined }>;
|
|
48
|
+
};
|
|
49
|
+
quotes: Array<{
|
|
50
|
+
coverageType: string | undefined;
|
|
51
|
+
riders: Array<{ code: string | undefined; amountUsd: number | null }>;
|
|
52
|
+
}>;
|
|
53
|
+
pastPolicies: Array<{ insurer: string | undefined }> | null;
|
|
54
|
+
// An object field literally named `each` — the value model, not the
|
|
55
|
+
// constraint's shape, directs interpretation.
|
|
56
|
+
audit: { each: string | undefined };
|
|
57
|
+
agreedToTerms: boolean | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// One applicant spec, built once via perField and reused for both applicant
|
|
61
|
+
// sections — nested specs, each-in-each, arrays at depth, and a whole-list
|
|
62
|
+
// leaf validator on a structural field (leaf and structural forms are
|
|
63
|
+
// mutually exclusive per key: vehicles validates as a value, drivers per
|
|
64
|
+
// element).
|
|
65
|
+
const applicantSpec = perField({
|
|
66
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
67
|
+
homeAddress: {
|
|
68
|
+
city: notEmpty('city'),
|
|
69
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
70
|
+
},
|
|
71
|
+
mailingAddress: {
|
|
72
|
+
city: notEmpty('mailing city'),
|
|
73
|
+
},
|
|
74
|
+
drivers: {
|
|
75
|
+
each: {
|
|
76
|
+
name: notEmpty('name'),
|
|
77
|
+
incidents: {
|
|
78
|
+
each: {
|
|
79
|
+
date: notEmpty('date'),
|
|
80
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
vehicles: [(val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle')],
|
|
86
|
+
}) satisfies Validations<Applicant>;
|
|
87
|
+
|
|
88
|
+
const constraints = {
|
|
89
|
+
primary: applicantSpec,
|
|
90
|
+
secondary: applicantSpec,
|
|
91
|
+
household: {
|
|
92
|
+
members: { each: { name: notEmpty('member name') } },
|
|
93
|
+
},
|
|
94
|
+
quotes: {
|
|
95
|
+
each: {
|
|
96
|
+
coverageType: notEmpty('coverageType'),
|
|
97
|
+
riders: {
|
|
98
|
+
each: { code: notEmpty('code'), amountUsd: min('amountUsd', 0) },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
pastPolicies: {
|
|
103
|
+
each: { insurer: notEmpty('insurer') },
|
|
104
|
+
},
|
|
105
|
+
audit: { each: notEmpty('audit each') },
|
|
106
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
107
|
+
} as const satisfies Validations<QuoteForm>;
|
|
108
|
+
|
|
109
|
+
// Mostly-valid deep values with deliberate failures: an each-in-each leaf
|
|
110
|
+
// and its sibling at depth 5, a member name, a rider code at list-in-
|
|
111
|
+
// object-in-list depth, and the `audit.each` disambiguation field. The
|
|
112
|
+
// secondary section is absent and pastPolicies is null — both fully
|
|
113
|
+
// spec'd, both skipped.
|
|
114
|
+
const makeValues = (): QuoteForm => ({
|
|
115
|
+
primary: {
|
|
116
|
+
email: 'ada@lovelace.dev',
|
|
117
|
+
homeAddress: { line1: '1 Main St', city: 'London', postalCode: '12345' },
|
|
118
|
+
mailingAddress: undefined,
|
|
119
|
+
drivers: [
|
|
120
|
+
{ name: 'Ada', incidents: [] },
|
|
121
|
+
{ name: 'Grace', incidents: [{ date: undefined, claimAmountUsd: -50 }] },
|
|
122
|
+
],
|
|
123
|
+
vehicles: [{ vin: 'V1' }],
|
|
124
|
+
},
|
|
125
|
+
secondary: undefined,
|
|
126
|
+
household: { members: [{ name: undefined }, { name: 'Linus' }] },
|
|
127
|
+
quotes: [
|
|
128
|
+
{
|
|
129
|
+
coverageType: 'auto',
|
|
130
|
+
riders: [
|
|
131
|
+
{ code: 'R1', amountUsd: 10 },
|
|
132
|
+
{ code: undefined, amountUsd: null },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
pastPolicies: null,
|
|
137
|
+
audit: { each: undefined },
|
|
138
|
+
agreedToTerms: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const setup = (onSubmit?: (values: QuoteForm) => void) =>
|
|
142
|
+
renderHook(() =>
|
|
143
|
+
useFormState({ initialValues: makeValues(), constraints, onSubmit }),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
describe('useFormState — deep mixed composition at the hook boundary', () => {
|
|
147
|
+
test('errors carry full structural paths across every grammar construct', () => {
|
|
148
|
+
const { result } = setup();
|
|
149
|
+
// The exact list: constraint-key order at each level, elements in list
|
|
150
|
+
// order, failing nodes only. Absent secondary and null pastPolicies
|
|
151
|
+
// contribute nothing despite full specs; the whole-list vehicles
|
|
152
|
+
// validator and every passing leaf contribute nothing.
|
|
153
|
+
expect(result.current.errors).toEqual([
|
|
154
|
+
{
|
|
155
|
+
path: ['primary', 'drivers', 1, 'incidents', 0, 'date'],
|
|
156
|
+
error: "'date' cannot be empty",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
path: ['primary', 'drivers', 1, 'incidents', 0, 'claimAmountUsd'],
|
|
160
|
+
error: "'claimAmountUsd' must be at least 0",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: ['household', 'members', 0, 'name'],
|
|
164
|
+
error: "'member name' cannot be empty",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
path: ['quotes', 0, 'riders', 1, 'code'],
|
|
168
|
+
error: "'code' cannot be empty",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
path: ['audit', 'each'],
|
|
172
|
+
error: "'audit each' cannot be empty",
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
expect(result.current.isValid).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('errorAt resolves deep numeric paths and stays empty on skipped subtrees', () => {
|
|
179
|
+
const { result } = setup();
|
|
180
|
+
expect(
|
|
181
|
+
errorAt(result.current.errors, ['primary', 'drivers', 1, 'incidents', 0, 'date']),
|
|
182
|
+
).toBe("'date' cannot be empty");
|
|
183
|
+
expect(errorAt(result.current.errors, ['quotes', 0, 'riders', 1, 'code'])).toBe(
|
|
184
|
+
"'code' cannot be empty",
|
|
185
|
+
);
|
|
186
|
+
// Sibling elements that passed have no entry at their own index.
|
|
187
|
+
expect(
|
|
188
|
+
errorAt(result.current.errors, ['quotes', 0, 'riders', 0, 'code']),
|
|
189
|
+
).toBeUndefined();
|
|
190
|
+
// The absent section's constrained leaves have no entries.
|
|
191
|
+
expect(errorAt(result.current.errors, ['secondary', 'email'])).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a deep fix through getFormFieldPropsAt clears exactly that entry', () => {
|
|
195
|
+
const { result } = setup();
|
|
196
|
+
|
|
197
|
+
act(() => {
|
|
198
|
+
result.current
|
|
199
|
+
.getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'date'])
|
|
200
|
+
.onChange('2026-01-01');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(
|
|
204
|
+
errorAt(result.current.errors, ['primary', 'drivers', 1, 'incidents', 0, 'date']),
|
|
205
|
+
).toBeUndefined();
|
|
206
|
+
// The failing sibling leaf in the same element is untouched …
|
|
207
|
+
expect(
|
|
208
|
+
errorAt(result.current.errors, [
|
|
209
|
+
'primary',
|
|
210
|
+
'drivers',
|
|
211
|
+
1,
|
|
212
|
+
'incidents',
|
|
213
|
+
0,
|
|
214
|
+
'claimAmountUsd',
|
|
215
|
+
]),
|
|
216
|
+
).toBe("'claimAmountUsd' must be at least 0");
|
|
217
|
+
// … and the write only cloned the spine: the other driver keeps identity.
|
|
218
|
+
expect(result.current.values.primary.drivers[0]).toEqual({
|
|
219
|
+
name: 'Ada',
|
|
220
|
+
incidents: [],
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('a whole-list leaf validator on a structural field runs against the list value', () => {
|
|
225
|
+
const { result } = setup();
|
|
226
|
+
expect(errorAt(result.current.errors, ['primary', 'vehicles'])).toBeUndefined();
|
|
227
|
+
|
|
228
|
+
act(() => {
|
|
229
|
+
result.current.getFormFieldPropsAt(['primary', 'vehicles']).onChange([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(errorAt(result.current.errors, ['primary', 'vehicles'])).toBe(
|
|
233
|
+
'at least one vehicle',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('materializing an absent section brings its whole spec to life', () => {
|
|
238
|
+
const { result } = setup();
|
|
239
|
+
|
|
240
|
+
act(() => {
|
|
241
|
+
result.current.onValueChanges((prev) => ({
|
|
242
|
+
...prev,
|
|
243
|
+
secondary: {
|
|
244
|
+
email: undefined,
|
|
245
|
+
homeAddress: { line1: undefined, city: undefined, postalCode: undefined },
|
|
246
|
+
mailingAddress: undefined,
|
|
247
|
+
drivers: [{ name: undefined, incidents: [] }],
|
|
248
|
+
vehicles: [],
|
|
249
|
+
},
|
|
250
|
+
}));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(errorAt(result.current.errors, ['secondary', 'email'])).toBe(
|
|
254
|
+
"'email' cannot be empty",
|
|
255
|
+
);
|
|
256
|
+
expect(errorAt(result.current.errors, ['secondary', 'homeAddress', 'city'])).toBe(
|
|
257
|
+
"'city' cannot be empty",
|
|
258
|
+
);
|
|
259
|
+
expect(
|
|
260
|
+
errorAt(result.current.errors, ['secondary', 'drivers', 0, 'name']),
|
|
261
|
+
).toBe("'name' cannot be empty");
|
|
262
|
+
expect(errorAt(result.current.errors, ['secondary', 'vehicles'])).toBe(
|
|
263
|
+
'at least one vehicle',
|
|
264
|
+
);
|
|
265
|
+
// The still-absent nested section inside the materialized one stays
|
|
266
|
+
// skipped.
|
|
267
|
+
expect(
|
|
268
|
+
errorAt(result.current.errors, ['secondary', 'mailingAddress', 'city']),
|
|
269
|
+
).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a null list wakes up the same way', () => {
|
|
273
|
+
const { result } = setup();
|
|
274
|
+
expect(errorAt(result.current.errors, ['pastPolicies', 0, 'insurer'])).toBeUndefined();
|
|
275
|
+
|
|
276
|
+
act(() => {
|
|
277
|
+
result.current.onValueChanges((prev) => ({
|
|
278
|
+
...prev,
|
|
279
|
+
pastPolicies: [{ insurer: 'Acme' }, { insurer: undefined }],
|
|
280
|
+
}));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(errorAt(result.current.errors, ['pastPolicies', 0, 'insurer'])).toBeUndefined();
|
|
284
|
+
expect(errorAt(result.current.errors, ['pastPolicies', 1, 'insurer'])).toBe(
|
|
285
|
+
"'insurer' cannot be empty",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('submit is gated on the deep composition and passes the current values through', () => {
|
|
290
|
+
let submitted: QuoteForm | undefined;
|
|
291
|
+
const onSubmit = mock((values: QuoteForm) => {
|
|
292
|
+
submitted = values;
|
|
293
|
+
});
|
|
294
|
+
const { result } = setup(onSubmit);
|
|
295
|
+
|
|
296
|
+
act(() => {
|
|
297
|
+
result.current.submit();
|
|
298
|
+
});
|
|
299
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
300
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
301
|
+
|
|
302
|
+
// Fix every failing node, one grammar construct at a time.
|
|
303
|
+
act(() => {
|
|
304
|
+
result.current
|
|
305
|
+
.getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'date'])
|
|
306
|
+
.onChange('2026-01-01');
|
|
307
|
+
});
|
|
308
|
+
act(() => {
|
|
309
|
+
result.current
|
|
310
|
+
.getFormFieldPropsAt(['primary', 'drivers', 1, 'incidents', 0, 'claimAmountUsd'])
|
|
311
|
+
.onChange(500);
|
|
312
|
+
});
|
|
313
|
+
act(() => {
|
|
314
|
+
result.current
|
|
315
|
+
.getFormFieldPropsAt(['household', 'members', 0, 'name'])
|
|
316
|
+
.onChange('Margaret');
|
|
317
|
+
});
|
|
318
|
+
act(() => {
|
|
319
|
+
result.current
|
|
320
|
+
.getFormFieldPropsAt(['quotes', 0, 'riders', 1, 'code'])
|
|
321
|
+
.onChange('R2');
|
|
322
|
+
});
|
|
323
|
+
act(() => {
|
|
324
|
+
result.current.getFormFieldPropsAt(['audit', 'each']).onChange('reviewed');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(result.current.errors).toEqual([]);
|
|
328
|
+
expect(result.current.isValid).toBe(true);
|
|
329
|
+
|
|
330
|
+
act(() => {
|
|
331
|
+
result.current.submit();
|
|
332
|
+
});
|
|
333
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
334
|
+
expect(submitted?.primary.drivers[1].incidents[0]).toEqual({
|
|
335
|
+
date: '2026-01-01',
|
|
336
|
+
claimAmountUsd: 500,
|
|
337
|
+
});
|
|
338
|
+
expect(submitted?.quotes[0].riders[1].code).toBe('R2');
|
|
339
|
+
expect(submitted?.secondary).toBeUndefined();
|
|
340
|
+
expect(submitted?.pastPolicies).toBeNull();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
import { useFormState } from './useFormState';
|
|
3
|
+
import { perField } from '../validations/perField';
|
|
4
|
+
import { matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
+
import type { FormFieldProps } from './types';
|
|
6
|
+
import type { Refine, Validations } from '../validations/types';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The stress tier (plan phase 4, composition hardening) — the phase-2 type
|
|
10
|
+
// spike's stress fixtures, pinned against the REAL hook. Where
|
|
11
|
+
// useFormState.test-d.ts probes the grammar at chunky (~30-leaf) scale, this
|
|
12
|
+
// file pushes past it to hold the measured headroom: a ~110-leaf form at 5–6
|
|
13
|
+
// levels with the large applicant spec written inline twice, the same spec
|
|
14
|
+
// pre-built ONCE via perField and reused for both applicant sections (the
|
|
15
|
+
// pre-built entry point at realistic scale), a 12-level nested-object spec
|
|
16
|
+
// ladder, a 4-deep list-in-list `each` ladder, and deep Path bindings on the
|
|
17
|
+
// mega form. If the recursion budget regresses, tsc slows or fails here
|
|
18
|
+
// first — watch the instantiation count in the plan's recursion-budget
|
|
19
|
+
// section, not just success.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
type UsAddress = {
|
|
23
|
+
line1: string | undefined;
|
|
24
|
+
line2: string | undefined;
|
|
25
|
+
city: string | undefined;
|
|
26
|
+
state: string | undefined;
|
|
27
|
+
postalCode: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Incident = {
|
|
31
|
+
date: string | undefined;
|
|
32
|
+
kind: string | undefined;
|
|
33
|
+
claimAmountUsd: number | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Driver = {
|
|
37
|
+
name: string | undefined;
|
|
38
|
+
licenseNumber: string | undefined;
|
|
39
|
+
licenseState: string | undefined;
|
|
40
|
+
incidents: Incident[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Vehicle = {
|
|
44
|
+
vin: string | undefined;
|
|
45
|
+
year: number | undefined;
|
|
46
|
+
make: string | undefined;
|
|
47
|
+
model: string | undefined;
|
|
48
|
+
primaryDriverName: string | undefined;
|
|
49
|
+
garagingAddress: UsAddress;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Applicant = {
|
|
53
|
+
firstName: string | undefined;
|
|
54
|
+
lastName: string | undefined;
|
|
55
|
+
email: string | undefined;
|
|
56
|
+
phone: string | undefined;
|
|
57
|
+
dateOfBirth: string | undefined;
|
|
58
|
+
employer: string | null;
|
|
59
|
+
jobTitle: string | null;
|
|
60
|
+
yearsEmployed: number | null;
|
|
61
|
+
annualIncomeUsd: number | null;
|
|
62
|
+
homeAddress: UsAddress;
|
|
63
|
+
mailingAddress: UsAddress | undefined;
|
|
64
|
+
drivers: Driver[];
|
|
65
|
+
vehicles: Vehicle[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type MegaQuoteForm = {
|
|
69
|
+
primary: Applicant;
|
|
70
|
+
secondary: Applicant | undefined;
|
|
71
|
+
household: {
|
|
72
|
+
address: UsAddress;
|
|
73
|
+
members: Array<{
|
|
74
|
+
name: string | undefined;
|
|
75
|
+
relation: string | undefined;
|
|
76
|
+
dateOfBirth: string | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
79
|
+
quotes: Array<{
|
|
80
|
+
coverageType: string | undefined;
|
|
81
|
+
deductibleUsd: number | undefined;
|
|
82
|
+
startDate: string | undefined;
|
|
83
|
+
riders: Array<{
|
|
84
|
+
code: string | undefined;
|
|
85
|
+
amountUsd: number | null;
|
|
86
|
+
}>;
|
|
87
|
+
}>;
|
|
88
|
+
referralSource: string | null;
|
|
89
|
+
notes: string | undefined;
|
|
90
|
+
agreedToTerms: boolean | undefined;
|
|
91
|
+
paperlessBilling: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe('useFormState stress: ~110-leaf form, large inline spec written twice', () => {
|
|
95
|
+
it('composes and refines with the applicant spec duplicated inline', () => {
|
|
96
|
+
useFormState({
|
|
97
|
+
initialValues: {} as MegaQuoteForm,
|
|
98
|
+
constraints: {
|
|
99
|
+
primary: {
|
|
100
|
+
firstName: notEmpty('firstName'),
|
|
101
|
+
lastName: [notEmpty('lastName')],
|
|
102
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
103
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
104
|
+
dateOfBirth: notEmpty('dateOfBirth'),
|
|
105
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
106
|
+
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
107
|
+
homeAddress: {
|
|
108
|
+
line1: notEmpty('line1'),
|
|
109
|
+
city: notEmpty('city'),
|
|
110
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
111
|
+
},
|
|
112
|
+
mailingAddress: {
|
|
113
|
+
city: notEmpty('mailing city'),
|
|
114
|
+
},
|
|
115
|
+
drivers: {
|
|
116
|
+
each: {
|
|
117
|
+
name: notEmpty('name'),
|
|
118
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
119
|
+
incidents: {
|
|
120
|
+
each: {
|
|
121
|
+
date: notEmpty('date'),
|
|
122
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
vehicles: {
|
|
128
|
+
each: {
|
|
129
|
+
vin: [notEmpty('vin')],
|
|
130
|
+
year: min('year', 1900),
|
|
131
|
+
garagingAddress: {
|
|
132
|
+
line1: notEmpty('garaging line1'),
|
|
133
|
+
postalCode: [notEmpty('garaging postalCode')],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
// The same large spec again, on the nullable secondary applicant —
|
|
139
|
+
// simulates a real page where big sections each get full specs.
|
|
140
|
+
secondary: {
|
|
141
|
+
firstName: notEmpty('firstName'),
|
|
142
|
+
lastName: [notEmpty('lastName')],
|
|
143
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
144
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
145
|
+
dateOfBirth: notEmpty('dateOfBirth'),
|
|
146
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
147
|
+
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
148
|
+
homeAddress: {
|
|
149
|
+
line1: notEmpty('line1'),
|
|
150
|
+
city: notEmpty('city'),
|
|
151
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
152
|
+
},
|
|
153
|
+
mailingAddress: {
|
|
154
|
+
city: notEmpty('mailing city'),
|
|
155
|
+
},
|
|
156
|
+
drivers: {
|
|
157
|
+
each: {
|
|
158
|
+
name: notEmpty('name'),
|
|
159
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
160
|
+
incidents: {
|
|
161
|
+
each: {
|
|
162
|
+
date: notEmpty('date'),
|
|
163
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
vehicles: {
|
|
169
|
+
each: {
|
|
170
|
+
vin: [notEmpty('vin')],
|
|
171
|
+
year: min('year', 1900),
|
|
172
|
+
garagingAddress: {
|
|
173
|
+
line1: notEmpty('garaging line1'),
|
|
174
|
+
postalCode: [notEmpty('garaging postalCode')],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
household: {
|
|
180
|
+
address: {
|
|
181
|
+
city: notEmpty('household city'),
|
|
182
|
+
},
|
|
183
|
+
members: {
|
|
184
|
+
each: {
|
|
185
|
+
name: notEmpty('member name'),
|
|
186
|
+
dateOfBirth: notEmpty('member dateOfBirth'),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
quotes: {
|
|
191
|
+
each: {
|
|
192
|
+
coverageType: notEmpty('coverageType'),
|
|
193
|
+
deductibleUsd: min('deductibleUsd', 0),
|
|
194
|
+
startDate: notEmpty('startDate'),
|
|
195
|
+
riders: {
|
|
196
|
+
each: {
|
|
197
|
+
code: notEmpty('code'),
|
|
198
|
+
amountUsd: min('amountUsd', 0),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
referralSource: notEmpty('referralSource'),
|
|
204
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
205
|
+
},
|
|
206
|
+
onSubmit: (values) => {
|
|
207
|
+
// Depth 5–6 refinements on both applicant sections.
|
|
208
|
+
expectTypeOf(values.primary.email).toEqualTypeOf<string>();
|
|
209
|
+
expectTypeOf(values.primary.homeAddress.postalCode).toEqualTypeOf<string>();
|
|
210
|
+
expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
211
|
+
expectTypeOf(values.primary.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
|
|
212
|
+
expectTypeOf(values.primary.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
|
|
213
|
+
expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
|
|
214
|
+
expectTypeOf(values.secondary?.drivers[0].incidents[0].date).toEqualTypeOf<
|
|
215
|
+
string | undefined
|
|
216
|
+
>();
|
|
217
|
+
expectTypeOf(values.household.members[0].name).toEqualTypeOf<string>();
|
|
218
|
+
expectTypeOf(values.quotes[0].riders[0].code).toEqualTypeOf<string>();
|
|
219
|
+
expectTypeOf(values.quotes[0].riders[0].amountUsd).toEqualTypeOf<number | null>();
|
|
220
|
+
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
221
|
+
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('Path machinery holds at mega scale: deep bindings on the hook result', () => {
|
|
227
|
+
const form = useFormState({ initialValues: {} as MegaQuoteForm });
|
|
228
|
+
|
|
229
|
+
expectTypeOf(
|
|
230
|
+
form.getFormFieldPropsAt(['primary', 'drivers', 0, 'incidents', 0, 'date']),
|
|
231
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
232
|
+
expectTypeOf(
|
|
233
|
+
form.getFormFieldPropsAt(['secondary', 'vehicles', 0, 'garagingAddress', 'postalCode']),
|
|
234
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
235
|
+
expectTypeOf(
|
|
236
|
+
form.getFormFieldPropsAt(['quotes', 0, 'riders', 0, 'amountUsd']),
|
|
237
|
+
).toEqualTypeOf<FormFieldProps<number | null>>();
|
|
238
|
+
expectTypeOf(
|
|
239
|
+
form.getFormFieldPropsAt(['household', 'members', 0, 'relation']),
|
|
240
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
241
|
+
|
|
242
|
+
// @ts-expect-error 'drvers' is not a field of the primary applicant
|
|
243
|
+
form.getFormFieldPropsAt(['primary', 'drvers', 0, 'name']);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// perField at realistic scale (plan phase 4): the pre-built entry point on
|
|
249
|
+
// the full recursive grammar — one applicant spec built once, reused for
|
|
250
|
+
// both applicant sections, mixing each-in-each, nested-in-each, and a
|
|
251
|
+
// whole-list leaf validator on a structural field. Before this phase,
|
|
252
|
+
// perField was only ever exercised on a 3-key flat form.
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
describe('useFormState stress: perField pre-built specs at mega scale', () => {
|
|
256
|
+
it('preserves markers through a perField applicant spec reused across sections', () => {
|
|
257
|
+
const applicantSpec = perField({
|
|
258
|
+
firstName: notEmpty('firstName'),
|
|
259
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
260
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
261
|
+
homeAddress: {
|
|
262
|
+
city: notEmpty('city'),
|
|
263
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
264
|
+
},
|
|
265
|
+
mailingAddress: {
|
|
266
|
+
city: notEmpty('mailing city'),
|
|
267
|
+
},
|
|
268
|
+
drivers: {
|
|
269
|
+
each: {
|
|
270
|
+
name: notEmpty('name'),
|
|
271
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
272
|
+
incidents: {
|
|
273
|
+
each: {
|
|
274
|
+
date: notEmpty('date'),
|
|
275
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
// Whole-list leaf validator on a structural field: leaf and structural
|
|
281
|
+
// forms are mutually exclusive per key, so vehicles takes the
|
|
282
|
+
// list-as-a-value form while drivers takes `each`.
|
|
283
|
+
vehicles: [
|
|
284
|
+
(val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle'),
|
|
285
|
+
],
|
|
286
|
+
}) satisfies Validations<Applicant>;
|
|
287
|
+
|
|
288
|
+
const constraints = {
|
|
289
|
+
primary: applicantSpec,
|
|
290
|
+
secondary: applicantSpec,
|
|
291
|
+
household: {
|
|
292
|
+
members: {
|
|
293
|
+
each: { name: notEmpty('member name') },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
297
|
+
} as const satisfies Validations<MegaQuoteForm>;
|
|
298
|
+
|
|
299
|
+
// Direct Refine application on the composed pre-built object.
|
|
300
|
+
type Submit = Refine<MegaQuoteForm, typeof constraints>;
|
|
301
|
+
expectTypeOf<Submit['primary']['email']>().toEqualTypeOf<string>();
|
|
302
|
+
expectTypeOf<Submit['primary']['homeAddress']['postalCode']>().toEqualTypeOf<string>();
|
|
303
|
+
expectTypeOf<
|
|
304
|
+
Submit['primary']['drivers'][number]['incidents'][number]['date']
|
|
305
|
+
>().toEqualTypeOf<string>();
|
|
306
|
+
expectTypeOf<Submit['primary']['vehicles']>().toEqualTypeOf<Vehicle[]>();
|
|
307
|
+
expectTypeOf<Submit['secondary']>().toEqualTypeOf<
|
|
308
|
+
Refine<Applicant, typeof applicantSpec> | undefined
|
|
309
|
+
>();
|
|
310
|
+
expectTypeOf<Submit['household']['members'][number]['name']>().toEqualTypeOf<string>();
|
|
311
|
+
expectTypeOf<Submit['quotes']>().toEqualTypeOf<MegaQuoteForm['quotes']>();
|
|
312
|
+
|
|
313
|
+
// And through the hook boundary.
|
|
314
|
+
useFormState({
|
|
315
|
+
initialValues: {} as MegaQuoteForm,
|
|
316
|
+
constraints,
|
|
317
|
+
onSubmit: (values) => {
|
|
318
|
+
expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
319
|
+
expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
|
|
320
|
+
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
321
|
+
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// --- Object-depth ladder: specs 12 levels deep --------------------------------
|
|
328
|
+
|
|
329
|
+
type L12 = { leaf: string | undefined; tag: string | undefined };
|
|
330
|
+
type L11 = { next: L12; tag: string | undefined };
|
|
331
|
+
type L10 = { next: L11; tag: string | undefined };
|
|
332
|
+
type L9 = { next: L10; tag: string | undefined };
|
|
333
|
+
type L8 = { next: L9; tag: string | undefined };
|
|
334
|
+
type L7 = { next: L8; tag: string | undefined };
|
|
335
|
+
type L6 = { next: L7; tag: string | undefined };
|
|
336
|
+
type L5 = { next: L6; tag: string | undefined };
|
|
337
|
+
type L4 = { next: L5; tag: string | undefined };
|
|
338
|
+
type L3 = { next: L4; tag: string | undefined };
|
|
339
|
+
type L2 = { next: L3; tag: string | undefined };
|
|
340
|
+
type L1 = { next: L2; tag: string | undefined };
|
|
341
|
+
type DeepObjectForm = { root: L1 };
|
|
342
|
+
|
|
343
|
+
describe('useFormState stress: 12-level nested-object spec ladder', () => {
|
|
344
|
+
it('refines a leaf 13 levels down, with a validator at every level', () => {
|
|
345
|
+
useFormState({
|
|
346
|
+
initialValues: {} as DeepObjectForm,
|
|
347
|
+
constraints: {
|
|
348
|
+
root: {
|
|
349
|
+
tag: notEmpty('t1'),
|
|
350
|
+
next: {
|
|
351
|
+
tag: notEmpty('t2'),
|
|
352
|
+
next: {
|
|
353
|
+
tag: notEmpty('t3'),
|
|
354
|
+
next: {
|
|
355
|
+
tag: notEmpty('t4'),
|
|
356
|
+
next: {
|
|
357
|
+
tag: notEmpty('t5'),
|
|
358
|
+
next: {
|
|
359
|
+
tag: notEmpty('t6'),
|
|
360
|
+
next: {
|
|
361
|
+
tag: notEmpty('t7'),
|
|
362
|
+
next: {
|
|
363
|
+
tag: notEmpty('t8'),
|
|
364
|
+
next: {
|
|
365
|
+
tag: notEmpty('t9'),
|
|
366
|
+
next: {
|
|
367
|
+
tag: notEmpty('t10'),
|
|
368
|
+
next: {
|
|
369
|
+
tag: notEmpty('t11'),
|
|
370
|
+
next: {
|
|
371
|
+
tag: notEmpty('t12'),
|
|
372
|
+
leaf: [notEmpty('leaf'), minLength('leaf', 2)],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
onSubmit: (values) => {
|
|
387
|
+
expectTypeOf(
|
|
388
|
+
values.root.next.next.next.next.next.next.next.next.next.next.next.leaf,
|
|
389
|
+
).toEqualTypeOf<string>();
|
|
390
|
+
expectTypeOf(
|
|
391
|
+
values.root.next.next.next.next.next.next.next.next.next.next.next.tag,
|
|
392
|
+
).toEqualTypeOf<string>();
|
|
393
|
+
expectTypeOf(values.root.tag).toEqualTypeOf<string>();
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// --- List-depth ladder: `each` 4 levels deep ----------------------------------
|
|
400
|
+
|
|
401
|
+
type DeepListForm = {
|
|
402
|
+
xs: Array<{
|
|
403
|
+
label: string | undefined;
|
|
404
|
+
ys: Array<{
|
|
405
|
+
label: string | undefined;
|
|
406
|
+
zs: Array<{
|
|
407
|
+
label: string | undefined;
|
|
408
|
+
ws: Array<{
|
|
409
|
+
v: string | undefined;
|
|
410
|
+
n: number | null;
|
|
411
|
+
}>;
|
|
412
|
+
}>;
|
|
413
|
+
}>;
|
|
414
|
+
}>;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
describe('useFormState stress: 4-deep list-in-list each ladder', () => {
|
|
418
|
+
it('refines through four nested each specs', () => {
|
|
419
|
+
useFormState({
|
|
420
|
+
initialValues: {} as DeepListForm,
|
|
421
|
+
constraints: {
|
|
422
|
+
xs: {
|
|
423
|
+
each: {
|
|
424
|
+
label: notEmpty('x label'),
|
|
425
|
+
ys: {
|
|
426
|
+
each: {
|
|
427
|
+
label: notEmpty('y label'),
|
|
428
|
+
zs: {
|
|
429
|
+
each: {
|
|
430
|
+
label: notEmpty('z label'),
|
|
431
|
+
ws: {
|
|
432
|
+
each: {
|
|
433
|
+
v: notEmpty('v'),
|
|
434
|
+
n: min('n', 0),
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
onSubmit: (values) => {
|
|
445
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].v).toEqualTypeOf<string>();
|
|
446
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].n).toEqualTypeOf<number | null>();
|
|
447
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].label).toEqualTypeOf<string>();
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
// The loose, form-type-agnostic shape of one constraint: a validator, a
|
|
2
|
+
// validator array, or an object of more of the same — which covers both
|
|
3
|
+
// nested specs and `{ each: … }` (an `each` value is itself a spec object).
|
|
4
|
+
// `(val: never) => …` admits any validator input contravariantly. This bound
|
|
5
|
+
// only rejects obvious garbage (strings, numbers, null values); the real
|
|
6
|
+
// shape check — every key a field, every validator's input compatible,
|
|
7
|
+
// structural forms only where the field type permits — happens at the
|
|
8
|
+
// caller's `satisfies Validations<FormType>`, which is where the form type
|
|
9
|
+
// first enters the picture.
|
|
10
|
+
type LooseFieldConstraint =
|
|
11
|
+
| ((val: never) => string | null)
|
|
12
|
+
| readonly ((val: never) => string | null)[]
|
|
13
|
+
| { readonly [key: string]: LooseFieldConstraint };
|
|
14
|
+
|
|
1
15
|
// Aggregation entry point for building a `Validations<T>`-shaped constraints
|
|
2
|
-
// object
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
((val: never) => string | null) | readonly ((val: never) => string | null)[]
|
|
11
|
-
>,
|
|
12
|
-
>(
|
|
16
|
+
// object — the full recursive grammar (leaf validators and arrays, nested
|
|
17
|
+
// object specs, list `each` specs, freely composed). Runtime identity; the
|
|
18
|
+
// value is in the type: the `const` type parameter freezes each validator's
|
|
19
|
+
// precise type (including `Refinement` markers) instead of letting function
|
|
20
|
+
// types widen. Callers still apply `satisfies Validations<FormType>` to
|
|
21
|
+
// shape-check against their form type without losing that precision — see
|
|
22
|
+
// src/forms/CLAUDE.md.
|
|
23
|
+
export const perField = <const V extends Record<string, LooseFieldConstraint>>(
|
|
13
24
|
validations: V,
|
|
14
25
|
): V => validations;
|
|
@@ -205,6 +205,114 @@ describe('Refine<T, V> — union-typed members inside arrays', () => {
|
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// perField at the recursive grammar (plan phase 4): the pre-built-spec entry
|
|
210
|
+
// point admits the full grammar — nested object specs, list `each` specs,
|
|
211
|
+
// arrays at every level — and preserves refinement markers through all of it.
|
|
212
|
+
// perField's own bound is shape-only (validators / arrays / objects of the
|
|
213
|
+
// same); the form-type shape check happens at `satisfies Validations<T>`.
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
type NestedFormType = {
|
|
217
|
+
email: string | undefined;
|
|
218
|
+
homeAddress: {
|
|
219
|
+
city: string | undefined;
|
|
220
|
+
postalCode: string | undefined;
|
|
221
|
+
};
|
|
222
|
+
mailingAddress: { city: string | undefined } | undefined;
|
|
223
|
+
drivers: Array<{
|
|
224
|
+
name: string | undefined;
|
|
225
|
+
incidents: Array<{ date: string | undefined }>;
|
|
226
|
+
}>;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
describe('perField — recursive grammar', () => {
|
|
230
|
+
it('preserves markers through nested specs and each-in-each specs', () => {
|
|
231
|
+
const constraints = perField({
|
|
232
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
233
|
+
homeAddress: {
|
|
234
|
+
city: notEmpty('city'),
|
|
235
|
+
postalCode: [notEmpty('postalCode'), minLength('postalCode', 5)],
|
|
236
|
+
},
|
|
237
|
+
mailingAddress: {
|
|
238
|
+
city: notEmpty('mailing city'),
|
|
239
|
+
},
|
|
240
|
+
drivers: {
|
|
241
|
+
each: {
|
|
242
|
+
name: notEmpty('name'),
|
|
243
|
+
incidents: { each: { date: notEmpty('date') } },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}) satisfies Validations<NestedFormType>;
|
|
247
|
+
|
|
248
|
+
type Result = Refine<NestedFormType, typeof constraints>;
|
|
249
|
+
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
|
|
250
|
+
expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
|
|
251
|
+
expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<string>();
|
|
252
|
+
// Nullability survives around the refined interior of a nullable section.
|
|
253
|
+
expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
|
|
254
|
+
{ city: string } | undefined
|
|
255
|
+
>();
|
|
256
|
+
expectTypeOf<Result['drivers'][number]['name']>().toEqualTypeOf<string>();
|
|
257
|
+
expectTypeOf<
|
|
258
|
+
Result['drivers'][number]['incidents'][number]['date']
|
|
259
|
+
>().toEqualTypeOf<string>();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('a pre-built section spec composes as a nested-spec value', () => {
|
|
263
|
+
// Building one section's spec via perField and placing it under the
|
|
264
|
+
// section key in a larger constraints object — the reuse shape phase 4
|
|
265
|
+
// exists to keep working.
|
|
266
|
+
const addressSpec = perField({
|
|
267
|
+
city: notEmpty('city'),
|
|
268
|
+
}) satisfies Validations<NestedFormType['homeAddress']>;
|
|
269
|
+
|
|
270
|
+
const constraints = {
|
|
271
|
+
homeAddress: addressSpec,
|
|
272
|
+
mailingAddress: addressSpec,
|
|
273
|
+
} as const satisfies Validations<NestedFormType>;
|
|
274
|
+
|
|
275
|
+
type Result = Refine<NestedFormType, typeof constraints>;
|
|
276
|
+
expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
|
|
277
|
+
expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<
|
|
278
|
+
string | undefined
|
|
279
|
+
>();
|
|
280
|
+
expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
|
|
281
|
+
{ city: string } | undefined
|
|
282
|
+
>();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rejects non-constraint values at the perField call itself', () => {
|
|
286
|
+
perField({
|
|
287
|
+
// @ts-expect-error a string is not a validator, array, or spec
|
|
288
|
+
email: 'not a validator',
|
|
289
|
+
});
|
|
290
|
+
perField({
|
|
291
|
+
// @ts-expect-error garbage stays rejected at depth (the TS2322
|
|
292
|
+
// anchors at the parent key, same as deep validator-input mismatches)
|
|
293
|
+
homeAddress: {
|
|
294
|
+
city: 42,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('form-type shape errors surface at the satisfies check', () => {
|
|
300
|
+
// perField is form-type-agnostic, so a wrong key passes its bound and
|
|
301
|
+
// is caught where the form type first appears.
|
|
302
|
+
const wrongKey = perField({
|
|
303
|
+
homeAddress: { cityy: notEmpty('cityy') },
|
|
304
|
+
});
|
|
305
|
+
// @ts-expect-error 'cityy' is not a field of homeAddress
|
|
306
|
+
wrongKey satisfies Validations<NestedFormType>;
|
|
307
|
+
|
|
308
|
+
const eachOnObject = perField({
|
|
309
|
+
homeAddress: { each: { city: notEmpty('city') } },
|
|
310
|
+
});
|
|
311
|
+
// @ts-expect-error an `each` spec is not legal on an object field
|
|
312
|
+
eachOnObject satisfies Validations<NestedFormType>;
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
208
316
|
describe('Refine<T, V> — union-typed single constraints (conditional pick)', () => {
|
|
209
317
|
it('narrows a conditionally-picked validator to the union of branch results', () => {
|
|
210
318
|
// Only ONE branch runs at runtime, so the sound result is the UNION of
|