frontend: Parser-Unit-Tests (Vitest) gegen SPEC §10 + Randfälle

TASKS Phase 1. tests/parser.test.js mit dem kanonischen SPEC-§10-Beispiel als
Fixture und den geforderten Randfällen: URL mit @, %% am Zeilenanfang/-ende,
leere Labels, mehrere Wurzeln, Tab = 2 Leerzeichen, ungleichmäßige Einrückung,
gemischte Gates, x/X + case-insensitive Größe, Vokabular-Exporte. 18 Tests grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-21 20:51:30 +02:00
co-authored by Claude Opus 4.8
parent 80d05a33a2
commit 7c8f216589
3 changed files with 154 additions and 1 deletions
+2 -1
View File
@@ -13,9 +13,10 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen.
- [x] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8. - [x] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8.
`src/parser.js` exportiert `parse`, `STATUS_BY_CODE`, `SIZE_RANK` `src/parser.js` exportiert `parse`, `STATUS_BY_CODE`, `SIZE_RANK`
(headless, kein DOM); `app.js` importiert sie. (headless, kein DOM); `app.js` importiert sie.
- [ ] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10 - [x] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10
als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung, als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung,
URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende. URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende.
`tests/parser.test.js` (18 Tests).
- [ ] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für - [ ] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für
Normal- und Vertikalmodus sowie „Untergliederung fehlt“. Normal- und Vertikalmodus sowie „Untergliederung fehlt“.
- [ ] Warnungs-Modell vereinheitlichen (Zeilennummern, Typen). - [ ] Warnungs-Modell vereinheitlichen (Zeilennummern, Typen).
View File
+152
View File
@@ -0,0 +1,152 @@
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('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');
});
});