Files
werkbaum/frontend/tests/parser.test.js
T
mhoennigandClaude Opus 4.8 0a8b3659ea frontend: Fehlertolerantes Parsen — unbekannte Statuszeichen melden
TASKS Phase 2 (Phase 2 abgeschlossen). Der Parser erfasst die Statusbox jetzt
als beliebiges Einzelzeichen `\[([^\]])\]` und validiert gegen STATUS_BY_CODE.
Unbekannte Codes (z. B. [z]) werden nicht mehr still ins Label geschluckt,
sondern als {type:'unknownStatus', line, code} in parse().warnings gemeldet;
der Knoten bleibt neutral, Folgezeilen unberührt. Mehrzeichige Klammern ([xyz])
sind keine Statusbox und bleiben im Label (kein Fehlalarm).

render() in app.js führt Parser- und Renderer-Warnungen zusammen (nach Zeile
sortiert) und zeigt sie via formatWarning. Neuer i18n-Key unknownStatusWarn in
allen 9 Sprachen.

Verifiziert: mehrere unbekannte Codes -> zeilensortierte, lokalisierte
Warnungen (DE/EN), Knoten neutral, keine Konsolenfehler. tests/parser.test.js
+5 (34 Tests grün). SPEC §4 + CLAUDE ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:16:34 +02:00

191 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { parse, STATUS_BY_CODE, SIZE_RANK } from '../src/parser.js';
/* Kanonisches Beispiel aus docs/SPEC.md §10 — die verbindliche Fixture.
Bei Syntaxänderungen: SPEC zuerst, dann diese Fixture + Assertions. */
const SPEC_EXAMPLE = `%% Projektstruktur Stand Sprint 14
[~] Website-Relaunch (XL) https://wiki.example.de/relaunch
- [x] Konzeption (M)
- [x] Zielgruppenanalyse (S)
- [x] Sitemap (XS)
- [~] Umsetzung (XL)
- [/] Frontend (S) https://git.example.de/frontend @anna
- [ ] Backend (L) @ben @carla
- [ ] CMS-Anbindung (M)
| [ ] WordPress
| [?] Headless CMS
| [-] Eigenentwicklung %% Aufwand zu hoch
- [?] Hosting (M)
| Cloud
| On-Premise`;
describe('parse — kanonisches SPEC-§10-Beispiel', () => {
const { roots, warnings } = parse(SPEC_EXAMPLE);
it('erzeugt genau eine Wurzel mit allen Bestandteilen', () => {
expect(roots).toHaveLength(1);
const r = roots[0];
expect(r.label).toBe('Website-Relaunch');
expect(r.status.key).toBe('arbeit');
expect(r.size).toBe('XL');
expect(r.url).toBe('https://wiki.example.de/relaunch');
expect(r.type).toBe('and'); // kein Gate-Zeichen -> and
expect(r.tags).toEqual([]);
expect(r.line).toBe(2); // Zeile 1 ist der %%-Kommentar
});
it('bildet die Hierarchie über die Einrückung ab', () => {
const r = roots[0];
expect(r.children.map(c => c.label)).toEqual(['Konzeption', 'Umsetzung', 'Hosting']);
const [konz, ums, host] = r.children;
expect(konz.children.map(c => c.label)).toEqual(['Zielgruppenanalyse', 'Sitemap']);
expect(ums.children.map(c => c.label)).toEqual(['Frontend', 'Backend', 'CMS-Anbindung']);
expect(host.children.map(c => c.label)).toEqual(['Cloud', 'On-Premise']);
});
it('extrahiert Status, Größe, URL und Tags korrekt', () => {
const ums = roots[0].children[1];
const [fe, be, cms] = ums.children;
expect(fe.status.key).toBe('durchstich');
expect(fe.size).toBe('S');
expect(fe.url).toBe('https://git.example.de/frontend');
expect(fe.tags).toEqual(['anna']);
expect(be.status.key).toBe('geplant');
expect(be.size).toBe('L');
expect(be.tags).toEqual(['ben', 'carla']);
expect(cms.size).toBe('M');
});
it('setzt das Gate je Kind (any-of unter CMS-Anbindung)', () => {
const cms = roots[0].children[1].children[2];
expect(cms.children.map(c => c.type)).toEqual(['or', 'or', 'or']);
expect(cms.children.map(c => c.status.key))
.toEqual(['geplant', 'idee', 'verworfen']);
// Kommentar hinter „Eigenentwicklung" ist entfernt
expect(cms.children[2].label).toBe('Eigenentwicklung');
});
it('Hosting-Alternativen ohne Status bleiben neutral', () => {
const host = roots[0].children[2];
expect(host.status.key).toBe('idee');
expect(host.children.every(c => c.status === null)).toBe(true);
expect(host.children.every(c => c.type === 'or')).toBe(true);
});
it('liefert (noch) keine Warnungen', () => {
expect(warnings).toEqual([]);
});
});
describe('parse — Randfälle', () => {
it('URL mit @ kollidiert nicht mit Personen-Tags', () => {
const { roots } = parse('- Deploy https://user@host.example.com/path @ben');
expect(roots[0].url).toBe('https://user@host.example.com/path');
expect(roots[0].tags).toEqual(['ben']);
expect(roots[0].label).toBe('Deploy');
});
it('%% am Zeilenanfang und am Zeilenende', () => {
const { roots } = parse('%% ganze Zeile Kommentar\n- Aufgabe %% Rest weg');
expect(roots).toHaveLength(1);
expect(roots[0].label).toBe('Aufgabe');
});
it('leere Labels (nur Zeichen/Status) werden ignoriert', () => {
const { roots } = parse('- [x]\n- Echt');
expect(roots.map(r => r.label)).toEqual(['Echt']);
});
it('mehrere Wurzeln stehen nebeneinander', () => {
const { roots } = parse('Baum A\nBaum B\n - Kind von B');
expect(roots.map(r => r.label)).toEqual(['Baum A', 'Baum B']);
expect(roots[1].children.map(c => c.label)).toEqual(['Kind von B']);
});
it('Tab zählt als zwei Leerzeichen (gleiche Ebene)', () => {
const { roots } = parse('- P\n\t- Tab-Kind\n - Space-Kind');
expect(roots).toHaveLength(1);
expect(roots[0].children.map(c => c.label)).toEqual(['Tab-Kind', 'Space-Kind']);
});
it('ungleichmäßige Einrückung: Elternknoten ist die nächste kleinere Breite', () => {
const { roots } = parse('- P\n - tief eingerückt\n - noch tiefer');
const p = roots[0];
expect(p.children.map(c => c.label)).toEqual(['tief eingerückt']);
expect(p.children[0].children.map(c => c.label)).toEqual(['noch tiefer']);
});
it('gemischte Gates werden je Zeile treu übernommen', () => {
const { roots } = parse('- P\n - und-Kind\n | oder-Kind');
expect(roots[0].children.map(c => c.type)).toEqual(['and', 'or']);
});
it('Status akzeptiert x und X; Größe ist case-insensitive', () => {
const { roots } = parse('- [X] Fertig groß (xxl)\n- [x] Fertig klein (m)');
expect(roots[0].status.key).toBe('fertig');
expect(roots[0].size).toBe('XXL');
expect(roots[1].status.key).toBe('fertig');
expect(roots[1].size).toBe('M');
});
it('ohne Angaben: status/url/size null, tags leer', () => {
const { roots } = parse('- Nackt');
expect(roots[0]).toMatchObject({ status: null, url: null, size: null, tags: [] });
});
it('leerer/Whitespace-Text ergibt keine Wurzeln', () => {
expect(parse('').roots).toEqual([]);
expect(parse(' \n\t\n ').roots).toEqual([]);
});
});
describe('parse — fehlertolerant: unbekannte Statuszeichen', () => {
it('meldet unbekannten Code, stellt Knoten neutral dar, Label bleibt sauber', () => {
const {roots, warnings} = parse('- [z] Aufgabe');
expect(roots).toHaveLength(1);
expect(roots[0].status).toBeNull();
expect(roots[0].label).toBe('Aufgabe'); // Box aus dem Label entfernt
expect(warnings).toEqual([{type: 'unknownStatus', line: 1, code: 'z'}]);
});
it('bricht nachfolgende Zeilen nicht (Geschwister/Kinder bleiben heil)', () => {
const {roots, warnings} = parse('- [z] Kaputt\n- [x] Heil\n - [~] Kind');
expect(roots.map(r => r.label)).toEqual(['Kaputt', 'Heil']);
expect(roots[1].status.key).toBe('fertig');
expect(roots[1].children[0].status.key).toBe('arbeit');
expect(warnings).toEqual([{type: 'unknownStatus', line: 1, code: 'z'}]);
});
it('gültige Codes (inkl. X) lösen keine Warnung aus', () => {
const {warnings} = parse('- [X] a\n- [ ] b\n- [~] c\n- [/] d\n- [^] e\n- [-] f\n- [?] g');
expect(warnings).toEqual([]);
});
it('mehrzeichige Klammern sind keine Statusbox — kein Fehlalarm', () => {
const {roots, warnings} = parse('- [xyz] Text');
expect(warnings).toEqual([]);
expect(roots[0].label).toBe('[xyz] Text'); // bleibt im Label
expect(roots[0].status).toBeNull();
});
it('sammelt mehrere unbekannte Codes mit ihren Zeilennummern', () => {
const {warnings} = parse('- [x] ok\n- [q] eins\n- [ ] ok\n- [w] zwei');
expect(warnings).toEqual([
{type: 'unknownStatus', line: 2, code: 'q'},
{type: 'unknownStatus', line: 4, code: 'w'},
]);
});
});
describe('Vokabular-Exporte', () => {
it('SIZE_RANK ist aufsteigend geordnet (SPEC §5)', () => {
expect(SIZE_RANK).toEqual({ XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 });
});
it('STATUS_BY_CODE deckt alle sieben Codes ab (SPEC §4)', () => {
expect(Object.keys(STATUS_BY_CODE).sort())
.toEqual([' ', '-', '/', '?', '^', 'x', '~'].sort());
expect(STATUS_BY_CODE['-'].key).toBe('verworfen');
});
});