feat(stamdata): admin stamdata maintenance editor (beheer)
Realizes ADR-0004's "future low-code editor that commits a PR": an
admin-only stamdata maintenance editor built on the stamdata-as-code
foundation.
Backend: `professions` moves from a hardcoded C# dictionary to an embedded
`professions.json` data-file (typed as `ProfessionMapping`) with valid-time
(geldigVan/geldigTot, half-open). A generic, reflection-driven
StamdataCatalog/StamdataTable/StamdataFile describes every table so one
endpoint pair + one grid editor serve all of them; add a table in one line.
Two read-only, admin-gated endpoints (GET /stamdata, GET /stamdata/{table}
?peildatum=) — no runtime write path. Generic build gate
`Every_catalog_table_is_valid` (keys non-blank, no overlapping validity,
well-formed windows).
Frontend: new `beheer` context (route beheer/stamdata, capabilityGuard
'stamdata:edit'). A schema-driven grid editor edits rows locally; download()
emits {table}.json for the admin to commit as a reviewed PR (no mutation
command — the CI build + StamdataValidationTests stay the authority).
Full gate GREEN both sides; gen:api leaves no drift; new stamdata story
passes axe. See WP-29 + ADR-0004.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StamTable, activeOn, changeCounts, isValid, rowErrors, toJson } from './stamdata';
|
||||
|
||||
const professions: StamTable = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: true,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'beroep', type: 'text', isKey: false, options: [] },
|
||||
{ name: 'geldigVan', type: 'date', isKey: false, options: [] },
|
||||
{ name: 'geldigTot', type: 'date', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const row = (program: string, beroep: string, van: string, tot = ''): Record<string, string> => ({
|
||||
program,
|
||||
beroep,
|
||||
geldigVan: van,
|
||||
geldigTot: tot,
|
||||
});
|
||||
|
||||
describe('activeOn (valid-time, half-open [van, tot))', () => {
|
||||
it('includes a row whose window covers the date', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '2020-01-01')).toBe(true);
|
||||
});
|
||||
it('excludes a row before its geldigVan', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false);
|
||||
});
|
||||
it('excludes on the geldigTot boundary (half-open)', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(false);
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowErrors / isValid (format only)', () => {
|
||||
it('flags a blank key', () => {
|
||||
expect(rowErrors(professions, [row('', 'A', '2000-01-01')])[0]).not.toBe('');
|
||||
});
|
||||
it('flags a missing geldigVan on a temporal table', () => {
|
||||
expect(rowErrors(professions, [row('a', 'A', '')])[0]).not.toBe('');
|
||||
});
|
||||
it('flags geldigTot on or before geldigVan', () => {
|
||||
expect(rowErrors(professions, [row('a', 'A', '2020-01-01', '2020-01-01')])[0]).not.toBe('');
|
||||
});
|
||||
it('passes a well-formed row', () => {
|
||||
expect(isValid(professions, [row('a', 'A', '2000-01-01')])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeCounts (diff against the loaded snapshot, by key)', () => {
|
||||
const original = [row('a', 'A', '2000-01-01'), row('b', 'B', '2000-01-01')];
|
||||
it('counts an added key', () => {
|
||||
const draft = [...original, row('c', 'C', '2000-01-01')];
|
||||
expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 });
|
||||
});
|
||||
it('counts a removed key', () => {
|
||||
expect(changeCounts(professions, original, [original[0]])).toEqual({ added: 0, removed: 1, edited: 0 });
|
||||
});
|
||||
it('counts an edited cell', () => {
|
||||
const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]];
|
||||
expect(changeCounts(professions, original, draft)).toEqual({ added: 0, removed: 0, edited: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJson (draft → file shape)', () => {
|
||||
it('reconstructs an open-ended geldigTot as null and pretty-prints', () => {
|
||||
const json = toJson(professions, [row('a', 'Arts', '2000-01-01')]);
|
||||
expect(JSON.parse(json)).toEqual([
|
||||
{ program: 'a', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: null },
|
||||
]);
|
||||
expect(json.endsWith('\n')).toBe(true);
|
||||
});
|
||||
it('coerces a number column', () => {
|
||||
const table: StamTable = {
|
||||
id: 't',
|
||||
label: 't',
|
||||
temporal: false,
|
||||
columns: [
|
||||
{ name: 'code', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'jaar', type: 'number', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([{ code: 'x', jaar: 2020 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user