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>
153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { ok, err } from '@shared/kernel/fp';
|
|
import { given } from '@shared/testing/machine';
|
|
import {
|
|
initial,
|
|
next,
|
|
back,
|
|
gaNaarStap,
|
|
submit,
|
|
resolve,
|
|
reduce,
|
|
WizardState,
|
|
} from './herregistratie.machine';
|
|
|
|
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(toStep1('4160')) as any).step).toBe(2);
|
|
});
|
|
|
|
it('next advances step 2 → 3 only when punten parses', () => {
|
|
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(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(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(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(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(toStep3('4160', '200'), 1) as any).step).toBe(1);
|
|
});
|
|
|
|
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
|
|
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
|
|
});
|
|
});
|
|
|
|
describe('reduce (message-driven)', () => {
|
|
it('drives the full happy path via messages', () => {
|
|
let s: WizardState = initial;
|
|
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
|
|
s = reduce(s, { tag: 'SetField', key: 'jaren', value: '5' });
|
|
s = reduce(s, { tag: 'Next' });
|
|
expect(s.tag === 'Editing' && s.step).toBe(2);
|
|
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
|
|
s = reduce(s, { tag: 'Next' });
|
|
expect(s.tag === 'Editing' && s.step).toBe(3);
|
|
s = reduce(s, { tag: 'Submit' });
|
|
expect(s.tag).toBe('Submitting');
|
|
s = reduce(s, { tag: 'SubmitConfirmed' });
|
|
expect(s.tag).toBe('Submitted');
|
|
});
|
|
|
|
it('blocks submit until required documents are satisfied', () => {
|
|
const cat = {
|
|
categoryId: 'bewijs',
|
|
label: 'Bewijs',
|
|
description: '',
|
|
required: true,
|
|
acceptedTypes: [],
|
|
maxSizeMb: 10,
|
|
multiple: false,
|
|
allowPostDelivery: true,
|
|
};
|
|
let s = reduce(toStep3('4160', '200'), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
|
});
|
|
s = reduce(s, { tag: 'Submit' });
|
|
expect(s.tag).toBe('Editing');
|
|
expect((s as any).errors.documenten).toBeTruthy();
|
|
s = reduce(s, {
|
|
tag: 'Upload',
|
|
msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' },
|
|
});
|
|
s = reduce(s, { tag: 'Submit' });
|
|
expect(s.tag).toBe('Submitting');
|
|
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
|
|
});
|
|
|
|
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
|
|
let s = reduce(reduce(toStep3('4160', '200'), { tag: 'Submit' }), {
|
|
tag: 'SubmitFailed',
|
|
error: 'boom',
|
|
});
|
|
expect(s.tag).toBe('Failed');
|
|
s = reduce(s, { tag: 'Retry' });
|
|
expect(s.tag).toBe('Submitting');
|
|
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
|
|
});
|
|
|
|
it('Seed mounts an arbitrary state', () => {
|
|
expect(reduce(initial, { tag: 'Seed', state: toStep2('1', '2') }).tag).toBe('Editing');
|
|
});
|
|
});
|