204 WP-NN/RB-NN comments named a closed ticket instead of the code they sit next to. git blame already records history and stays correct when code moves; the comment does not. This sweep removes the reference and keeps the sentence, across 95 files in apps/ and libs/ plus the behaviour-spec generator's header text. Eleven references stay: five story files justify an a11y disable per the README's rule, and one line in a11y.mdx documents that convention. Two sentences needed a rewrite, not a deletion, so the reference's meaning survives its removal. behaviour-spec.mdx is regenerated, not hand-edited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|