bdd.mdx previously banned "Given/When/Then ceremony" outright, which
directly contradicted WP-70's own acceptance tests (Acceptance/
BesluitLifecycleTests.cs already used // Given/When/Then comments) and
the backend's organically-evolved PascalCase_snake_sentence convention,
which the doc gave zero guidance for. Reverses that rule: every test is
now structured Given -> When -> Then, with a genuinely empty phase
omitted rather than faked; present-tense declarative naming and the
one-behaviour-per-test rule are unchanged. ADR-0006 gets a cross-reference
so both documents agree everywhere, not just in acceptance tests.
Also closes out the doc's other named-but-unenforced rules found by the
audit: fixes the 5 files asserting rendered $localize copy instead of
the underlying tag/message-id (the compliant pattern already existed in
werkvoorraad-item-view.spec.ts), splits the multi-behaviour titles the
doc itself calls a smell (";", "and", "/"), and fixes bdd.mdx's own false
citation of registratie-wizard.machine.spec.ts as "one transition per
test" by actually splitting that test into one-transition-per-test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
172 lines
7.1 KiB
TypeScript
172 lines
7.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { given } from '@shared/testing/machine';
|
|
import { reduce, IntakeState } from './intake.machine';
|
|
import { givenIntake } from './intake.testing';
|
|
|
|
/**
|
|
* Behaviour-level journeys: each test replays the exact `IntakeMsg` sequence a
|
|
* real user (or the server, for `SetPolicy`) would send, and asserts only on
|
|
* the reachable end state — never a hand-assembled `IntakeState` literal. No
|
|
* `$localize` copy is asserted, only tags/fields/error PRESENCE.
|
|
*/
|
|
describe('intake acceptance journeys', () => {
|
|
it('high uren, no buitenland werk: no scholing question, straight through to Submitted', () => {
|
|
const s = givenIntake(
|
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'Submit' },
|
|
{ tag: 'SubmitConfirmed' },
|
|
);
|
|
expect(s.tag).toBe('Submitted');
|
|
expect(s.tag === 'Submitted' && s.data).toEqual({
|
|
werktBuitenland: false,
|
|
land: undefined,
|
|
buitenlandseUren: undefined,
|
|
uren: 1200,
|
|
aanvullendeScholing: undefined,
|
|
punten: undefined,
|
|
});
|
|
});
|
|
|
|
it('low uren requires the scholing question, and punten only once scholing is followed', () => {
|
|
// Leaving the werk step without answering the scholing question is blocked.
|
|
const blocked = givenIntake(
|
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
|
{ tag: 'Next' },
|
|
);
|
|
expect(blocked.tag).toBe('Answering');
|
|
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(1); // still on 'werk'
|
|
expect(blocked.tag === 'Answering' && blocked.errors.scholingGevolgd).toBeTruthy();
|
|
|
|
// Answering scholing but not yet punten is still blocked.
|
|
const stillBlocked = given(reduce, blocked)(
|
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
|
{ tag: 'Next' },
|
|
);
|
|
expect(stillBlocked.tag).toBe('Answering');
|
|
expect(stillBlocked.tag === 'Answering' && stillBlocked.cursor).toBe(1);
|
|
expect(stillBlocked.tag === 'Answering' && stillBlocked.errors.punten).toBeTruthy();
|
|
|
|
// Supplying punten unblocks: reach review, submit, fail, retry, succeed.
|
|
const submitting = given(reduce, stillBlocked)(
|
|
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'Submit' },
|
|
);
|
|
expect(submitting.tag).toBe('Submitting');
|
|
|
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'netwerkfout' });
|
|
expect(failed.tag).toBe('Failed');
|
|
|
|
const retried = reduce(failed, { tag: 'Retry' });
|
|
expect(retried.tag).toBe('Submitting');
|
|
|
|
const done = reduce(retried, { tag: 'SubmitConfirmed' });
|
|
expect(done.tag).toBe('Submitted');
|
|
expect(done.tag === 'Submitted' && done.data).toEqual({
|
|
werktBuitenland: false,
|
|
land: undefined,
|
|
buitenlandseUren: undefined,
|
|
uren: 500,
|
|
aanvullendeScholing: true,
|
|
punten: 150,
|
|
});
|
|
});
|
|
|
|
it('buitenland gewerkt requires land and hours abroad before advancing', () => {
|
|
// Given a user who says they worked abroad.
|
|
// When they try to advance without a country...
|
|
const blocked = givenIntake(
|
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
|
{ tag: 'Next' },
|
|
);
|
|
|
|
// Then they stay on the same step, blocked by a missing 'land' answer.
|
|
expect(blocked.tag).toBe('Answering');
|
|
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0);
|
|
expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy();
|
|
|
|
// When land and hours abroad are supplied, and the rest of the journey answered...
|
|
const reviewing = given(reduce, blocked)(
|
|
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
|
|
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
|
{ tag: 'Next' }, // buitenland step now valid -> werk
|
|
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
|
{ tag: 'Next' }, // werk step valid, uren high enough to skip scholing -> review
|
|
);
|
|
|
|
// Then the journey advances all the way to review.
|
|
expect(reviewing.tag).toBe('Answering');
|
|
expect(reviewing.tag === 'Answering' && reviewing.cursor).toBe(2); // review
|
|
});
|
|
|
|
it('gaNaarStap corrects an earlier answer without losing later ones', () => {
|
|
// Given a journey that reached review with a foreign-work answer.
|
|
const reviewing = givenIntake(
|
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
|
|
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
|
{ tag: 'Next' },
|
|
);
|
|
|
|
// When gaNaarStap jumps back to correct the country...
|
|
const corrected: IntakeState = given(reduce, reviewing)(
|
|
{ tag: 'GaNaarStap', cursor: 0 },
|
|
{ tag: 'SetAnswer', key: 'land', value: 'België' },
|
|
{ tag: 'Next' }, // buitenland step re-validated
|
|
{ tag: 'Next' }, // werk step re-validated (earlier 'uren' answer preserved)
|
|
);
|
|
|
|
// Then the correction lands back on review with the new answer, and the later
|
|
// 'uren' answer is preserved rather than lost.
|
|
expect(corrected.tag).toBe('Answering');
|
|
expect(corrected.tag === 'Answering' && corrected.cursor).toBe(2);
|
|
expect(corrected.tag === 'Answering' && corrected.answers.land).toBe('België');
|
|
|
|
// A forward jump (would skip validation) is refused — a true no-op.
|
|
const noForwardJump = reduce(corrected, { tag: 'GaNaarStap', cursor: 2 });
|
|
expect(noForwardJump).toBe(corrected);
|
|
|
|
// And the corrected journey still submits successfully with the corrected data.
|
|
const done = given(reduce, corrected)({ tag: 'Submit' }, { tag: 'SubmitConfirmed' });
|
|
expect(done.tag).toBe('Submitted');
|
|
expect(done.tag === 'Submitted' && done.data).toEqual({
|
|
werktBuitenland: true,
|
|
land: 'België',
|
|
buitenlandseUren: 300,
|
|
uren: 1200,
|
|
aanvullendeScholing: undefined,
|
|
punten: undefined,
|
|
});
|
|
});
|
|
|
|
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
|
|
const atWerkStep = givenIntake(
|
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
|
{ tag: 'Next' },
|
|
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
|
);
|
|
|
|
// With the default threshold (1000), 1200 hours needs no scholing question.
|
|
const acceptedWithDefault = reduce(atWerkStep, { tag: 'Next' });
|
|
expect(acceptedWithDefault.tag).toBe('Answering');
|
|
expect(acceptedWithDefault.tag === 'Answering' && acceptedWithDefault.cursor).toBe(2);
|
|
|
|
// The server raises the threshold above 1200 -> the same answer now requires it.
|
|
const raised = reduce(atWerkStep, { tag: 'SetPolicy', scholingThreshold: 1500 });
|
|
const blockedByNewPolicy = reduce(raised, { tag: 'Next' });
|
|
expect(blockedByNewPolicy.tag).toBe('Answering');
|
|
expect(blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.cursor).toBe(1);
|
|
expect(
|
|
blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.errors.scholingGevolgd,
|
|
).toBeTruthy();
|
|
});
|
|
});
|