Files
werkbaum/frontend/tests/parser.test.js
T
mhoennigandClaude Fable 5 59da485af6 feat: Größe ist das letzte alleinstehende (L)-Token der Zeile (D68)
Erkannt nur alleinstehend angesetzt (wie #id und :#…) — damit greifen die
Zitier-Konventionen "(L)" und ((L)) von selbst, und Backend(L) bleibt
Label. Das letzte Token gewinnt statt des ersten: Die Größe steht hinter
dem Titel, frühere Vorkommen sind Text. SPEC §1/§5 und llms.md nachgezogen;
mitgelieferte Beispiele unverändert (Parse-Vergleich alt/neu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:19:52 +02:00

221 lines
8.8 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');
});
/* Größen-Token (SPEC §1 Schritt 4, D68): das LETZTE alleinstehend
angesetzte `(L)`-Token ist die Größe; frühere bleiben im Label, und die
Zitier-Konventionen `"(L)"`/`((L))` halten ein Literal heraus. */
it('das letzte alleinstehende Größen-Token gewinnt, frühere bleiben im Label', () => {
const { roots } = parse('- Variante (L) bauen (M)');
expect(roots[0].size).toBe('M');
expect(roots[0].label).toBe('Variante (L) bauen');
});
it('Größe nur alleinstehend angesetzt: Backend(L) bleibt Label', () => {
const { roots } = parse('- Backend(L)');
expect(roots[0].size).toBe(null);
expect(roots[0].label).toBe('Backend(L)');
});
it('Zitier-Konventionen: "(L)" und ((L)) bleiben Label', () => {
const { roots } = parse('- Merch "(L)" drucken\n- Auswahl ((L)) treffen (S)');
expect(roots[0].size).toBe(null);
expect(roots[0].label).toBe('Merch "(L)" drucken');
expect(roots[1].size).toBe('S');
expect(roots[1].label).toBe('Auswahl ((L)) treffen');
});
it('Größe am Zeilenanfang des Rests zählt weiterhin', () => {
const { roots } = parse('- (L) Backend');
expect(roots[0].size).toBe('L');
expect(roots[0].label).toBe('Backend');
});
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 acht Codes ab (SPEC §4)', () => {
expect(Object.keys(STATUS_BY_CODE).sort())
.toEqual([' ', '!', '-', '/', '?', '^', 'x', '~'].sort());
expect(STATUS_BY_CODE['-'].key).toBe('verworfen');
expect(STATUS_BY_CODE['!'].key).toBe('highrisk');
});
});