@structuralists/scaffolding 0.13.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.
@@ -47,6 +47,9 @@ const preview: Preview = {
47
47
  options: {
48
48
  storySort: {
49
49
  order: [
50
+ // Demo: polished illustrations of what can be built — first thing
51
+ // a visitor sees.
52
+ 'Demo',
50
53
  // Showcase: how the pieces fit together.
51
54
  'Composition',
52
55
  // Main library, alphabetical.
package/AGENTS.md CHANGED
@@ -63,6 +63,17 @@ The Storybook toolbar has a Theme control (paintbrush icon) listing all six
63
63
  leaves the attribute unset (prefers-color-scheme fallback) and is the initial
64
64
  value, so the vitest story run is unaffected by the toggle.
65
65
 
66
+ ## The Demo section
67
+
68
+ The first Storybook section (`'Demo'` in `storySort` in
69
+ `.storybook/preview.tsx`) holds polished showcase compositions — full
70
+ mini-apps built entirely from the library's public pieces, not component API
71
+ docs. Demo stories live in `src/storybook/` (like `Composition.stories.tsx`)
72
+ with titles of the form `Demo/<Name>`, keep their datasets deterministic (no
73
+ `Date.now()`/randomness at render), and double as integration tests via
74
+ `play` functions. Don't add new public components for a demo; compose from
75
+ what exists.
76
+
66
77
  ## Testing
67
78
 
68
79
  ### Story tests (the main gate)
package/README.md CHANGED
@@ -53,7 +53,7 @@ export const SignupCard = () => {
53
53
  };
54
54
  ```
55
55
 
56
- Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
56
+ Browse every component (with live controls) in the Storybook: `bun run storybook`. The sidebar opens with a **Demo** section — full mini-apps (like a CRUD Team Directory) composed entirely from the library's public pieces, showing what an app scaffolded with it looks like. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
57
57
 
58
58
  ## Consumer notes
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -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
- currently admits only the leaf forms (a validator or validator array per
130
- key) extending it to pre-built nested/`each` specs is plan phase 4
131
- (composition hardening); until then, write structural constraints inline in
132
- the `useFormState` call.
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: **in progress** — order of operations below. The baseline (flat
4
- `Validations<T>`, one validator per key, shallow `Refine`) is **merged to main**
5
- (PR #12, squashed; released as 0.5.0).
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. *current*
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 fixtures (list-in-object-in-list),
347
- `perField` still the entry point for pre-built specs, docs
348
- (forms/CLAUDE.md) updated to the new grammar.
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
+ });