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:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { given } from '@shared/testing/machine';
|
||||
import {
|
||||
initial,
|
||||
next,
|
||||
@@ -12,74 +12,80 @@ import {
|
||||
WizardState,
|
||||
} from './herregistratie.machine';
|
||||
|
||||
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 2,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 3,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const wizard = given(reduce, initial);
|
||||
|
||||
/** Replay to a step-1 Editing state with the given draft values — the ONLY
|
||||
door to a WizardState is `reduce`, so a fixture here is provably reachable. */
|
||||
const toStep1 = (uren: string, jaren = '5'): WizardState =>
|
||||
wizard(
|
||||
{ tag: 'SetField', key: 'uren', value: uren },
|
||||
{ tag: 'SetField', key: 'jaren', value: jaren },
|
||||
);
|
||||
|
||||
/** Replay to a step-2 Editing state: reach step 1, advance, then set punten
|
||||
(which may itself be invalid — Next only gated step 1's own fields). */
|
||||
const toStep2 = (uren: string, punten: string, jaren = '5'): WizardState =>
|
||||
given(reduce, toStep1(uren, jaren))(
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetField', key: 'punten', value: punten },
|
||||
);
|
||||
|
||||
/** Replay to a step-3 Editing state: reach step 2 with a placeholder-valid
|
||||
punten so `Next` actually advances, THEN overwrite punten with the real
|
||||
(possibly invalid) value — exactly what a user editing step 3 can do,
|
||||
since `SetField` never re-checks the step it's setting a field for. */
|
||||
const toStep3 = (uren: string, punten: string, jaren = '5'): WizardState =>
|
||||
given(reduce, toStep2(uren, '1', jaren))(
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetField', key: 'punten', value: punten },
|
||||
);
|
||||
|
||||
describe('wizard.machine', () => {
|
||||
it('next advances only when step 1 parses', () => {
|
||||
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
|
||||
expect((next(initial) as any).errors.uren).toBeTruthy();
|
||||
expect((next(editing1('4160')) as any).step).toBe(2);
|
||||
expect((next(toStep1('4160')) as any).step).toBe(2);
|
||||
});
|
||||
|
||||
it('next advances step 2 → 3 only when punten parses', () => {
|
||||
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
|
||||
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
|
||||
expect((next(editing2('4160', '200')) as any).step).toBe(3);
|
||||
expect((next(toStep2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
|
||||
expect((next(toStep2('4160', 'x')) as any).errors.punten).toBeTruthy();
|
||||
expect((next(toStep2('4160', '200')) as any).step).toBe(3);
|
||||
});
|
||||
|
||||
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
|
||||
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
|
||||
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
|
||||
const good = submit(editing3('4160', '200'));
|
||||
expect(submit(toStep2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
|
||||
expect(submit(toStep3('4160', 'x')).tag).toBe('Editing'); // invalid punten
|
||||
const good = submit(toStep3('4160', '200'));
|
||||
expect(good.tag).toBe('Submitting');
|
||||
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
|
||||
});
|
||||
|
||||
it('next requires BOTH step-1 fields (uren and jaren)', () => {
|
||||
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
|
||||
expect((next(editing1('4160', '')) as any).step).toBe(1);
|
||||
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
|
||||
expect((next(toStep1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
|
||||
expect((next(toStep1('4160', '')) as any).step).toBe(1);
|
||||
expect((next(toStep1('4160', '5')) as any).step).toBe(2); // both valid -> advance
|
||||
});
|
||||
|
||||
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
|
||||
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
|
||||
expect((back(editing3('1', '2')) as any).step).toBe(2);
|
||||
expect((back(editing2('1', '2')) as any).step).toBe(1);
|
||||
expect((back(toStep3('1', '2')) as any).step).toBe(2);
|
||||
expect((back(toStep2('1', '2')) as any).step).toBe(1);
|
||||
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted / Failed', () => {
|
||||
const submitting = submit(editing3('4160', '200'));
|
||||
const submitting = submit(toStep3('4160', '200'));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
|
||||
expect((gaNaarStap(toStep3('4160', '200'), 1) as any).step).toBe(1);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
|
||||
const e3 = editing3('4160', '200');
|
||||
const e3 = toStep3('4160', '200');
|
||||
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
|
||||
const submitting = submit(e3);
|
||||
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
|
||||
@@ -113,7 +119,7 @@ describe('reduce (message-driven)', () => {
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
let s = reduce(editing3('4160', '200'), {
|
||||
let s = reduce(toStep3('4160', '200'), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
@@ -130,7 +136,7 @@ describe('reduce (message-driven)', () => {
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
|
||||
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
|
||||
let s = reduce(reduce(toStep3('4160', '200'), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
@@ -141,6 +147,6 @@ describe('reduce (message-driven)', () => {
|
||||
});
|
||||
|
||||
it('Seed mounts an arbitrary state', () => {
|
||||
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
|
||||
expect(reduce(initial, { tag: 'Seed', state: toStep2('1', '2') }).tag).toBe('Editing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { given } from '@shared/testing/machine';
|
||||
import { reduce, initial } from './intake.machine';
|
||||
|
||||
/** Replay real `IntakeMsg`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 givenIntake = given(reduce, initial);
|
||||
Reference in New Issue
Block a user