# 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.