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>
This commit is contained in:
mhoennig
2026-07-21 21:16:34 +02:00
co-authored by Claude Opus 4.8
parent 8d3ac63574
commit 0a8b3659ea
6 changed files with 88 additions and 14 deletions
+5
View File
@@ -64,6 +64,11 @@ Codiert als Checkbox nach dem Zeichen (Erweiterung der Markdown-Task-Syntax):
- `x` auch als `X` zulässig. - `x` auch als `X` zulässig.
- Verworfene Knoten (inkl. Teilbaum) sind per Default **ausgeblendet**; - Verworfene Knoten (inkl. Teilbaum) sind per Default **ausgeblendet**;
Toggle „verworfene einblenden“ zeigt sie. Toggle „verworfene einblenden“ zeigt sie.
- **Fehlertoleranz:** Ein unbekanntes Zeichen in der Statusbox (z. B. `[z]`)
wird nicht verschluckt: Der Knoten erscheint **neutral** und es wird eine
Warnung `unknownStatus` mit Zeilennummer gemeldet — die Zeile geht nicht
verloren, Folgezeilen bleiben unberührt. (Eine mehrzeichige Klammer wie
`[xyz]` ist keine Statusbox und bleibt Teil des Labels.)
## 5. Aufwand (T-Shirt-Größen) ## 5. Aufwand (T-Shirt-Größen)
+6 -1
View File
@@ -41,7 +41,12 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen.
`@media print` in `style.css`: blendet Kopf/Editor/Splitter/Bedien- `@media print` in `style.css`: blendet Kopf/Editor/Splitter/Bedien-
elemente/Warnungen/Footer aus, Diagramm füllt die Seite; Statusfarben via elemente/Warnungen/Footer aus, Diagramm füllt die Seite; Statusfarben via
`print-color-adjust:exact`, `break-inside:avoid`, Pfad-Overlay inklusive. `print-color-adjust:exact`, `break-inside:avoid`, Pfad-Overlay inklusive.
- [ ] Fehlertolerantes Parsen weiter ausbauen (unbekannte Statuszeichen melden). - [x] Fehlertolerantes Parsen weiter ausbauen (unbekannte Statuszeichen melden).
→ Parser erfasst die Statusbox als beliebiges Einzelzeichen, validiert
gegen `STATUS_BY_CODE`; unbekannte Codes → `unknownStatus`-Warnung (Zeile +
Code), Knoten neutral, Folgezeilen unberührt. `render()` führt Parser- +
Renderer-Warnungen zusammen. i18n `unknownStatusWarn` in allen 9 Sprachen.
`tests/parser.test.js` (5 neue Tests).
## Deployment ## Deployment
- [x] GitHub-Pages-Workflow angelegt (`.github/workflows/pages.yml`, - [x] GitHub-Pages-Workflow angelegt (`.github/workflows/pages.yml`,
+6
View File
@@ -38,6 +38,12 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15). `var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/ - Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
Status → URL → Größe → Tags (sonst kollidiert `@` in URLs). Status → URL → Größe → Tags (sonst kollidiert `@` in URLs).
- Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als *beliebiges*
Einzelzeichen `\[([^\]])\]` und validiert gegen `STATUS_BY_CODE`; unbekannte
Codes → `parse().warnings` als `{type:'unknownStatus', line, code}`, Knoten
neutral. `render()` in app.js führt Parser- und Renderer-Warnungen zusammen
(nach Zeile sortiert) und zeigt sie via `formatWarning` (warnings.js). Neue
Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen.
- Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/ - Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/
Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`, Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`,
`computeCheapSet`, `cheapCls`), `render.js` (HTML-String via `computeCheapSet`, `cheapCls`), `render.js` (HTML-String via
+22 -11
View File
@@ -38,26 +38,28 @@ let cheapPathOn = true;
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */ render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
function render(){ function render(){
let {roots} = parse(src.value); const parsed = parse(src.value);
let roots = parsed.roots;
const showDiscarded = discardedShown(); const showDiscarded = discardedShown();
if(!showDiscarded){ if(!showDiscarded){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
} }
/* Warnungen aus Parser (unbekannte Statuszeichen) + Renderer (gemischte
Gates) zusammenführen, nach Zeile sortiert anzeigen. */
let warnings = parsed.warnings;
if(!roots.length){ if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`; out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
warnBox.innerHTML = ''; } else {
return; const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn);
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
} }
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
out.classList.toggle('cheap-on', cheapPathOn);
const {html, warnings} = renderTreeHtml(roots, {
t, showDiscarded, cheapPath: cheapPathOn, cheapSet
});
out.innerHTML = html;
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join(''); warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join('');
drawCheapPath(); drawCheapPath();
} }
@@ -561,6 +563,7 @@ const I18N = {
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.", mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen", st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yLink:"verlinkt", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
@@ -599,6 +602,7 @@ const I18N = {
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.", mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_verworfen:"discarded", st_fertig:"done", st_prod:"in production", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yLink:"has link", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_all:"sub-task, all required", hint_any:"alternative, choose one",
@@ -637,6 +641,7 @@ const I18N = {
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.", mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado", st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado",
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yLink:"con enlace", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
@@ -675,6 +680,7 @@ const I18N = {
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.", mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné", st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné",
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yLink:"avec lien", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
@@ -713,6 +719,7 @@ const I18N = {
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.", mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone", st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem", a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
@@ -751,6 +758,7 @@ const I18N = {
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.", mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
@@ -789,6 +797,7 @@ const I18N = {
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।", mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
@@ -827,6 +836,7 @@ const I18N = {
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。", mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
@@ -865,6 +875,7 @@ const I18N = {
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。", mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
+11 -2
View File
@@ -31,10 +31,13 @@ export function parse(text){
text.split('\n').forEach((raw, i) => { text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */ raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
if(!raw.trim()) return; if(!raw.trim()) return;
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/); /* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
(fehlertolerant: die Zeile geht nicht verloren). */
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([^\]])\]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length; const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and'; const type = m[2] === '|' ? 'or' : 'and';
const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null; const boxChar = m[3]; // undefined, wenn keine Statusbox
let rest = m[4], url = null, size = null; let rest = m[4], url = null, size = null;
const tags = []; const tags = [];
@@ -44,6 +47,12 @@ export function parse(text){
const label = rest.replace(/\s+/g, ' ').trim(); const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return; if(!label) return;
let status = null;
if(boxChar != null){
status = STATUS_BY_CODE[boxChar.toLowerCase()] || null;
if(!status) warnings.push({type:'unknownStatus', line:i+1, code:boxChar});
}
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node; const parent = stack[stack.length-1].node;
+38
View File
@@ -139,6 +139,44 @@ describe('parse — Randfälle', () => {
}); });
}); });
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', () => { describe('Vokabular-Exporte', () => {
it('SIZE_RANK ist aufsteigend geordnet (SPEC §5)', () => { it('SIZE_RANK ist aufsteigend geordnet (SPEC §5)', () => {
expect(SIZE_RANK).toEqual({ XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 }); expect(SIZE_RANK).toEqual({ XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 });