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' }]); }); });