Files
atomic-design-poc/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts
T
ehoandClaude Sonnet 5 be1fcb4b40 feat: add Primary message to the 3 wizard machines (RD-07)
Each wizard component re-derives the step-boundary decision the reducer
already owns: advance on a middle step, submit on the last step. This
ticket moves that decision into the machine, so RD-08 can replace the
component's guard with one dispatch.

Add a `Primary` message to each Msg union, and export a `primary(s)`
function next to the existing `next`/`submit` pair. `primary` is a
three-line branch that delegates to `next`/`submit` and writes no new
validation. Each machine tests "last step" in its own vocabulary, per
the ticket's Decisions block: `herregistratie` checks `step === 3`,
`intake` checks `currentStep(s) === 'review'`, `registratie` checks
`currentStep(s) === 'controle'`. `Next` and `Submit` stay in every
union and every reducer — `Primary` is purely additive.

Add 3 spec cases per machine (9 total): Primary advances from a
non-final step, Primary submits from the final step, and Primary is a
no-op outside the editing state. Each case also asserts the
equivalence the ticket requires for RD-08's migration:
`reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and
equals `reduce(s, Submit)` at the final step.

Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:06:02 +02:00

192 lines
7.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { given } from '@shared/testing/machine';
import { expectTag } from '@shared/testing/expect-tag';
import {
initial,
next,
back,
gaNaarStap,
submit,
primary,
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(expectTag(next(initial), 'Editing').errors.uren).toBeTruthy();
expect(expectTag(next(toStep1('4160')), 'Editing').step).toBe(2);
});
it('next advances step 2 → 3 only when punten parses', () => {
expect(expectTag(next(toStep2('4160', 'x')), 'Editing').step).toBe(2); // invalid punten -> stays
expect(expectTag(next(toStep2('4160', 'x')), 'Editing').errors.punten).toBeTruthy();
expect(expectTag(next(toStep2('4160', '200')), 'Editing').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 = expectTag(submit(toStep3('4160', '200')), 'Submitting');
expect(good.data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('next requires BOTH step-1 fields (uren and jaren)', () => {
expect(expectTag(next(toStep1('4160', '')), 'Editing').errors.jaren).toBeTruthy(); // jaren empty -> stays
expect(expectTag(next(toStep1('4160', '')), 'Editing').step).toBe(1);
expect(expectTag(next(toStep1('4160', '5')), 'Editing').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(expectTag(back(toStep3('1', '2')), 'Editing').step).toBe(2);
expect(expectTag(back(toStep2('1', '2')), 'Editing').step).toBe(1);
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps a successful Submitting to Submitted', () => {
// Given a wizard mid-submit.
const submitting = submit(toStep3('4160', '200'));
// When the submission resolves ok...
// Then the wizard reaches Submitted.
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps a failing Submitting to Failed', () => {
// Given a wizard mid-submit.
const submitting = submit(toStep3('4160', '200'));
// When the submission resolves with an error...
// Then the wizard reaches Failed.
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
expect(expectTag(gaNaarStap(toStep3('4160', '200'), 1), 'Editing').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('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(expectTag(primary(s), 'Editing').step).toBe(3);
});
it('Primary submits from the final step', () => {
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(toStep3('4160', '200'));
expect(primary(submitting)).toBe(submitting);
});
});
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(expectTag(s, 'Editing').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(expectTag(s, 'Submitting').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(expectTag(s, 'Submitting').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');
});
});