diff --git a/angular.json b/angular.json index 8502606..30dde9d 100644 --- a/angular.json +++ b/angular.json @@ -108,6 +108,7 @@ "**/contracts/**", "libs/shared/src/infrastructure/api-client.ts", "apps/ssp/src/main.ts", + "**/*.testing.ts", "**/*.d.ts" ] } @@ -236,6 +237,7 @@ "**/contracts/**", "libs/shared/src/infrastructure/api-client.ts", "apps/behandelportal/src/main.ts", + "**/*.testing.ts", "**/*.d.ts" ] } @@ -293,6 +295,7 @@ "**/contracts/**", "src/infrastructure/api-client.ts", "src/test-entry.ts", + "**/*.testing.ts", "**/*.d.ts" ] } @@ -330,6 +333,7 @@ "**/*.stories.ts", "**/contracts/**", "src/test-entry.ts", + "**/*.testing.ts", "**/*.d.ts" ] } diff --git a/apps/behandelportal/tsconfig.app.json b/apps/behandelportal/tsconfig.app.json index 064a817..ccb6d3c 100644 --- a/apps/behandelportal/tsconfig.app.json +++ b/apps/behandelportal/tsconfig.app.json @@ -7,5 +7,5 @@ "types": ["@angular/localize"] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"] } diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts index 0dd1d9a..28e1b86 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { given } from '@shared/testing/machine'; import { initial, next, @@ -12,74 +12,80 @@ import { WizardState, } from './herregistratie.machine'; -const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ - tag: 'Editing', - step: 1, - draft: { uren, jaren, punten }, - errors: {}, - upload: initialUpload, -}); -const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ - tag: 'Editing', - step: 2, - draft: { uren, jaren, punten }, - errors: {}, - upload: initialUpload, -}); -const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ - tag: 'Editing', - step: 3, - draft: { uren, jaren, punten }, - errors: {}, - upload: initialUpload, -}); +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((next(initial) as any).errors.uren).toBeTruthy(); - expect((next(editing1('4160')) as any).step).toBe(2); + expect((next(toStep1('4160')) as any).step).toBe(2); }); it('next advances step 2 → 3 only when punten parses', () => { - expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays - expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy(); - expect((next(editing2('4160', '200')) as any).step).toBe(3); + expect((next(toStep2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays + expect((next(toStep2('4160', 'x')) as any).errors.punten).toBeTruthy(); + expect((next(toStep2('4160', '200')) as any).step).toBe(3); }); it('submit reaches Submitting ONLY from step 3 with fully valid data', () => { - expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting - expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten - const good = submit(editing3('4160', '200')); + 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 = submit(toStep3('4160', '200')); expect(good.tag).toBe('Submitting'); expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('next requires BOTH step-1 fields (uren and jaren)', () => { - expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays - expect((next(editing1('4160', '')) as any).step).toBe(1); - expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance + expect((next(toStep1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays + expect((next(toStep1('4160', '')) as any).step).toBe(1); + expect((next(toStep1('4160', '5')) as any).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((back(editing3('1', '2')) as any).step).toBe(2); - expect((back(editing2('1', '2')) as any).step).toBe(1); + expect((back(toStep3('1', '2')) as any).step).toBe(2); + expect((back(toStep2('1', '2')) as any).step).toBe(1); expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting }); it('resolve maps Submitting to Submitted / Failed', () => { - const submitting = submit(editing3('4160', '200')); + const submitting = submit(toStep3('4160', '200')); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { - expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1); + expect((gaNaarStap(toStep3('4160', '200'), 1) as any).step).toBe(1); }); it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => { - const e3 = editing3('4160', '200'); + 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 @@ -113,7 +119,7 @@ describe('reduce (message-driven)', () => { multiple: false, allowPostDelivery: true, }; - let s = reduce(editing3('4160', '200'), { + let s = reduce(toStep3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); @@ -130,7 +136,7 @@ describe('reduce (message-driven)', () => { }); it('SubmitFailed then Retry returns to Submitting with the same data', () => { - let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { + let s = reduce(reduce(toStep3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -141,6 +147,6 @@ describe('reduce (message-driven)', () => { }); it('Seed mounts an arbitrary state', () => { - expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing'); + expect(reduce(initial, { tag: 'Seed', state: toStep2('1', '2') }).tag).toBe('Editing'); }); }); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts new file mode 100644 index 0000000..68e97de --- /dev/null +++ b/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts @@ -0,0 +1,147 @@ +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 + hours abroad, and gaNaarStap corrects an earlier answer', () => { + const blocked = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }, + { tag: 'Next' }, + ); + expect(blocked.tag).toBe('Answering'); + expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0); + expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy(); + + 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 + ); + expect(reviewing.tag).toBe('Answering'); + expect(reviewing.tag === 'Answering' && reviewing.cursor).toBe(2); // review + + // Jump back from review to correct the country, without losing later answers. + 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) + ); + 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); + + 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(); + }); +}); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.testing.ts b/apps/ssp/src/app/herregistratie/domain/intake.testing.ts new file mode 100644 index 0000000..5a115f0 --- /dev/null +++ b/apps/ssp/src/app/herregistratie/domain/intake.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './intake.machine'; + +/** Replay real `IntakeMsg`s through the real `reduce`, starting from `initial`. + Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser + `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenIntake = given(reduce, initial); diff --git a/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts b/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts index 6aba4c1..0081d0a 100644 --- a/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; +import { given } from '@shared/testing/machine'; import { ChangeRequestState, reduce, initial } from './change-request.machine'; -const editingWith = (telefoon: string): ChangeRequestState => ({ - tag: 'Editing', - draft: { telefoon }, - errors: {}, -}); +const givenChangeRequest = given(reduce, initial); + +const editingWith = (telefoon: string): ChangeRequestState => + givenChangeRequest({ tag: 'SetField', key: 'telefoon', value: telefoon }); describe('change-request reduce', () => { it('SetField updates the draft while editing', () => { diff --git a/apps/ssp/tsconfig.app.json b/apps/ssp/tsconfig.app.json index 064a817..ccb6d3c 100644 --- a/apps/ssp/tsconfig.app.json +++ b/apps/ssp/tsconfig.app.json @@ -7,5 +7,5 @@ "types": ["@angular/localize"] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"] } diff --git a/libs/shared/src/application/machine-remote-data.spec.ts b/libs/shared/src/application/machine-remote-data.spec.ts index 484d890..b0c11fd 100644 --- a/libs/shared/src/application/machine-remote-data.spec.ts +++ b/libs/shared/src/application/machine-remote-data.spec.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { machineRemoteData } from './machine-remote-data'; +import { loading, success } from '../testing/remote-data'; describe('machineRemoteData', () => { it('maps loading → Loading', () => { - expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' }); + expect(machineRemoteData({ tag: 'loading' })).toEqual(loading()); }); it('maps failed → Failure carrying an Error with the reason', () => { @@ -14,6 +15,6 @@ describe('machineRemoteData', () => { it('maps loaded → Success carrying the whole loaded state', () => { const loaded = { tag: 'loaded', foo: 42 } as const; - expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded }); + expect(machineRemoteData(loaded)).toEqual(success(loaded)); }); }); diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index 30a1489..ee7bd64 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -1,22 +1,23 @@ import { describe, it, expect } from 'vitest'; import { RemoteData, map2, map } from './remote-data'; +import { loading, failure, success } from '../testing/remote-data'; -const loading: RemoteData = { tag: 'Loading' }; -const failure: RemoteData = { tag: 'Failure', error: 'x' }; -const ok = (n: number): RemoteData => ({ tag: 'Success', value: n }); +const loadingRd: RemoteData = loading(); +const failureRd: RemoteData = failure('x'); +const ok = (n: number): RemoteData => success(n); describe('RemoteData combinators', () => { it('map only touches Success', () => { const times10 = (n: number) => n * 10; expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 }); - expect(map(loading, times10)).toEqual(loading); + expect(map(loadingRd, times10)).toEqual(loadingRd); }); it('map2 precedence: Failure > Loading > Success', () => { const add = (a: number, b: number) => a + b; - expect(map2(failure, ok(1), add)).toEqual(failure); // a failed - expect(map2(ok(1), failure, add)).toEqual(failure); // b failed - expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' }); + expect(map2(failureRd, ok(1), add)).toEqual(failureRd); // a failed + expect(map2(ok(1), failureRd, add)).toEqual(failureRd); // b failed + expect(map2(loadingRd, ok(1), add)).toEqual({ tag: 'Loading' }); expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 }); }); }); diff --git a/libs/shared/src/testing/machine.ts b/libs/shared/src/testing/machine.ts new file mode 100644 index 0000000..f656e13 --- /dev/null +++ b/libs/shared/src/testing/machine.ts @@ -0,0 +1,15 @@ +/** + * Test-only DSL for the Elm-store idiom (CLAUDE.md #3). A fixture is built by + * replaying real `Msg`s through the real `reduce` — never by hand-assembling a + * state object field-by-field. That closes off illegal states the reducer would + * never actually produce: if a spec can't reach a state via messages, it can't + * assert on it either. + * + * `given(reduce, initial)` partially applies a machine's reducer + starting + * state; the result is a variadic replay function a spec calls with the exact + * message sequence a real user/flow would send. + */ +export const given = + (reduce: (s: S, m: M) => S, initial: S) => + (...msgs: M[]): S => + msgs.reduce(reduce, initial); diff --git a/libs/shared/src/testing/remote-data.ts b/libs/shared/src/testing/remote-data.ts new file mode 100644 index 0000000..1ed22f2 --- /dev/null +++ b/libs/shared/src/testing/remote-data.ts @@ -0,0 +1,13 @@ +import { RemoteData } from '@shared/application/remote-data'; + +/** + * Test-only constructors for `RemoteData` — one line per variant, so a spec + * builds fixtures through the same tagged-union shape production code renders + * (`foldRemote`/``), never a hand-rolled literal that could drift + * from the real type. + */ +export const loading = (): RemoteData => ({ tag: 'Loading' }); + +export const success = (value: T): RemoteData => ({ tag: 'Success', value }); + +export const failure = (error: E): RemoteData => ({ tag: 'Failure', error }); diff --git a/libs/shared/src/testing/value-object.ts b/libs/shared/src/testing/value-object.ts new file mode 100644 index 0000000..08245e0 --- /dev/null +++ b/libs/shared/src/testing/value-object.ts @@ -0,0 +1,14 @@ +import { Result } from '@shared/kernel/fp'; + +/** + * Unwrap a `Result` produced by a REAL `parse*` value-object parser, throwing + * if it isn't `ok`. This is the only sanctioned way for a spec to obtain a + * branded value-object type — it closes off the `'garbage' as Postcode` cast + * route, since the only door to the branded type is the parser itself. + */ +export function unwrapOk(result: Result): T { + if (!result.ok) { + throw new Error(`unwrapOk: expected ok, got error: ${JSON.stringify(result.error)}`); + } + return result.value; +}