test(frontend): replay real messages instead of hand-built state literals (WP-70)

Every machine spec redefined its own throwaway fixture helper (editing1/2/3,
editingWith), hardcoding fields like errors: {} that assert against shapes
the reducer may never actually produce. given(reduce, initial)(...msgs)
(libs/shared/src/testing/machine.ts) replaces them by replaying real Msgs
through the real reduce, so a fixture is provably reachable. Adds the same
idiom for value objects (unwrapOk) and RemoteData (loading/success/failure),
plus intake.acceptance.spec.ts as a worked full-journey example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 15:31:06 +02:00
co-authored by Claude Sonnet 5
parent 2eea860efe
commit a73a1c6f1e
12 changed files with 264 additions and 56 deletions
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { given } from '@shared/testing/machine';
import { reduce, IntakeState } from './intake.machine';
import { givenIntake } from './intake.testing';
/**
* Behaviour-level journeys: each test replays the exact `IntakeMsg` sequence a
* real user (or the server, for `SetPolicy`) would send, and asserts only on
* the reachable end state — never a hand-assembled `IntakeState` literal. No
* `$localize` copy is asserted, only tags/fields/error PRESENCE.
*/
describe('intake acceptance journeys', () => {
it('high uren, no buitenland werk: no scholing question, straight through to Submitted', () => {
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' },
{ tag: 'Submit' },
{ tag: 'SubmitConfirmed' },
);
expect(s.tag).toBe('Submitted');
expect(s.tag === 'Submitted' && s.data).toEqual({
werktBuitenland: false,
land: undefined,
buitenlandseUren: undefined,
uren: 1200,
aanvullendeScholing: undefined,
punten: undefined,
});
});
it('low uren requires the scholing question, and punten only once scholing is followed', () => {
// Leaving the werk step without answering the scholing question is blocked.
const blocked = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'Next' },
);
expect(blocked.tag).toBe('Answering');
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(1); // still on 'werk'
expect(blocked.tag === 'Answering' && blocked.errors.scholingGevolgd).toBeTruthy();
// Answering scholing but not yet punten is still blocked.
const stillBlocked = given(reduce, blocked)(
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'Next' },
);
expect(stillBlocked.tag).toBe('Answering');
expect(stillBlocked.tag === 'Answering' && stillBlocked.cursor).toBe(1);
expect(stillBlocked.tag === 'Answering' && stillBlocked.errors.punten).toBeTruthy();
// Supplying punten unblocks: reach review, submit, fail, retry, succeed.
const submitting = given(reduce, stillBlocked)(
{ tag: 'SetAnswer', key: 'punten', value: '150' },
{ tag: 'Next' },
{ tag: 'Submit' },
);
expect(submitting.tag).toBe('Submitting');
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'netwerkfout' });
expect(failed.tag).toBe('Failed');
const retried = reduce(failed, { tag: 'Retry' });
expect(retried.tag).toBe('Submitting');
const done = reduce(retried, { tag: 'SubmitConfirmed' });
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data).toEqual({
werktBuitenland: false,
land: undefined,
buitenlandseUren: undefined,
uren: 500,
aanvullendeScholing: true,
punten: 150,
});
});
it('buitenland gewerkt requires land + hours abroad, and gaNaarStap corrects an earlier answer', () => {
const blocked = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'Next' },
);
expect(blocked.tag).toBe('Answering');
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0);
expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy();
const reviewing = given(reduce, blocked)(
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' }, // buitenland step now valid -> werk
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' }, // werk step valid, uren high enough to skip scholing -> 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.
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)
);
expect(corrected.tag).toBe('Answering');
expect(corrected.tag === 'Answering' && corrected.cursor).toBe(2);
expect(corrected.tag === 'Answering' && corrected.answers.land).toBe('België');
// A forward jump (would skip validation) is refused — a true no-op.
const noForwardJump = reduce(corrected, { tag: 'GaNaarStap', cursor: 2 });
expect(noForwardJump).toBe(corrected);
const done = given(reduce, corrected)({ tag: 'Submit' }, { tag: 'SubmitConfirmed' });
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data).toEqual({
werktBuitenland: true,
land: 'België',
buitenlandseUren: 300,
uren: 1200,
aanvullendeScholing: undefined,
punten: undefined,
});
});
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
const atWerkStep = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
);
// With the default threshold (1000), 1200 hours needs no scholing question.
const acceptedWithDefault = reduce(atWerkStep, { tag: 'Next' });
expect(acceptedWithDefault.tag).toBe('Answering');
expect(acceptedWithDefault.tag === 'Answering' && acceptedWithDefault.cursor).toBe(2);
// The server raises the threshold above 1200 -> the same answer now requires it.
const raised = reduce(atWerkStep, { tag: 'SetPolicy', scholingThreshold: 1500 });
const blockedByNewPolicy = reduce(raised, { tag: 'Next' });
expect(blockedByNewPolicy.tag).toBe('Answering');
expect(blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.cursor).toBe(1);
expect(
blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.errors.scholingGevolgd,
).toBeTruthy();
});
});