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:
@@ -8,6 +8,7 @@ import {
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
primary,
|
||||
resolve,
|
||||
reduce,
|
||||
WizardState,
|
||||
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary', () => {
|
||||
it('Primary advances to the next step from a non-final step', () => {
|
||||
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
|
||||
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
|
||||
expect(expectTag(primary(s), 'Editing').step).toBe(3);
|
||||
});
|
||||
|
||||
it('Primary submits from the final step', () => {
|
||||
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
|
||||
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
|
||||
expect(primary(s).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Primary is a no-op from a non-editing state', () => {
|
||||
const submitting = submit(toStep3('4160', '200'));
|
||||
expect(primary(submitting)).toBe(submitting);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven)', () => {
|
||||
it('drives the full happy path via messages', () => {
|
||||
let s: WizardState = initial;
|
||||
|
||||
@@ -121,6 +121,13 @@ export function submit(s: WizardState): WizardState {
|
||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
||||
}
|
||||
|
||||
/** The primary button's action: advance, or submit from the last step. No-op
|
||||
outside Editing — this is the one decision `onPrimary()` used to make. */
|
||||
export function primary(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return s.step === 3 ? submit(s) : next(s);
|
||||
}
|
||||
|
||||
/** Route an upload sub-message through the pure upload reducer (Editing only). */
|
||||
export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
@@ -152,6 +159,7 @@ export type WizardMsg =
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Primary' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
@@ -170,6 +178,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
||||
return gaNaarStap(s, m.step);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Primary':
|
||||
return primary(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
primary,
|
||||
resolve,
|
||||
reduce,
|
||||
IntakeState,
|
||||
@@ -207,6 +208,33 @@ describe('submit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary', () => {
|
||||
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
|
||||
// uren high enough to skip the scholing question.
|
||||
const highUren = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
);
|
||||
|
||||
it('Primary advances to the next step from a non-final step', () => {
|
||||
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
|
||||
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
|
||||
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('Primary submits from the final step', () => {
|
||||
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
|
||||
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
|
||||
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
|
||||
expect(primary(atReview).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Primary is a no-op from a non-editing state', () => {
|
||||
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
|
||||
expect(primary(submitting)).toBe(submitting);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives abroad branch end to end', () => {
|
||||
let s: IntakeState = initial;
|
||||
|
||||
@@ -200,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
/** The primary button's action: advance, or submit from the review step. No-op
|
||||
outside Answering — this is the one decision `onPrimary()` used to make. */
|
||||
export function primary(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
return currentStep(s) === 'review' ? submit(s) : next(s);
|
||||
}
|
||||
|
||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok
|
||||
@@ -213,6 +220,7 @@ export type IntakeMsg =
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Primary' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
@@ -231,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
return gaNaarStap(s, m.cursor);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Primary':
|
||||
return primary(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user