diff --git a/docs/TASKS.md b/docs/TASKS.md
index 052855c..402fb39 100644
--- a/docs/TASKS.md
+++ b/docs/TASKS.md
@@ -17,8 +17,13 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen.
als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung,
URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende.
→ `tests/parser.test.js` (18 Tests).
-- [ ] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für
+- [x] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für
Normal- und Vertikalmodus sowie „Untergliederung fehlt“.
+ → `src/model.js` (Baum-/Kostenlogik) + `src/render.js` (`renderTreeHtml`,
+ headless); `app.js` reicht UI-State als Parameter herein.
+ `tests/render.test.js` (6 Tests, Snapshots). Anm.: der Modus
+ (horizontal/vertikal/kompakt) ist reine CSS-Container-Klasse und ändert
+ den Renderer-String nicht — ein Snapshot deckt alle drei Modi ab.
- [ ] Warnungs-Modell vereinheitlichen (Zeilennummern, Typen).
## Phase 2 — Qualität
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index cb2d797..90b57fe 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -38,8 +38,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
Status → URL → Größe → Tags (sonst kollidiert `@` in URLs).
-- Günstigster Pfad: `markCheapest()`/`cheapestCost()` markieren die nötigen
- Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` zeichnet nach jedem
+- Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/
+ Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`,
+ `computeCheapSet`, `cheapCls`), `render.js` (HTML-String via
+ `renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet})`, headless),
+ `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State
+ (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
+ `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`.
+- Günstigster Pfad: `markCheapest()`/`cheapestCost()` (in `model.js`) markieren
+ die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js)
+ zeichnet nach jedem
`render()` **und** nach `applyLayout()` zwei Overlay-SVGs in `#out` (hinten
kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den
CSS-`zoom` von `#out`, Punkte in unskalierte `#out`-Koordinaten umrechnen
diff --git a/frontend/src/app.js b/frontend/src/app.js
index 4f1b7bf..472ea2f 100644
--- a/frontend/src/app.js
+++ b/frontend/src/app.js
@@ -1,5 +1,7 @@
import './style.css';
-import { SIZE_RANK, STATUS_BY_CODE, parse } from './parser.js';
+import { parse } from './parser.js';
+import { computeCheapSet } from './model.js';
+import { esc, renderTreeHtml } from './render.js';
const INITIAL = `%% Project structure – Sprint 14
[~] Website relaunch (XL) https://wiki.example.com/relaunch
@@ -26,113 +28,18 @@ const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
-function esc(s){
- return s.replace(/&/g,'&').replace(//g,'>');
-}
-
-/* ---------- Renderer ---------- */
-function gateOf(children){
- return children.length && children[0].type === 'or' ? 'or' : 'and';
-}
-
-function needsBreakdown(n){
- if(n.status && n.status.key === 'verworfen') return false;
- return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
-}
-
-function visibleChildren(n){
- if(discardedShown()) return n.children;
- return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
-}
-
-/* ---------- Günstigster Pfad ----------
- Markiert die Knoten, die für die günstigste Realisierung nötig sind:
- all-of ⇒ alle Kinder nötig; any-of ⇒ nur die günstigste Alternative.
- „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe + Kinder;
- bei any-of das Minimum). Verworfene zählen nie mit — unabhängig vom
- Einblenden-Toggle. Gleichstand ⇒ erste Alternative. Fehlende Größe = M. */
+/* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster
+ Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt
+ nur der UI-State des Günstigster-Pfad-Toggles (persistiert). */
let cheapPathOn = true;
-const cheapSet = new Set();
-function pathChildren(n){
- return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
-}
-/* fehlende Größe wird als M interpretiert */
-function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; }
-function cheapestCost(n){
- const kids = pathChildren(n);
- let c = ownCost(n);
- if(kids.length){
- if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost));
- else c += kids.reduce((s, k) => s + cheapestCost(k), 0);
- }
- return c;
-}
-function markCheapest(n){
- cheapSet.add(n);
- const kids = pathChildren(n);
- if(!kids.length) return;
- if(gateOf(kids) === 'or'){
- let best = null, bc = Infinity;
- for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } }
- if(best) markCheapest(best);
- } else {
- for(const k of kids) markCheapest(k);
- }
-}
-function cheapCls(n){
- if(!(cheapPathOn && cheapSet.has(n))) return '';
- /* Endknoten des Pfads: kein Kind liegt (mehr) auf dem günstigen Pfad */
- const leaf = !pathChildren(n).some(k => cheapSet.has(k));
- return leaf ? 'cheap cheap-leaf' : 'cheap';
-}
-
-function nodeHtml(n, extra){
- const need = needsBreakdown(n);
- const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
- .filter(Boolean).join(' ');
- const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : '';
- const tagsHtml = n.tags && n.tags.length
- ? `${n.tags.map(tag => `${esc(tag)}`).join('')}`
- : '';
- const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"');
- const sizeBadge = n.size
- ? `${n.size}`
- : (cheapPathOn ? `M` : '');
- const inner = esc(n.label) +
- (n.url ? '↗' : '') +
- sizeBadge +
- tagsHtml;
- const html = n.url
- ? `${inner}`
- : `
${inner}
`;
- const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"');
- const ghost = `${esc(t('ghost'))}
`;
- return html + (need ? ghost : '');
-}
-
-function renderChildren(node, warnings){
- const kids = visibleChildren(node);
- if(!kids.length) return '';
- const types = new Set(kids.map(k => k.type));
- if(types.size > 1){
- warnings.push(t('mixedWarn', {line: kids[0].line, label: esc(node.label)}));
- }
- const gate = gateOf(kids);
- const items = kids.map(k => {
- const vk = visibleChildren(k);
- const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
- return `` +
- nodeHtml(k, cheapCls(k)) +
- renderChildren(k, warnings) +
- ``;
- }).join('');
- return ``;
-}
+/* ---------- Renderer (Anbindung an den DOM) ----------
+ parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
+ render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
function render(){
let {roots} = parse(src.value);
- const warnings = [];
- if(!discardedShown()){
+ const showDiscarded = discardedShown();
+ if(!showDiscarded){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
@@ -142,18 +49,13 @@ function render(){
return;
}
- cheapSet.clear();
- if(cheapPathOn) roots.forEach(markCheapest);
+ const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn);
- out.innerHTML = roots.map(root => {
- const vk = visibleChildren(root);
- const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
- return `` +
- nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) +
- renderChildren(root, warnings) +
- ``;
- }).join('');
+ const {html, warnings} = renderTreeHtml(roots, {
+ t, showDiscarded, cheapPath: cheapPathOn, cheapSet
+ });
+ out.innerHTML = html;
warnBox.innerHTML = warnings.map(w => `⚠ ${w}
`).join('');
drawCheapPath();
diff --git a/frontend/src/model.js b/frontend/src/model.js
new file mode 100644
index 0000000..5ce6dc9
--- /dev/null
+++ b/frontend/src/model.js
@@ -0,0 +1,70 @@
+/* Werkbaum-Modell — headless Baum-/Kostenlogik über den geparsten Knotenbaum.
+ Kein DOM, keine UI-State-Globals: Zustand (verworfene einblenden, günstigster
+ Pfad) wird als Parameter hereingereicht. Grundlage für Renderer, SVG-Export
+ und Mermaid-Plugin. Vgl. docs/SPEC.md §3–§5, §9 und D18. */
+
+import { SIZE_RANK } from './parser.js';
+
+/* Gate der Geschwistergruppe: 'or', wenn das erste Kind '|' trägt, sonst 'and'
+ (SPEC §3 — Darstellung nach dem ersten Kind). */
+export function gateOf(children){
+ return children.length && children[0].type === 'or' ? 'or' : 'and';
+}
+
+/* Untergliederungspflicht ab Größe M ohne Kinder (SPEC §5); verworfene nie. */
+export function needsBreakdown(n){
+ if(n.status && n.status.key === 'verworfen') return false;
+ return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
+}
+
+/* Sichtbare Kinder: verworfene ausblenden, außer showDiscarded ist gesetzt. */
+export function visibleChildren(n, showDiscarded){
+ if(showDiscarded) return n.children;
+ return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
+}
+
+/* ---------- Günstigster Pfad (D18) ----------
+ Nötige Knoten für die günstigste Realisierung: all-of ⇒ alle Kinder,
+ any-of ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive
+ Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie
+ mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende
+ Größe = M. */
+export function pathChildren(n){
+ return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
+}
+/* fehlende Größe wird als M interpretiert */
+export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; }
+export function cheapestCost(n){
+ const kids = pathChildren(n);
+ let c = ownCost(n);
+ if(kids.length){
+ if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost));
+ else c += kids.reduce((s, k) => s + cheapestCost(k), 0);
+ }
+ return c;
+}
+export function markCheapest(n, set){
+ set.add(n);
+ const kids = pathChildren(n);
+ if(!kids.length) return;
+ if(gateOf(kids) === 'or'){
+ let best = null, bc = Infinity;
+ for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } }
+ if(best) markCheapest(best, set);
+ } else {
+ for(const k of kids) markCheapest(k, set);
+ }
+}
+/* Menge der nötigen Knoten über alle Wurzeln. */
+export function computeCheapSet(roots){
+ const set = new Set();
+ roots.forEach(r => markCheapest(r, set));
+ return set;
+}
+/* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ⇒ ''.
+ Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */
+export function cheapCls(n, cheapSet){
+ if(!cheapSet.has(n)) return '';
+ const leaf = !pathChildren(n).some(k => cheapSet.has(k));
+ return leaf ? 'cheap cheap-leaf' : 'cheap';
+}
diff --git a/frontend/src/render.js b/frontend/src/render.js
new file mode 100644
index 0000000..746c64e
--- /dev/null
+++ b/frontend/src/render.js
@@ -0,0 +1,77 @@
+/* Werkbaum-Renderer — erzeugt den HTML-String des Diagrammbaums (die -Liste
+ für #out). Headless: keine DOM-Zugriffe, kein globaler UI-State. Alles kommt
+ über `opts` herein. Der Darstellungsmodus (horizontal/vertikal/kompakt) ist
+ rein CSS (Klasse am Container, von app.js gesetzt) und ändert diesen String
+ NICHT. Vgl. docs/SPEC.md §4–§9, D18.
+
+ opts = {
+ t, // i18n-Funktion (key, vars?) -> String
+ showDiscarded, // verworfene einblenden?
+ cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge)
+ cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus)
+ } */
+
+import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
+
+export function esc(s){
+ return s.replace(/&/g,'&').replace(//g,'>');
+}
+
+function nodeHtml(n, extra, opts){
+ const { t, cheapPath } = opts;
+ const need = needsBreakdown(n);
+ const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
+ .filter(Boolean).join(' ');
+ const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : '';
+ const tagsHtml = n.tags && n.tags.length
+ ? `${n.tags.map(tag => `${esc(tag)}`).join('')}`
+ : '';
+ const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"');
+ const sizeBadge = n.size
+ ? `${n.size}`
+ : (cheapPath ? `M` : '');
+ const inner = esc(n.label) +
+ (n.url ? '↗' : '') +
+ sizeBadge +
+ tagsHtml;
+ const html = n.url
+ ? `${inner}`
+ : `${inner}
`;
+ const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"');
+ const ghost = `${esc(t('ghost'))}
`;
+ return html + (need ? ghost : '');
+}
+
+function renderChildren(node, warnings, opts){
+ const kids = visibleChildren(node, opts.showDiscarded);
+ if(!kids.length) return '';
+ const types = new Set(kids.map(k => k.type));
+ if(types.size > 1){
+ warnings.push(opts.t('mixedWarn', {line: kids[0].line, label: esc(node.label)}));
+ }
+ const gate = gateOf(kids);
+ const items = kids.map(k => {
+ const vk = visibleChildren(k, opts.showDiscarded);
+ const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
+ return `` +
+ nodeHtml(k, cheapCls(k, opts.cheapSet), opts) +
+ renderChildren(k, warnings, opts) +
+ ``;
+ }).join('');
+ return ``;
+}
+
+/* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und
+ sammelt Warnungen (gemischte Gates). Leere Wurzelliste ⇒ leerer String. */
+export function renderTreeHtml(roots, opts){
+ const warnings = [];
+ const html = roots.map(root => {
+ const vk = visibleChildren(root, opts.showDiscarded);
+ const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
+ return `` +
+ nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) +
+ renderChildren(root, warnings, opts) +
+ ``;
+ }).join('');
+ return { html, warnings };
+}
diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap
new file mode 100644
index 0000000..dd55ed4
--- /dev/null
+++ b/frontend/tests/__snapshots__/render.test.js.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
HostingM
"`;
+
+exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
HostingM
"`;
+
+exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
- Frontend↗Sanna
BackendLbencarla
ghost
CMS-AnbindungM
WordPress
Headless CMS
Eigenentwicklung
HostingM
"`;
diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js
new file mode 100644
index 0000000..b6b064f
--- /dev/null
+++ b/frontend/tests/render.test.js
@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+import { parse } from '../src/parser.js';
+import { computeCheapSet } from '../src/model.js';
+import { renderTreeHtml } from '../src/render.js';
+
+/* Deterministischer i18n-Stub: gibt den Key zurück (bzw. interpoliert
+ mixedWarn), damit die Snapshots sprachunabhängig und stabil sind. */
+const t = (key, vars) =>
+ key === 'mixedWarn' ? `mixedWarn(line=${vars.line}, label=${vars.label})` : key;
+
+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`;
+
+/* Rendert die (wie in app.js) vorab gefilterten Wurzeln. */
+function renderExample({showDiscarded = false, cheapPath = false} = {}){
+ let {roots} = parse(SPEC_EXAMPLE);
+ if(!showDiscarded){
+ roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
+ }
+ const cheapSet = cheapPath ? computeCheapSet(roots) : new Set();
+ return renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet});
+}
+
+const count = (html, needle) => html.split(needle).length - 1;
+
+describe('renderTreeHtml — kanonisches Beispiel', () => {
+ it('Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot', () => {
+ const {html, warnings} = renderExample();
+ expect(count(html, 'class="node')).toBe(13); // Eigenentwicklung ausgeblendet
+ expect(count(html, 'cheap-leaf')).toBe(0);
+ expect(count(html, 'size implicit')).toBe(0);
+ expect(count(html, 'ghost-node')).toBe(1); // Backend (L) ist ein M+-Blatt
+ expect(count(html, '')).toBe(2); // CMS-Anbindung, Hosting
+ expect(warnings).toEqual([]);
+ expect(html).toMatchSnapshot();
+ });
+
+ it('günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges', () => {
+ const {html} = renderExample({cheapPath: true});
+ expect(count(html, 'cheap-leaf')).toBeGreaterThan(0);
+ expect(count(html, 'size implicit')).toBeGreaterThan(0); // Cloud/On-Premise ohne Größe
+ expect(html).toMatchSnapshot();
+ });
+
+ it('verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen)', () => {
+ const {html} = renderExample({showDiscarded: true});
+ expect(count(html, 'class="node')).toBe(14);
+ expect(html).toContain('st-verworfen');
+ expect(html).toMatchSnapshot();
+ });
+});
+
+describe('renderTreeHtml — „Untergliederung fehlt" (Geister-Knoten, SPEC §5)', () => {
+ it('M+ ohne Kinder erzeugt genau einen Geister-Knoten', () => {
+ let {roots} = parse('- [ ] Großes Paket (L)');
+ const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
+ expect(count(html, 'ghost-node')).toBe(1);
+ expect(html).toContain('title="ghostTooltip"');
+ expect(html).toMatchInlineSnapshot(`"Großes PaketL
ghost
"`);
+ });
+
+ it('verworfenes M+ löst die Regel nicht aus', () => {
+ let {roots} = parse('- [-] Verworfen groß (XL)');
+ const {html} = renderTreeHtml(roots, {t, showDiscarded: true, cheapPath: false, cheapSet: new Set()});
+ expect(count(html, 'ghost-node')).toBe(0);
+ });
+});
+
+describe('renderTreeHtml — Moduswechsel ist CSS, nicht Renderer', () => {
+ it('erzeugt nie eine Modus-Klasse (vertical/kompakt) — die setzt app.js am Container', () => {
+ const {html} = renderExample();
+ expect(html).not.toMatch(/\bvertical\b/);
+ expect(html).not.toMatch(/\bkompakt\b/);
+ // Also gilt derselbe Renderer-Snapshot für alle drei Modi (horizontal,
+ // vertikal, kompakt) — sie unterscheiden sich nur in der Container-Klasse.
+ });
+});