`machine-remote-data.ts` defined a third encoding of an in-flight fetch: `LoadLifecycle`. It had three call sites, all one identical line, and the type was never imported by name. Move the mapping into `remote-data.ts` as `fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData` constructor, not a sixth encoding. The lowercase `loading`/`failed`/`loaded` tags on `BriefState`, `OrgTemplateState` and `StamdataEditorState` existed only because `LoadLifecycle` required them. Now that the constraint is inline and PascalCase, the three machines' load-lifecycle tags become `Loading`, `Failed` and `Loaded` — matching their own PascalCase message tags in the same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase message producing a lowercase state. `BriefStatus` (the letter's draft/submitted/approved/rejected/sent status, parsed off the wire from `BriefViewDto`) is a separate tag family and is untouched — its tag count stays 54 before and after this change. Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`. Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since `remote-data.ts` carries a showcase region. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { Result } from '@shared/kernel/fp';
|
|
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
|
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
|
import { OrgTemplate } from '@brief/domain/org-template';
|
|
import {
|
|
BRIEF_LOAD_FAILED,
|
|
BriefAdapter,
|
|
BriefLoadFailure,
|
|
BriefView,
|
|
} from '@brief/infrastructure/brief.adapter';
|
|
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
|
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
|
import { BriefStore } from './brief.store';
|
|
|
|
const decisions: BriefDecisions = {
|
|
canEdit: true,
|
|
canApprove: true,
|
|
canReject: true,
|
|
canSend: true,
|
|
canRevealBigNummer: true,
|
|
};
|
|
|
|
const brief: Brief = {
|
|
briefId: 'b1',
|
|
beroep: 'arts',
|
|
templateId: 't1',
|
|
placeholders: [],
|
|
sections: [],
|
|
status: { tag: 'draft' },
|
|
drafterId: 'u1',
|
|
};
|
|
|
|
const orgTemplate: OrgTemplate = {
|
|
subOrgId: 'cibg-registers',
|
|
orgName: 'CIBG — Registers',
|
|
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
|
footerContact: 'info@voorbeeld.example',
|
|
footerLegal: 'KvK 00000000',
|
|
signatureName: 'A. de Vries',
|
|
signatureRole: 'Hoofd Registratie',
|
|
signatureClosing: 'Met vriendelijke groet,',
|
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
|
version: 1,
|
|
};
|
|
|
|
const caseContext: CaseContext = {
|
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
|
bigNummer: '19012345601',
|
|
beroep: 'arts',
|
|
aanvraagReferentie: 'HER-2026-000842',
|
|
};
|
|
|
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
|
|
|
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
|
|
touching the DOM, so a spec can assert a command's success path directly. */
|
|
function fakeBlobPresenter() {
|
|
const opened: Blob[] = [];
|
|
const presenter: BlobPresenter = {
|
|
open: (blob) => opened.push(blob),
|
|
download: () => {
|
|
throw new Error('not used by BriefStore');
|
|
},
|
|
};
|
|
return { presenter, opened };
|
|
}
|
|
|
|
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{ provide: BriefAdapter, useValue: adapter },
|
|
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
|
|
],
|
|
});
|
|
return TestBed.inject(BriefStore);
|
|
}
|
|
|
|
describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|
it('is Busy synchronously once a transition starts', async () => {
|
|
const approved: BriefView = {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
};
|
|
const store = setup({
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: approved }),
|
|
});
|
|
await store.load();
|
|
|
|
const pending = store.approve();
|
|
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
|
|
|
|
await pending; // settle before the test ends
|
|
});
|
|
|
|
it('settles to Idle on a successful transition', async () => {
|
|
const approved: BriefView = {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
};
|
|
const store = setup({
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: approved }),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
|
const store = setup({
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBe('niet toegestaan');
|
|
});
|
|
|
|
it('a subsequent successful transition clears a prior Failed state', async () => {
|
|
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
|
const store = setup({
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.lastError()).toBe('eerste poging mislukt');
|
|
|
|
approveResult = {
|
|
ok: true,
|
|
value: {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
},
|
|
};
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// --- WP-27: undo/redo history + rejection diff ---
|
|
|
|
function block(id: string, text: string): LetterBlock {
|
|
return {
|
|
type: 'freeText',
|
|
blockId: id,
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
|
};
|
|
}
|
|
const kern = (blocks: LetterBlock[]) => ({
|
|
sectionKey: 'kern',
|
|
title: 'Kern',
|
|
required: true,
|
|
locked: false,
|
|
blocks,
|
|
});
|
|
const filledBrief: Brief = { ...brief, sections: [kern([block('local-1', 'x')])] };
|
|
const filledView: BriefView = { ...view, brief: filledBrief };
|
|
|
|
function loadedBrief(store: BriefStore): Brief {
|
|
const s = store.model();
|
|
if (s.tag !== 'Loaded') throw new Error('not loaded');
|
|
return s.brief;
|
|
}
|
|
|
|
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
|
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
|
|
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
|
|
// channel `string`) — it only ever produces the `ok: true` branch.
|
|
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
|
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
|
await store.load();
|
|
return store;
|
|
}
|
|
|
|
describe('BriefStore undo/redo history', () => {
|
|
it('starts with nothing to undo', async () => {
|
|
// Given a freshly loaded brief.
|
|
// When no edit has happened yet...
|
|
const store = await loadedStore();
|
|
|
|
// Then there is nothing to undo.
|
|
expect(store.canUndo()).toBe(false);
|
|
});
|
|
|
|
it('records an edit and makes it undoable', async () => {
|
|
// Given a loaded brief with one block.
|
|
const store = await loadedStore();
|
|
|
|
// When a block is removed...
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
|
|
|
// Then the block is gone and the edit becomes undoable.
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
|
expect(store.canUndo()).toBe(true);
|
|
});
|
|
|
|
it('undo reverts the edit and enables redo', async () => {
|
|
// Given a brief with one recorded edit (a removed block).
|
|
const store = await loadedStore();
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
|
|
|
// When the edit is undone...
|
|
store.undo();
|
|
|
|
// Then the block is back, and redo becomes available.
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
|
expect(store.canRedo()).toBe(true);
|
|
});
|
|
|
|
it('redo reapplies the undone edit', async () => {
|
|
// Given an edit that was undone.
|
|
const store = await loadedStore();
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
|
store.undo();
|
|
|
|
// When it is redone...
|
|
store.redo();
|
|
|
|
// Then the edit is reapplied.
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
|
});
|
|
|
|
it('a no-op edit does not clear the redo future', async () => {
|
|
// Given an undone edit, with redo available.
|
|
const store = await loadedStore();
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
|
store.undo(); // back to 1 block, redo available
|
|
|
|
// When an edit that changes nothing (an unknown block) is applied...
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
|
|
|
// Then the no-op leaves no dead history step — redo is still available.
|
|
expect(store.canRedo()).toBe(true);
|
|
});
|
|
|
|
it('a new edit clears the redo future', async () => {
|
|
const store = await loadedStore();
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
store.undo();
|
|
expect(store.canRedo()).toBe(true);
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
expect(store.canRedo()).toBe(false);
|
|
});
|
|
|
|
it('caps history at 50 snapshots', async () => {
|
|
const store = await loadedStore();
|
|
for (let i = 0; i < 55; i++) store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
let undos = 0;
|
|
while (store.canUndo()) {
|
|
store.undo();
|
|
undos++;
|
|
}
|
|
expect(undos).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('BriefStore rejection diff', () => {
|
|
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
|
|
const submitted: Brief = {
|
|
...filledBrief,
|
|
status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' },
|
|
};
|
|
const rejected: Brief = {
|
|
...filledBrief,
|
|
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
|
};
|
|
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
|
const store = setup({
|
|
load: () => ok({ ...filledView, brief: submitted }),
|
|
save: () => ok(filledView),
|
|
reject: () => ok({ ...filledView, brief: rejected }),
|
|
});
|
|
await store.load();
|
|
await store.reject('nee');
|
|
expect(store.hasRejectionDiff()).toBe(false); // nothing changed yet
|
|
|
|
store.edit({
|
|
tag: 'BlockContentEdited',
|
|
blockId: 'local-1',
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'CHANGED' }] }] },
|
|
});
|
|
expect(store.blockDiffs().get('local-1')).toBe('changed');
|
|
expect(store.removedSinceReject()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.previewLetter', () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
|
|
const { presenter, opened } = fakeBlobPresenter();
|
|
const store = setup(
|
|
{
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
},
|
|
presenter,
|
|
);
|
|
await store.load();
|
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
|
ok: true,
|
|
value: blob,
|
|
});
|
|
|
|
await store.previewLetter();
|
|
expect(opened).toEqual([blob]);
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('surfaces the error without opening a tab on failure', async () => {
|
|
const { presenter, opened } = fakeBlobPresenter();
|
|
const store = setup(
|
|
{
|
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: view }),
|
|
},
|
|
presenter,
|
|
);
|
|
await store.load();
|
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
|
ok: false,
|
|
error: PREVIEW_FAILED,
|
|
});
|
|
|
|
await store.previewLetter();
|
|
expect(opened).toHaveLength(0);
|
|
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
// Loaded with a MASKED BIG-nummer, as the server ships it by default.
|
|
const maskedView: BriefView = {
|
|
...view,
|
|
caseContext: { ...caseContext, bigNummer: '********601' },
|
|
};
|
|
|
|
it('swaps the masked value for the revealed one on success', async () => {
|
|
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
|
await store.load();
|
|
expect(store.caseContext()?.bigNummer).toBe('********601');
|
|
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
|
ok: true,
|
|
value: '19012345601',
|
|
});
|
|
|
|
await store.revealBigNummer();
|
|
expect(store.caseContext()?.bigNummer).toBe('19012345601');
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('keeps the value masked and surfaces the error on failure', async () => {
|
|
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
|
await store.load();
|
|
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
|
ok: false,
|
|
error: 'geweigerd',
|
|
});
|
|
|
|
await store.revealBigNummer();
|
|
expect(store.caseContext()?.bigNummer).toBe('********601'); // unchanged
|
|
expect(store.lastError()).toBe('geweigerd');
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
|
const okSave = () =>
|
|
vi.fn(() => Promise.resolve({ ok: true, value: filledView } as Result<string, BriefView>));
|
|
|
|
it('flushes a pending debounced edit immediately and clears the pending flag', async () => {
|
|
const save = okSave();
|
|
const store = await loadedStore({ save });
|
|
expect(store.hasPendingSave()).toBe(false);
|
|
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
expect(store.hasPendingSave()).toBe(true); // 600ms debounce armed, not yet fired
|
|
|
|
await store.flushPending();
|
|
expect(save).toHaveBeenCalledTimes(1); // no timer wait needed
|
|
expect(store.hasPendingSave()).toBe(false); // timer consumed
|
|
});
|
|
|
|
it('is a no-op when no edit is pending', async () => {
|
|
const save = okSave();
|
|
const store = await loadedStore({ save });
|
|
await store.flushPending();
|
|
expect(save).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
|
|
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
|
|
// that); this fake adapter is what exercises the branch until then. ---
|
|
|
|
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
|
|
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
|
|
const resetOk: Result<string, BriefView> = { ok: true, value: view };
|
|
|
|
it('a 404 drives exactly one reset(), which populates the store', async () => {
|
|
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
|
|
const load = vi.fn(() => Promise.resolve(notFound));
|
|
const reset = vi.fn(() => Promise.resolve(resetOk));
|
|
const store = setup({ load, reset });
|
|
|
|
// When the store loads...
|
|
await store.load();
|
|
|
|
// Then reset() ran exactly once, and the store ends up loaded from its result.
|
|
expect(reset).toHaveBeenCalledTimes(1);
|
|
expect(store.model().tag).toBe('Loaded');
|
|
});
|
|
|
|
it('a second 404 does not drive a second reset()', async () => {
|
|
// Given every load() attempt 404s (e.g. the brief still fails to appear).
|
|
const load = vi.fn(() => Promise.resolve(notFound));
|
|
const reset = vi.fn(() => Promise.resolve(resetOk));
|
|
const store = setup({ load, reset });
|
|
|
|
// When the store loads twice...
|
|
await store.load();
|
|
await store.load();
|
|
|
|
// Then reset() ran exactly once — the once-only bound holds across calls, not
|
|
// just within one — and the second 404 surfaces as an ordinary load failure.
|
|
expect(reset).toHaveBeenCalledTimes(1);
|
|
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
|
|
});
|
|
});
|