Merge RB-31 — replay real messages in 4 machine specs

ADR-C-010: intake, registratie-wizard, besluit and brief machine specs
hand-rolled a state literal, three of them hardcoding errors: {} by hand
instead of running real Msgs through the real reduce (ADR-0006 §2). intake
now shares the existing intake.testing.ts with the acceptance spec instead
of ignoring it; the other three each get a *.testing.ts one-liner.

Found real drift: two registratie-wizard tests asserted a cursor-2 state
reached before any diploma was chosen, which the real reducer cannot
produce (advancing past beroep requires a diploma already set). Replayed at
cursor 1 instead; submit() validates the whole draft regardless of cursor,
so the assertions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
This commit is contained in:
eho
2026-08-28 13:27:16 +02:00
9 changed files with 401 additions and 174 deletions
@@ -1,12 +1,7 @@
import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
import { reduce, initial } from './besluit.machine';
import { givenBesluit } from './besluit.testing';
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
const s = reduce(initial, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
const s = reduce(editingAfwijzen, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const editingGoedkeuren = givenBesluit({
tag: 'SetField',
key: 'besluit',
value: 'Goedkeuren',
});
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
const editingAfwijzenWithToelichting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
);
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './besluit.machine';
/** Replay real `BesluitMsg`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 givenBesluit = given(reduce, initial);
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine';
import { givenBrief } from './brief.testing';
const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true },
@@ -64,15 +65,15 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true,
};
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
// instead of hand-assembling the 'loaded' state directly.
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
givenBrief({
tag: 'BriefLoaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
const st = givenBrief({
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
});
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './brief.machine';
/** Replay real `BriefMsg`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 givenBrief = given(reduce, initial);
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { expectTag } from '@shared/testing/expect-tag';
import {
Answers,
initial,
STEPS,
lageUren,
@@ -15,14 +14,7 @@ import {
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
import { givenIntake } from './intake.testing';
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
@@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => {
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect(
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
});
it('reveals the scholing question only when NL-hours are below the threshold', () => {
@@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => {
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
const lowThresholdState = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
{ tag: 'SetPolicy', scholingThreshold: 2000 },
);
const lowThreshold = submit(lowThresholdState);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
});
@@ -65,18 +61,21 @@ describe('navigation', () => {
});
it('Next advances once the step is valid', () => {
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
const s = expectTag(next(domestic), 'Answering');
expect(s.cursor).toBe(1);
expect(currentStep(s)).toBe('werk');
});
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const atWerk = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'SetAnswer', key: 'land', value: 'België' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
);
const edited = expectTag(
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
}),
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
'Answering',
);
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
@@ -87,57 +86,86 @@ describe('navigation', () => {
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
const submitting = submit(atReview);
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = expectTag(submit(answering(complete)), 'Submitting');
const badPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
);
expect(submit(badPunten).tag).toBe('Answering');
const good = expectTag(submit(highUren), 'Submitting');
expect(good.data.uren).toBe(4160);
expect(good.data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
'Answering',
const scholingJaNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
expect(missing.errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
const scholingNee = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
);
expect(submit(scholingNee).tag).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = expectTag(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
),
'Submitting',
const lowUrenNoScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
);
const noScholing = submit(lowUrenNoScholing);
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const lowUrenWithScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
);
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
expect(withScholing.data.aanvullendeScholing).toBe(true);
expect(withScholing.data.punten).toBe(200);
});
@@ -145,38 +173,36 @@ describe('submit', () => {
it('does not require punten for a hidden question (WP-69 §6)', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either.
const good = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })),
'Submitting',
const staleScholingNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
expect(good.data.aanvullendeScholing).toBeUndefined();
});
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
// Same stale answer, but this time punten was also filled in while uren was low.
const good = expectTag(
submit(
answering({
buitenlandGewerkt: 'nee',
uren: '1500',
scholingGevolgd: 'ja',
punten: '150',
}),
),
'Submitting',
const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '150' },
);
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
expect(good.data.aanvullendeScholing).toBeUndefined();
expect(good.data.punten).toBeUndefined();
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
@@ -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,
});
const validAdres = {
/**
* 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',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
},
{ 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', () => {
@@ -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);
@@ -132,7 +132,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | SM | Low | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **done** |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **done** |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** |
@@ -0,0 +1,155 @@
# RB-31 — replay real messages in the four hand-rolling machine specs
Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 ·
`99-backlog.md` RB-31
RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)`
replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of
running real `Msg`s through the real `reduce`"). This is a fixture-construction change
only. No `*.machine.ts` production file was touched.
## What was wrong
Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state
with a local object-literal helper instead of replaying messages:
- `intake.machine.spec.ts``answering(answers, cursor, scholingThreshold)` hardcoded
`errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to
it and was already correct, but was imported only by `intake.acceptance.spec.ts`.
- `registratie-wizard.machine.spec.ts``invullen(draft, cursor)` hardcoded `errors: {}`
and `upload: initialUpload`.
- `besluit.machine.spec.ts``editingWith(besluit, toelichting)` hardcoded `errors: {}`.
- `brief.machine.spec.ts``loaded(status, sections)` built the `'loaded'` tag object
directly (no `errors` field on this union, so this one did not hardcode `errors: {}`,
but it still skipped the reducer).
## What changed
| File | Change |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. |
| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. |
| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). |
| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. |
| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). |
| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. |
| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open``implemented`. |
## Message sequence used per machine
### `intake.machine.spec.ts`
Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden
`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy`
through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new
generic wrapper was added — the finding's own resolution is "wire the spec to
`givenIntake`", not "invent a second helper"). Representative sequences:
- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`.
- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`.
- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`,
`SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid ->
cursor 1), then the edit under test.
- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with
`SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2).
No drift found here: `intake.testing.ts` already existed correctly, and every one of the
nine cursor/threshold combinations the old literal used turned out to be reachable by a
real message sequence.
### `registratie-wizard.machine.spec.ts`
- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')`
— cursor 0, matches the old `invullen(validAdres)`.
- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches
`invullen(validAdres, 1)`.
- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))`
cursor 1, diploma set. Matches `invullen(validDraft, 1)`.
- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches
`invullen(validDraft, 2)`.
- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`,
never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/
`SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before
ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft.
- `invullen({})` (five call sites) is exactly the machine's own `initial` value
(`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`)
— replaced with `initial` directly, no message needed.
### `besluit.machine.spec.ts`
- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced
with `initial` directly.
- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`.
- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`.
- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})`
composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`.
### `brief.machine.spec.ts`
- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`.
This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly
`{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}`
— the same three fields the old literal built by hand, with the same values. No drift.
## Drift found
Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the
real reducer cannot reach:
- `'validateAll keeps only the answers to the questions that applied'` built
`invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already
at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay:
advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass,
which requires `diplomaId` and `beroep` to already be set. The literal encoded a state
the reducer can never produce.
- `'requires a declared beroep + all maximal questions before submit'` had the same
problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep`
`undefined` — again a cursor-2 state that could never have been reached via `Next`.
In both cases the cursor value is not actually load-bearing for the test: `submit()`
calls `validateAll(s.draft, s.upload)`, which validates every step regardless of
`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent
(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see
rb-31.md)` comment at each site. No assertion changed — both tests still check the same
`submit(...)` outcome on the same field values; only the now-irrelevant cursor number
in the starting fixture moved from an unreachable 2 to a reachable 1.
No other named state, across any of the four machines, turned out to be unreachable.
## Beyond the letter of the finding
`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second,
separate `BriefState` literal inline (not through the `loaded(...)` helper the finding
cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list.
Since it sits inside one of the four files already being brought into line, and the fix
is the identical one-line `BriefLoaded` replay, it was converted too rather than left as
a residual violation in a file this ticket otherwise fixed. No other spec, in any other
file, was touched.
## `intake.acceptance.spec.ts` — confirmed unaffected
`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec
still imports and uses `givenIntake` exactly as before; it was not read or edited by
this ticket beyond confirming (by running it) that it still passes.
## Verification
- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes
`intake.machine.spec.ts`, `intake.acceptance.spec.ts`,
`registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp
spec, unmodified ones included).
- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes
`besluit.machine.spec.ts`).
- `npx eslint` on all seven touched/added files: clean.
- `npx prettier --check` on all seven touched/added files: clean (one file needed
`--write` once, then verified clean).
- `npm run ci`: see the commit message / session report for the exit code and step
count.
## What this ticket did not touch
No `*.machine.ts` reducer or production domain file was changed — every fixture change
is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed
above. No other machine spec (including `change-request.machine.spec.ts`, which the
finding notes already honours the idiom inline) was touched.