feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt: the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and the kern's standaardteksten follow the selection live. Front-end (this increment): - Kern is recomposed reactively from the besluit selection (new BesluitSelected machine msg + composeKern); the "Genereer conceptbrief" button is gone. The drafter's free text is preserved across a selection change. - The editor shows only the editable sections; the locked aanhef/slot render in the preview, not the authoring surface. Slot is a case-type template section (per templateId), documented as such. - The panel re-seeds from the letter via inferSelection() — the besluit + redenen are read back off the kern's passage blocks, so the selection survives reload with no new wire fields (derive, don't store). - letter-section drops the now-redundant per-section passage picker (besluit owns standaardteksten); keeps free-text + block edit/move/remove. Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is omitted. The CIBG styled checkbox routes clicks through the label, so the shared id="undefined" made every reason label toggle the first input — the second checkbox could never be checked. Verified live (Playwright): each reason toggles independently. Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed, carried through the adapter parse boundary. Specs updated (besluit, brief.machine) and the affected stories re-pointed at the new API. FE lint + build + 253 vitest specs green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
|
||||
|
||||
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
|
||||
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
|
||||
passageId: over.passageId ?? 'x',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: over.label ?? 'x',
|
||||
content: block('x'),
|
||||
version: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const lib: LibraryPassage[] = [
|
||||
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
|
||||
p({ passageId: 'pos', besluit: 'positief' }),
|
||||
p({ passageId: 'neg', besluit: 'negatief' }),
|
||||
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
|
||||
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
|
||||
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
|
||||
];
|
||||
|
||||
describe('passagesForBesluit', () => {
|
||||
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'pos']);
|
||||
});
|
||||
|
||||
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg']);
|
||||
});
|
||||
|
||||
it('negatief with a reden ticked includes that reason-specific passage only', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
|
||||
});
|
||||
|
||||
it('preserves library order (= reading order)', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
|
||||
});
|
||||
|
||||
it('never offers non-kern passages', () => {
|
||||
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redenenFor', () => {
|
||||
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
|
||||
expect(redenenFor(lib, 'negatief')).toEqual([
|
||||
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
|
||||
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('positief has no reason-specific redenen', () => {
|
||||
expect(redenenFor(lib, 'positief')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferSelection', () => {
|
||||
// Build the kern blocks a besluit would produce, then read the selection back off them.
|
||||
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
|
||||
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${i + 1}`,
|
||||
sourcePassageId: p.passageId,
|
||||
sourceVersion: p.version,
|
||||
content: p.content,
|
||||
edited: false,
|
||||
}));
|
||||
|
||||
it('round-trips a positief selection', () => {
|
||||
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
|
||||
it('round-trips a negatief selection with redenen (in order)', () => {
|
||||
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
|
||||
expect(inferSelection(blocks, lib)).toEqual({
|
||||
besluit: 'negatief',
|
||||
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty kern (nothing chosen) infers no besluit', () => {
|
||||
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
|
||||
});
|
||||
|
||||
it('ignores free-text blocks and unknown passage ids', () => {
|
||||
const blocks: LetterBlock[] = [
|
||||
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
|
||||
...kern('positief', []),
|
||||
];
|
||||
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user