import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { expectTag } from '@shared/testing/expect-tag'; import { initial, STEPS, lageUren, currentStep, next, back, gaNaarStap, submit, primary, resolve, reduce, IntakeState, } from './intake.machine'; import { givenIntake } from './intake.testing'; 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. const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy(); const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves) expect(expectTag(next(domestic), 'Answering').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 lowThresholdState = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '1500' }, { tag: 'SetAnswer', key: 'punten', value: '200' }, { tag: 'SetPolicy', scholingThreshold: 2000 }, ); const lowThreshold = submit(lowThresholdState); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); }); }); describe('navigation', () => { it('Next is a no-op (sets an error) when the current step is invalid', () => { const s = expectTag(next(initial), 'Answering'); // buitenland unanswered expect(s.cursor).toBe(0); expect(s.errors.buitenlandGewerkt).toBeTruthy(); }); it('Next advances once the step is valid', () => { const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); const s = expectTag(next(domestic), 'Answering'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { const atWerk = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }, { tag: 'SetAnswer', key: 'land', value: 'België' }, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' }, { tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1 ); const edited = expectTag( reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }), 'Answering', ); expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change }); it('Back never goes below the first step', () => { expect(back(initial)).toBe(initial); }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { const s = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'Next' }, // cursor 0 -> 1 { tag: 'SetAnswer', key: 'uren', value: '4160' }, { tag: 'Next' }, // cursor 1 -> 2 ); expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0); }); it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { const s = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'Next' }, // cursor 0 -> 1 ); expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op const atReview = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'Next' }, // cursor 0 -> 1 { tag: 'SetAnswer', key: 'uren', value: '4160' }, { tag: 'Next' }, // cursor 1 -> 2 ); const submitting = submit(atReview); expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op }); }); describe('submit', () => { // High hours: no scholing question, so no punten is asked or collected. const highUren = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '4160' }, ); it('reaches Submitting ONLY with valid answers', () => { // Bad punten only blocks when scholing was followed (otherwise punten is ignored). const badPunten = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, { tag: 'SetAnswer', key: 'punten', value: 'x' }, ); expect(submit(badPunten).tag).toBe('Answering'); const good = expectTag(submit(highUren), 'Submitting'); expect(good.data.uren).toBe(4160); expect(good.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 scholingJaNoPunten = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); const missing = expectTag(submit(scholingJaNoPunten), 'Answering'); expect(missing.errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. const scholingNee = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' }, ); expect(submit(scholingNee).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { const lowUrenNoScholing = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '500' }, ); const noScholing = submit(lowUrenNoScholing); expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered const lowUrenWithScholing = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, { tag: 'SetAnswer', key: 'punten', value: '200' }, ); const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting'); expect(withScholing.data.aanvullendeScholing).toBe(true); expect(withScholing.data.punten).toBe(200); }); it('does not require punten for a hidden question', () => { // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // threshold — the template hides the question, so punten must not be required either. const staleScholingNoPunten = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '1500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); const good = expectTag(submit(staleScholingNoPunten), 'Submitting'); expect(good.data.aanvullendeScholing).toBeUndefined(); }); it('drops punten when raising uren hides the question', () => { // Same stale answer, but this time punten was also filled in while uren was low. const staleScholingWithPunten = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '1500' }, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, { tag: 'SetAnswer', key: 'punten', value: '150' }, ); const good = expectTag(submit(staleScholingWithPunten), 'Submitting'); // ValidIntake stays honest: neither the stale 'ja' nor its punten leak through. expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.punten).toBeUndefined(); }); it('resolve maps Submitting to Submitted on a successful submit', () => { const submitting = submit(highUren); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); }); it('resolve maps Submitting to Failed on a failed submit', () => { const submitting = submit(highUren); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); describe('primary', () => { // Same fixture as the 'submit' describe block above: buitenland answered 'nee', // uren high enough to skip the scholing question. const highUren = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'uren', value: '4160' }, ); it('Primary advances to the next step from a non-final step', () => { expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' })); expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1); }); it('Primary submits from the final step', () => { const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }); expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' })); expect(primary(atReview).tag).toBe('Submitting'); }); it('Primary is a no-op from a non-editing state', () => { const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' })); expect(primary(submitting)).toBe(submitting); }); }); 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(expectTag(s, 'Answering'))).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(expectTag(s, 'Answering'))).toBe('review'); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); s = reduce(s, { tag: 'SubmitConfirmed' }); expect(s.tag).toBe('Submitted'); }); });