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'); }); });