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>
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);
|
||||
Reference in New Issue
Block a user