refactor(specs): replay real messages in 4 machine specs (RB-31)

Four machine specs built their starting state with a hand-rolled object
literal instead of replaying real Msgs through the real reduce, the
exact anti-pattern ADR-0006 section 2 forbids. Three of the four also
hardcoded errors: {}, a shape the reducer might never actually produce.

intake.machine.spec.ts now imports the existing givenIntake from
intake.testing.ts (previously used only by intake.acceptance.spec.ts).
Three new one-line *.testing.ts files export the same given(reduce,
initial) wrapper for registratie-wizard, besluit, and brief. Every old
literal helper (answering, invullen, editingWith, loaded) is replaced
by a message replay that reaches the same state.

Two tests in registratie-wizard.machine.spec.ts asserted a cursor value
the real reducer cannot reach (cursor 2 with no diploma chosen yet,
which requires a diploma to already be set). Both are re-pointed at the
reachable cursor-1 equivalent; submit() validates the whole draft
regardless of cursor, so no assertion changed. Recorded in
implementation/rb-31.md, not worked around.

No *.machine.ts reducer was touched. All four specs pass; npm run ci
is green (lint, typecheck, dep:check, format, tokens, seam, all four
test suites, both app builds, backend 293/293, api-client drift).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-28 13:26:12 +02:00
co-authored by Claude Opus 5
parent 03c6e09306
commit dfc6c419f4
9 changed files with 435 additions and 208 deletions
@@ -1,9 +1,8 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/domain/upload.machine';
import { given } from '@shared/testing/machine';
import { expectTag } from '@shared/testing/expect-tag';
import {
Draft,
RegistratieState,
STEPS,
initial,
@@ -21,28 +20,50 @@ import {
resolve,
reduce,
} from './registratie-wizard.machine';
import { givenRegistratieWizard } from './registratie-wizard.testing';
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
tag: 'Invullen',
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
upload: initialUpload,
});
/**
* 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 validAdres = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
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', () => {
@@ -60,46 +81,55 @@ describe('navigation', () => {
});
it('Next advances once the adres step is valid', () => {
const s = expectTag(next(invullen(validAdres)), 'Invullen');
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 bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen');
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(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })),
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(invullen(validAdres, 1)), 'Invullen');
const noDiploma = expectTag(next(toBeroepStep()), 'Invullen');
expect(noDiploma.cursor).toBe(1);
expect(noDiploma.errors.diploma).toBeTruthy();
const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen');
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(invullen(validDraft, 2)), 'Invullen');
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(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
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(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
'Invullen',
);
expect(s.draft.adresHerkomst).toBe('brp');
@@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => {
});
it('editing a prefilled address field flips origin to handmatig', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
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(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen');
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(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
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 s = submit(
invullen({
straat: 'Kerkstraat 1',
postcode: '1234 AB',
woonplaats: 'Utrecht',
correspondentie: 'post',
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
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(s, 'Indienen');
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(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen');
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');
@@ -152,7 +177,7 @@ describe('kiesDiploma', () => {
describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => {
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
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();
@@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => {
});
it('validateAll keeps only the answers to the questions that applied', () => {
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
// 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');
@@ -173,14 +203,16 @@ 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(invullen(validAdres, 1), maxIds), 'Invullen');
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', () => {
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
// 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
@@ -193,11 +225,11 @@ describe('manual diploma fallback', () => {
describe('submit', () => {
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen');
expect(submit(toAdresValid()).tag).toBe('Invullen');
});
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
const good = expectTag(submit(invullen(validDraft)), 'Indienen');
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');
@@ -205,43 +237,18 @@ describe('submit', () => {
it('resolve maps Indienen to Ingediend with the referentie', () => {
const ingediend = expectTag(
resolve(submit(invullen(validDraft)), ok('BIG-2026-001')),
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(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt');
});
});
describe('reduce (message-driven happy path)', () => {
// Each helper replays real messages through the real reducer up to the named
// point — no hand-assembled state literal — so each test below Givens its own
// starting point independently, one transition at a time.
const toBeroepStep = (): RegistratieState => {
let s: RegistratieState = initial;
s = reduce(s, {
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
});
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
return reduce(s, { tag: 'Next' });
};
const toControleStep = (): RegistratieState => {
const s = reduce(toBeroepStep(), {
tag: 'KiesDiploma',
diplomaId: 'd1',
beroep: 'Arts',
vraagIds: [],
});
return reduce(s, { tag: 'Next' });
};
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
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...
@@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('SubmitFailed moves Indienen to Mislukt', () => {
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('Retry returns Mislukt to Indienen with the same data', () => {
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => {
it('routes Upload messages through the upload reducer', () => {
const s = expectTag(
reduce(invullen(validDraft), {
reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
}),
@@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => {
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), {
let s = reduce(toBeroepStepWithDiploma(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => {
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), {
let s = reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './registratie-wizard.machine';
/** Replay real `RegistratieMsg`s through the real `reduce`, starting from
`initial`. Pure TS only (no Angular) — domain/ stays framework-free
(dependency-cruiser `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenRegistratieWizard = given(reduce, initial);