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>
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { ok, err } from '@shared/kernel/fp';
|
|
import { given } from '@shared/testing/machine';
|
|
import { expectTag } from '@shared/testing/expect-tag';
|
|
import {
|
|
RegistratieState,
|
|
STEPS,
|
|
initial,
|
|
currentStep,
|
|
next,
|
|
back,
|
|
gaNaarStap,
|
|
kiesDiploma,
|
|
kiesHandmatig,
|
|
declareerBeroep,
|
|
setAntwoord,
|
|
setField,
|
|
prefillAdres,
|
|
submit,
|
|
primary,
|
|
resolve,
|
|
reduce,
|
|
} from './registratie-wizard.machine';
|
|
import { givenRegistratieWizard } from './registratie-wizard.testing';
|
|
|
|
/**
|
|
* Every fixture below is built by replaying real `RegistratieMsg`s through the
|
|
* real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState`
|
|
* literal. Each helper reaches a named point in the wizard one transition at a
|
|
* time, so a spec can only assert on a state the reducer can actually produce.
|
|
*/
|
|
const toAdresValid = (): RegistratieState =>
|
|
givenRegistratieWizard(
|
|
{
|
|
tag: 'PrefillAdres',
|
|
straat: 'Lange Voorhout 9',
|
|
postcode: '2514 EA',
|
|
woonplaats: 'Den Haag',
|
|
},
|
|
{ tag: 'SetCorrespondentie', value: 'post' },
|
|
);
|
|
|
|
const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet
|
|
|
|
const toBeroepStepWithDiploma = (): RegistratieState =>
|
|
reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
|
|
|
|
const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2
|
|
|
|
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
|
|
|
|
// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a
|
|
// field or choosing a diploma is never gated by cursor position, so this is a
|
|
// real, reachable 'Invullen' state at cursor 0 — matching what `submit()`
|
|
// (which validates the whole draft regardless of cursor) is exercised against
|
|
// in the tests below.
|
|
const toFullDraftAtCursor0 = (): RegistratieState =>
|
|
givenRegistratieWizard(
|
|
{
|
|
tag: 'PrefillAdres',
|
|
straat: 'Lange Voorhout 9',
|
|
postcode: '2514 EA',
|
|
woonplaats: 'Den Haag',
|
|
},
|
|
{ tag: 'SetCorrespondentie', value: 'post' },
|
|
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
|
|
);
|
|
|
|
describe('STEPS (fixed)', () => {
|
|
it('always has the same three steps', () => {
|
|
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
|
|
});
|
|
});
|
|
|
|
describe('navigation', () => {
|
|
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
|
|
const s = expectTag(next(initial), 'Invullen');
|
|
expect(s.tag).toBe('Invullen');
|
|
expect(s.cursor).toBe(0);
|
|
expect(s.errors.straat).toBeTruthy();
|
|
expect(s.errors.correspondentie).toBeTruthy();
|
|
});
|
|
|
|
it('Next advances once the adres step is valid', () => {
|
|
const s = expectTag(next(toAdresValid()), 'Invullen');
|
|
expect(s.cursor).toBe(1);
|
|
expect(currentStep(s)).toBe('beroep');
|
|
});
|
|
|
|
it('requires a valid e-mail only when the channel is email', () => {
|
|
const withEmailChannel = givenRegistratieWizard(
|
|
{
|
|
tag: 'PrefillAdres',
|
|
straat: 'Lange Voorhout 9',
|
|
postcode: '2514 EA',
|
|
woonplaats: 'Den Haag',
|
|
},
|
|
{ tag: 'SetCorrespondentie', value: 'email' },
|
|
);
|
|
const bad = expectTag(next(withEmailChannel), 'Invullen');
|
|
expect(bad.errors.email).toBeTruthy();
|
|
const good = expectTag(
|
|
next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })),
|
|
'Invullen',
|
|
);
|
|
expect(good.cursor).toBe(1);
|
|
});
|
|
|
|
it('beroep step requires a chosen diploma', () => {
|
|
const noDiploma = expectTag(next(toBeroepStep()), 'Invullen');
|
|
expect(noDiploma.cursor).toBe(1);
|
|
expect(noDiploma.errors.diploma).toBeTruthy();
|
|
const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen');
|
|
expect(withDiploma.cursor).toBe(2);
|
|
});
|
|
|
|
it('Back never goes below the first step and preserves the draft', () => {
|
|
expect(back(initial)).toBe(initial);
|
|
const s = expectTag(back(toControleStep()), 'Invullen');
|
|
expect(s.cursor).toBe(1);
|
|
expect(s.draft.beroep).toBe('Arts');
|
|
});
|
|
|
|
it('GaNaarStap only jumps backwards', () => {
|
|
expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0);
|
|
expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
|
|
});
|
|
});
|
|
|
|
describe('adres origin (BRP vs handmatig)', () => {
|
|
it('prefillAdres flags origin brp', () => {
|
|
const s = expectTag(
|
|
prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
|
|
'Invullen',
|
|
);
|
|
expect(s.draft.adresHerkomst).toBe('brp');
|
|
expect(s.draft.straat).toBe('Lange Voorhout 9');
|
|
});
|
|
|
|
it('editing a prefilled address field flips origin to handmatig', () => {
|
|
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
|
const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen');
|
|
expect(edited.draft.adresHerkomst).toBe('handmatig');
|
|
});
|
|
|
|
it('typing an address with no BRP prefill yields handmatig', () => {
|
|
const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen');
|
|
expect(s.draft.adresHerkomst).toBe('handmatig');
|
|
});
|
|
|
|
it('editing the e-mail field does not change the address origin', () => {
|
|
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
|
const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen');
|
|
expect(edited.draft.adresHerkomst).toBe('brp');
|
|
});
|
|
|
|
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
|
const manualAdres = givenRegistratieWizard(
|
|
{ tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' },
|
|
{ tag: 'SetField', key: 'postcode', value: '1234 AB' },
|
|
{ tag: 'SetField', key: 'woonplaats', value: 'Utrecht' },
|
|
{ tag: 'SetCorrespondentie', value: 'post' },
|
|
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
|
|
);
|
|
const indienen = expectTag(submit(manualAdres), 'Indienen');
|
|
expect(indienen.data.adresHerkomst).toBe('handmatig');
|
|
});
|
|
});
|
|
|
|
describe('kiesDiploma', () => {
|
|
it('derives the beroep from the chosen diploma and flags origin duo', () => {
|
|
const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen');
|
|
expect(s.draft.diplomaId).toBe('d9');
|
|
expect(s.draft.beroep).toBe('Verpleegkundige');
|
|
expect(s.draft.diplomaHerkomst).toBe('duo');
|
|
});
|
|
});
|
|
|
|
describe('policy questions (geldigheidsvragen)', () => {
|
|
it('a diploma with questions blocks Next until they are answered', () => {
|
|
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
|
const blocked = expectTag(next(s), 'Invullen');
|
|
expect(blocked.cursor).toBe(1);
|
|
expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy();
|
|
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
|
expect(expectTag(next(s), 'Invullen').cursor).toBe(2);
|
|
});
|
|
|
|
it('validateAll keeps only the answers to the questions that applied', () => {
|
|
// DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any
|
|
// diploma was chosen. That combination cannot occur in the real reducer —
|
|
// advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be
|
|
// set. Replayed here at cursor 1 instead; submit() validates the whole draft
|
|
// regardless of cursor, so the assertion below is unaffected.
|
|
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
|
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
|
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
|
|
const done = expectTag(submit(s), 'Indienen');
|
|
expect(done.data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
|
|
});
|
|
});
|
|
|
|
describe('manual diploma fallback', () => {
|
|
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
|
|
|
|
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
|
|
const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen');
|
|
expect(s.draft.diplomaHerkomst).toBe('handmatig');
|
|
expect(s.draft.beroep).toBeUndefined();
|
|
expect(s.draft.vraagIds).toEqual(maxIds);
|
|
});
|
|
|
|
it('requires a declared beroep + all maximal questions before submit', () => {
|
|
// DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination
|
|
// as above. Replayed at cursor 1; submit() is cursor-agnostic.
|
|
let s = kiesHandmatig(toBeroepStep(), maxIds);
|
|
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
|
|
s = declareerBeroep(s, 'Fysiotherapeut');
|
|
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
|
|
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
|
|
const done = expectTag(submit(s), 'Indienen');
|
|
expect(done.data.diplomaHerkomst).toBe('handmatig');
|
|
expect(done.data.beroep).toBe('Fysiotherapeut');
|
|
});
|
|
});
|
|
|
|
describe('submit', () => {
|
|
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
|
|
expect(submit(toAdresValid()).tag).toBe('Invullen');
|
|
});
|
|
|
|
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
|
|
const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen');
|
|
expect(good.data.beroep).toBe('Arts');
|
|
expect(good.data.adres.postcode).toBe('2514 EA');
|
|
expect(good.data.adresHerkomst).toBe('brp');
|
|
});
|
|
|
|
it('resolve maps Indienen to Ingediend with the referentie', () => {
|
|
const ingediend = expectTag(
|
|
resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')),
|
|
'Ingediend',
|
|
);
|
|
expect(ingediend.referentie).toBe('BIG-2026-001');
|
|
});
|
|
|
|
it('resolve maps Indienen to Mislukt on a failed submit', () => {
|
|
expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt');
|
|
});
|
|
});
|
|
|
|
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.
|
|
// When the adres is prefilled, correspondentie chosen, and Next dispatched...
|
|
const s = toBeroepStep();
|
|
|
|
// Then the wizard advances to the beroep step.
|
|
expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep');
|
|
});
|
|
|
|
it('diploma chosen, Next advances from beroep to controle', () => {
|
|
// Given a wizard on the beroep step.
|
|
// When a diploma is chosen and Next dispatched...
|
|
const s = toControleStep();
|
|
|
|
// Then the wizard advances to the controle step.
|
|
expect(currentStep(expectTag(s, 'Invullen'))).toBe('controle');
|
|
});
|
|
|
|
it('Submit moves a complete Invullen draft to Indienen', () => {
|
|
// Given a wizard on the controle step with a complete, valid draft.
|
|
// When Submit is dispatched...
|
|
const s = toIndienen();
|
|
|
|
// Then the wizard moves to Indienen.
|
|
expect(s.tag).toBe('Indienen');
|
|
});
|
|
|
|
it('SubmitConfirmed moves Indienen to Ingediend', () => {
|
|
// Given a wizard mid-submit (Indienen).
|
|
// When SubmitConfirmed arrives with a referentie...
|
|
const s = reduce(toIndienen(), { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
|
|
|
|
// Then the wizard reaches Ingediend.
|
|
expect(s.tag).toBe('Ingediend');
|
|
});
|
|
|
|
it('SubmitFailed moves Indienen to Mislukt', () => {
|
|
const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
|
|
tag: 'SubmitFailed',
|
|
error: 'boom',
|
|
});
|
|
expect(s.tag).toBe('Mislukt');
|
|
});
|
|
|
|
it('Retry returns Mislukt to Indienen with the same data', () => {
|
|
const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
|
|
tag: 'SubmitFailed',
|
|
error: 'boom',
|
|
});
|
|
const s = expectTag(reduce(mislukt, { tag: 'Retry' }), 'Indienen');
|
|
expect(s.data.beroep).toBe('Arts');
|
|
});
|
|
});
|
|
|
|
describe('inline document upload (beroep step)', () => {
|
|
const cat = {
|
|
categoryId: 'diploma',
|
|
label: 'Diploma',
|
|
description: '',
|
|
required: true,
|
|
acceptedTypes: [],
|
|
maxSizeMb: 10,
|
|
multiple: false,
|
|
allowPostDelivery: true,
|
|
};
|
|
|
|
it('routes Upload messages through the upload reducer', () => {
|
|
const s = expectTag(
|
|
reduce(toFullDraftAtCursor0(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
|
}),
|
|
'Invullen',
|
|
);
|
|
expect(s.upload.categories).toHaveLength(1);
|
|
});
|
|
|
|
it('blocks the beroep step until a required category is satisfied', () => {
|
|
let s = reduce(toBeroepStepWithDiploma(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
|
});
|
|
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
|
|
let invullenState = expectTag(s, 'Invullen');
|
|
expect(currentStep(invullenState)).toBe('beroep');
|
|
expect(invullenState.errors.documenten).toBeTruthy();
|
|
// choosing post delivery satisfies the requirement
|
|
s = reduce(s, {
|
|
tag: 'Upload',
|
|
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
|
});
|
|
s = reduce(s, { tag: 'Next' });
|
|
invullenState = expectTag(s, 'Invullen');
|
|
expect(currentStep(invullenState)).toBe('controle');
|
|
});
|
|
|
|
it('includes delivery refs in the submitted data', () => {
|
|
let s = reduce(toFullDraftAtCursor0(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
|
});
|
|
s = reduce(s, {
|
|
tag: 'Upload',
|
|
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
|
});
|
|
const done = expectTag(submit(s), 'Indienen');
|
|
expect(done.data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
|
|
});
|
|
});
|