docs(test): make Given/When/Then the default BDD structure (WP-71)

bdd.mdx previously banned "Given/When/Then ceremony" outright, which
directly contradicted WP-70's own acceptance tests (Acceptance/
BesluitLifecycleTests.cs already used // Given/When/Then comments) and
the backend's organically-evolved PascalCase_snake_sentence convention,
which the doc gave zero guidance for. Reverses that rule: every test is
now structured Given -> When -> Then, with a genuinely empty phase
omitted rather than faked; present-tense declarative naming and the
one-behaviour-per-test rule are unchanged. ADR-0006 gets a cross-reference
so both documents agree everywhere, not just in acceptance tests.

Also closes out the doc's other named-but-unenforced rules found by the
audit: fixes the 5 files asserting rendered $localize copy instead of
the underlying tag/message-id (the compliant pattern already existed in
werkvoorraad-item-view.spec.ts), splits the multi-behaviour titles the
doc itself calls a smell (";", "and", "/"), and fixes bdd.mdx's own false
citation of registratie-wizard.machine.spec.ts as "one transition per
test" by actually splitting that test into one-transition-per-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 20:25:30 +02:00
co-authored by Claude Sonnet 5
parent 306d002221
commit 3652ff8d3f
9 changed files with 247 additions and 34 deletions
@@ -27,32 +27,49 @@ describe('statusLabel', () => {
describe('detailRows', () => {
it('lists soort/status/referentie/eigenaar/ingediend', () => {
// Given a case InBehandeling.
// When its detail rows are derived...
const rows = detailRows({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
const values = rows.map((r) => r.value);
// Then the type label (via TYPE_LABELS, not a literal), the reference, and the
// owner all appear as rows.
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R1');
expect(values).toContain(base.owner);
expect(rows.length).toBe(5);
});
it('adds a reden row for Afgewezen and MeerInfoGevraagd only', () => {
// One behaviour ("a reden row is added exactly for the two statuses that carry a
// reden") checked as a truth table over three statuses — kept together per
// bdd.mdx's "truth-table of one rule" exception, rather than split apart.
//
// `reden` itself is raw domain data (a free-text field on the status union, not an
// enum), passed through `detailRows` unchanged and never wrapped by `$localize` —
// there is no reason-code/tag to assert on instead; the value under test IS the
// string the Given supplied, so checking it reappears in the Then is a
// pass-through check, not a translated-copy assertion.
it('a reden row is present only for Afgewezen and MeerInfoGevraagd', () => {
// Given three cases: rejected, more-info-requested, and approved.
// When their detail rows are derived...
const afgewezen = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
});
expect(afgewezen.length).toBe(6);
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
const meerInfo = detailRows({
...base,
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
});
expect(meerInfo.length).toBe(6);
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
// Then only the rejected and more-info-requested cases gain a reden row (carrying
// the reason through unchanged); the approved case does not.
expect(afgewezen.length).toBe(6);
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
expect(meerInfo.length).toBe(6);
expect(goedgekeurd.length).toBe(5);
});
});
@@ -31,7 +31,13 @@ describe('parseBeoordelingStatus', () => {
);
});
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
// One behaviour ("rejects a malformed status") checked over several malformed
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
it('rejects a malformed status', () => {
// Given a status that is missing entirely, has an unknown tag, or is missing a
// required field for its tag.
// When each is parsed...
// Then all are rejected.
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
@@ -50,7 +56,13 @@ describe('parseBeoordelingView', () => {
expect(r.value.canBesluiten).toBe(true);
});
it('rejects a missing owner, bad type, missing decisions, and non-objects', () => {
// One behaviour ("rejects a malformed view") checked over several malformed
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
it('rejects a malformed view', () => {
// Given a view that is a non-object, missing the owner, has an unknown aanvraag
// type, or is missing decisions.
// When each is parsed...
// Then all are rejected.
expect(parseBeoordelingView(null).ok).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
@@ -4,7 +4,7 @@ import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store';
@@ -164,25 +164,64 @@ async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore
}
describe('BriefStore undo/redo history', () => {
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
it('starts with nothing to undo', async () => {
// Given a freshly loaded brief.
// When no edit has happened yet...
const store = await loadedStore();
expect(store.canUndo()).toBe(false);
// Then there is nothing to undo.
expect(store.canUndo()).toBe(false);
});
it('records an edit and makes it undoable', async () => {
// Given a loaded brief with one block.
const store = await loadedStore();
// When a block is removed...
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
// Then the block is gone and the edit becomes undoable.
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
expect(store.canUndo()).toBe(true);
});
it('undo reverts the edit and enables redo', async () => {
// Given a brief with one recorded edit (a removed block).
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
// When the edit is undone...
store.undo();
// Then the block is back, and redo becomes available.
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
expect(store.canRedo()).toBe(true);
});
it('redo reapplies the undone edit', async () => {
// Given an edit that was undone.
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
store.undo();
// When it is redone...
store.redo();
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
// Then the edit is reapplied.
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
});
it('a no-op edit does not clear the redo future', async () => {
// Given an undone edit, with redo available.
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
store.undo(); // back to 1 block, redo available
// When an edit that changes nothing (an unknown block) is applied...
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
// Then the no-op leaves no dead history step — redo is still available.
expect(store.canRedo()).toBe(true);
});
it('a new edit clears the redo future', async () => {
@@ -268,12 +307,12 @@ describe('BriefStore.previewLetter', () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false,
error: 'De voorvertoning kon niet worden geopend.',
error: PREVIEW_FAILED,
});
await store.previewLetter();
expect(open).not.toHaveBeenCalled();
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
expect(store.lastError()).toBe(PREVIEW_FAILED);
});
});
@@ -4,7 +4,9 @@ import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
/** Exported so specs can assert against the same message id instead of retyping the
Dutch sentence (see `brief.store.spec.ts`'s `previewLetter` failure test). */
export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
/**
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
@@ -77,15 +77,20 @@ describe('intake acceptance journeys', () => {
});
});
it('buitenland gewerkt requires land + hours abroad, and gaNaarStap corrects an earlier answer', () => {
it('buitenland gewerkt requires land and hours abroad before advancing', () => {
// Given a user who says they worked abroad.
// When they try to advance without a country...
const blocked = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'Next' },
);
// Then they stay on the same step, blocked by a missing 'land' answer.
expect(blocked.tag).toBe('Answering');
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0);
expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy();
// When land and hours abroad are supplied, and the rest of the journey answered...
const reviewing = given(reduce, blocked)(
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
@@ -93,16 +98,34 @@ describe('intake acceptance journeys', () => {
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' }, // werk step valid, uren high enough to skip scholing -> review
);
// Then the journey advances all the way to review.
expect(reviewing.tag).toBe('Answering');
expect(reviewing.tag === 'Answering' && reviewing.cursor).toBe(2); // review
});
// Jump back from review to correct the country, without losing later answers.
it('gaNaarStap corrects an earlier answer without losing later ones', () => {
// Given a journey that reached review with a foreign-work answer.
const reviewing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' },
);
// When gaNaarStap jumps back to correct the country...
const corrected: IntakeState = given(reduce, reviewing)(
{ tag: 'GaNaarStap', cursor: 0 },
{ tag: 'SetAnswer', key: 'land', value: 'België' },
{ tag: 'Next' }, // buitenland step re-validated
{ tag: 'Next' }, // werk step re-validated (earlier 'uren' answer preserved)
);
// Then the correction lands back on review with the new answer, and the later
// 'uren' answer is preserved rather than lost.
expect(corrected.tag).toBe('Answering');
expect(corrected.tag === 'Answering' && corrected.cursor).toBe(2);
expect(corrected.tag === 'Answering' && corrected.answers.land).toBe('België');
@@ -111,6 +134,7 @@ describe('intake acceptance journeys', () => {
const noForwardJump = reduce(corrected, { tag: 'GaNaarStap', cursor: 2 });
expect(noForwardJump).toBe(corrected);
// And the corrected journey still submits successfully with the corrected data.
const done = given(reduce, corrected)({ tag: 'Submit' }, { tag: 'SubmitConfirmed' });
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data).toEqual({
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { formatDatumNl } from '@shared/kernel/datum';
import { tasksFromProfile } from './tasks';
import { Registration } from './registration';
@@ -13,34 +14,56 @@ const base: Registration = {
describe('tasksFromProfile', () => {
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
// Given a registration whose deadline is 2026-12-31.
// When the server says the professional is eligible for herregistratie...
const tasks = tasksFromProfile(base, true);
// Then one task is offered, routed to herregistratie, whose copy carries the
// deadline through the same date formatter the rest of the app uses (this test's
// point IS the date formatting, so the expectation is derived from `formatDatumNl`
// rather than a hardcoded Dutch date literal).
expect(tasks).toHaveLength(1);
expect(tasks[0].to).toBe('/herregistratie');
expect(tasks[0].description).toContain('31 december 2026');
expect(tasks[0].description).toContain(formatDatumNl('2026-12-31'));
});
it('offers nothing when the server says not eligible', () => {
// Given the same registration.
// When the server says the professional is not eligible for herregistratie...
// Then no task is offered.
expect(tasksFromProfile(base, false)).toHaveLength(0);
});
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
// Given a suspended ("Geschorst") registration, ineligible for herregistratie.
const reg: Registration = {
...base,
status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' },
};
// When the tasks are derived...
const tasks = tasksFromProfile(reg, false);
// Then exactly one notice is surfaced, routed to the registration page, carrying
// the raw suspension reason through unchanged (not translated copy — `reden` is
// domain data, passed through as-is).
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst');
expect(tasks[0].to).toBe('/registratie');
expect(tasks[0].description).toBe('Onderzoek');
});
it('surfaces a notice for a struck-off registration', () => {
// Given a struck-off ("Doorgehaald") registration.
const reg: Registration = {
...base,
status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' },
};
// When the tasks are derived...
const tasks = tasksFromProfile(reg, false);
// Then exactly one notice is surfaced, routed to the registration page.
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald');
expect(tasks[0].to).toBe('/registratie');
});
});