feat: add Primary message to the 3 wizard machines (RD-07)

Each wizard component re-derives the step-boundary decision the reducer
already owns: advance on a middle step, submit on the last step. This
ticket moves that decision into the machine, so RD-08 can replace the
component's guard with one dispatch.

Add a `Primary` message to each Msg union, and export a `primary(s)`
function next to the existing `next`/`submit` pair. `primary` is a
three-line branch that delegates to `next`/`submit` and writes no new
validation. Each machine tests "last step" in its own vocabulary, per
the ticket's Decisions block: `herregistratie` checks `step === 3`,
`intake` checks `currentStep(s) === 'review'`, `registratie` checks
`currentStep(s) === 'controle'`. `Next` and `Submit` stay in every
union and every reducer — `Primary` is purely additive.

Add 3 spec cases per machine (9 total): Primary advances from a
non-final step, Primary submits from the final step, and Primary is a
no-op outside the editing state. Each case also asserts the
equivalence the ticket requires for RD-08's migration:
`reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and
equals `reduce(s, Submit)` at the final step.

Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 17:06:02 +02:00
co-authored by Claude Sonnet 5
parent d9c56b0c24
commit be1fcb4b40
9 changed files with 273 additions and 2 deletions
@@ -17,6 +17,7 @@ import {
setField,
prefillAdres,
submit,
primary,
resolve,
reduce,
} from './registratie-wizard.machine';
@@ -248,6 +249,26 @@ describe('submit', () => {
});
});
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toBeroepStepWithDiploma(); // Invullen, beroep step — not the final step
expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep');
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(currentStep(expectTag(primary(s), 'Invullen'))).toBe('controle');
});
it('Primary submits from the final step', () => {
const s = toControleStep(); // Invullen, controle step — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Indienen');
});
it('Primary is a no-op from a non-editing state', () => {
const indienen = toIndienen();
expect(primary(indienen)).toBe(indienen);
});
});
describe('reduce (message-driven happy path)', () => {
it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard.
@@ -287,6 +287,13 @@ export function submit(s: RegistratieState): RegistratieState {
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
}
/** The primary button's action: advance, or submit from the controle step.
No-op outside Invullen — this is the one decision `onPrimary()` used to make. */
export function primary(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
return currentStep(s) === 'controle' ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Invullen only). */
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
if (s.tag !== 'Invullen') return s;
@@ -312,6 +319,7 @@ export type RegistratieMsg =
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
@@ -342,6 +350,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Primary':
return primary(s);
case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed':