New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
67 lines
2.9 KiB
TypeScript
67 lines
2.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
|
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
|
|
|
|
const view: BriefViewDto = {
|
|
brief: {
|
|
briefId: 'b1',
|
|
beroep: 'arts',
|
|
templateId: 't1',
|
|
drafterId: 'demo-drafter',
|
|
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
|
|
placeholders: [
|
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
|
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
|
|
],
|
|
sections: [
|
|
{
|
|
sectionKey: 'aanhef',
|
|
title: 'Aanhef',
|
|
required: true,
|
|
blocks: [
|
|
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true },
|
|
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
availablePassages: [
|
|
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 },
|
|
],
|
|
};
|
|
|
|
describe('brief.adapter parse boundary', () => {
|
|
it('parses a well-formed view into the domain unions', () => {
|
|
const r = parseBriefView(view);
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' });
|
|
const [passage, free] = r.value.brief.sections[0].blocks;
|
|
expect(passage.type === 'passage' && passage.edited).toBe(true);
|
|
expect(free.type).toBe('freeText');
|
|
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false });
|
|
});
|
|
|
|
it('narrows node variants and rejects unknown ones', () => {
|
|
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } });
|
|
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } });
|
|
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
|
|
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
|
|
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
|
|
});
|
|
|
|
it('rejects a status DTO missing its required fields', () => {
|
|
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
|
|
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
|
|
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
|
});
|
|
|
|
it('rejects a passage block missing provenance', () => {
|
|
const r = parseBrief({
|
|
...view.brief,
|
|
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|