feat(brief): letter composition + two-person approval (teaching slice)
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>
This commit is contained in:
49
src/app/brief/domain/brief.spec.ts
Normal file
49
src/app/brief/domain/brief.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||
];
|
||||
|
||||
const content = (...keys: string[]): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: keys.map((key) => ({ type: 'placeholder', key })) }],
|
||||
});
|
||||
|
||||
const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
|
||||
type: 'freeText',
|
||||
blockId,
|
||||
content: content(...keys),
|
||||
});
|
||||
|
||||
function brief(sections: Brief['sections']): Brief {
|
||||
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' };
|
||||
}
|
||||
|
||||
describe('brief selectors', () => {
|
||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||
{ sectionKey: 's2', title: 'S2', required: false, blocks: [passage('local-2', 'reden')] },
|
||||
]);
|
||||
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||
});
|
||||
|
||||
it('allDiagnostics flattens across sections and blocks', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||
]);
|
||||
const codes = allDiagnostics(b).map((d) => d.code);
|
||||
expect(codes).toContain('unresolved-at-send'); // reden
|
||||
expect(codes).toContain('unknown-placeholder'); // onbekend
|
||||
expect(hasBlockingErrors(allDiagnostics(b))).toBe(true); // unknown is an error
|
||||
});
|
||||
|
||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [] }]))).toBe(false);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, blocks: [] }]))).toBe(true);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1')] }]))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user