import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { Answers, initial, STEPS, lageUren, currentStep, next, back, submit, resolve, reduce, IntakeState, } from './intake.machine'; const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold }); describe('STEPS (fixed) and inline questions', () => { it('always has the same three steps', () => { expect(STEPS).toEqual(['buitenland', 'werk', 'review']); }); it('reveals the buitenland detail questions inline only when worked abroad', () => { // No new step; instead these fields become required within the buitenland step. expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy(); expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1); }); it('reveals the scholing question only when NL-hours are below the threshold', () => { expect(lageUren({ uren: '500' })).toBe(true); expect(lageUren({ uren: '4160' })).toBe(false); }); it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => { // Same hours, different threshold → different visibility. Proves de-hardcoding. expect(lageUren({ uren: '1500' }, 1000)).toBe(false); expect(lageUren({ uren: '1500' }, 2000)).toBe(true); // And the threshold from state flows through submit: const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000)); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy(); }); }); describe('navigation', () => { it('Next is a no-op (sets an error) when the current step is invalid', () => { const s = next(initial); // buitenland unanswered expect(s.tag).toBe('Answering'); expect((s as any).cursor).toBe(0); expect((s as any).errors.buitenlandGewerkt).toBeTruthy(); }); it('Next advances once the step is valid', () => { const s = next(answering({ buitenlandGewerkt: 'nee' })); expect((s as any).cursor).toBe(1); expect(currentStep(s as any)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change }); it('Back never goes below the first step', () => { expect(back(initial)).toBe(initial); }); }); describe('submit', () => { // High hours: no scholing question, so no punten is asked or collected. const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' }; it('reaches Submitting ONLY with valid answers', () => { // Bad punten only blocks when scholing was followed (otherwise punten is ignored). expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering'); const good = submit(answering(complete)); expect(good.tag).toBe('Submitting'); expect((good as any).data.uren).toBe(4160); expect((good as any).data.punten).toBeUndefined(); // not collected without scholing }); it('punten is required only when aanvullende scholing was gevolgd', () => { // scholing = ja but punten missing -> blocked on punten. const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })); expect(missing.tag).toBe('Answering'); expect((missing as any).errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' })); expect(withScholing.tag).toBe('Submitting'); expect((withScholing as any).data.aanvullendeScholing).toBe(true); expect((withScholing as any).data.punten).toBe(200); }); it('resolve maps Submitting to Submitted / Failed', () => { const submitting = submit(answering(complete)); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); describe('reduce (message-driven happy path)', () => { it('drives abroad branch end to end', () => { let s: IntakeState = initial; // Step 1: buitenland — the country/hours questions reveal inline (same step). s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' }); s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' }); s = reduce(s, { tag: 'Next' }); expect(currentStep(s as any)).toBe('werk'); // Step 2: werk — uren + punten (no inline scholing, hours are high). s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' }); s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' }); s = reduce(s, { tag: 'Next' }); expect(currentStep(s as any)).toBe('review'); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); s = reduce(s, { tag: 'SubmitConfirmed' }); expect(s.tag).toBe('Submitted'); }); });