Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md
T
ehoandClaude Opus 5 dfc6c419f4 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>
2026-08-28 13:26:12 +02:00

14 KiB

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