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:
@@ -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',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
// 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,
|
||||
});
|
||||
/**
|
||||
* 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);
|
||||
@@ -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 | S–M | 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.
|
||||
Reference in New Issue
Block a user