frontend: Renderer + Modell headless auslagern (src/model.js, src/render.js)
TASKS Phase 1. Baum-/Kostenlogik -> model.js (gateOf, needsBreakdown, visibleChildren, computeCheapSet, cheapCls), HTML-Erzeugung -> render.js (renderTreeHtml). Beide sind headless: UI-State (verworfene einblenden, günstigster Pfad) kommt als Parameter herein statt aus Globals; nur cheapPathOn bleibt UI-State in app.js. render() in app.js ist jetzt die dünne DOM-Anbindung. Snapshot-Tests (tests/render.test.js): Grundzustand, günstigster Pfad, verworfene einblenden, Geister-Knoten („Untergliederung fehlt"), plus die Zusicherung, dass der Renderer nie eine Modus-Klasse erzeugt (Modus = CSS-Container-Klasse in app.js -> ein Snapshot deckt alle drei Modi). 24 Tests grün. Byte-identisches Rendering im Browser verifiziert (18 Knoten, 6 Blätter, 3 Geister, Toggles ok, keine Konsolenfehler). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7c8f216589
commit
5905712aa5
+6
-1
@@ -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
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+16
-114
@@ -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,'<').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
|
||||
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"');
|
||||
const sizeBadge = n.size
|
||||
? `<span class="size">${n.size}</span>`
|
||||
: (cheapPathOn ? `<span class="size implicit" title="${implicitTip}">M</span>` : '');
|
||||
const inner = esc(n.label) +
|
||||
(n.url ? '<span class="ext">↗</span>' : '') +
|
||||
sizeBadge +
|
||||
tagsHtml;
|
||||
const html = n.url
|
||||
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'"')}" target="_blank" rel="noopener"${title}>${inner}</a>`
|
||||
: `<div class="${cls}"${title}>${inner}</div>`;
|
||||
const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"');
|
||||
const ghost = `<div class="ghost-node" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
||||
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 `<li${liCls}>` +
|
||||
nodeHtml(k, cheapCls(k)) +
|
||||
renderChildren(k, warnings) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
return `<ul class="${gate}">${items}</ul>`;
|
||||
}
|
||||
|
||||
/* ---------- 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 `<li${liCls}>` +
|
||||
nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) +
|
||||
renderChildren(root, warnings) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
const {html, warnings} = renderTreeHtml(roots, {
|
||||
t, showDiscarded, cheapPath: cheapPathOn, cheapSet
|
||||
});
|
||||
out.innerHTML = html;
|
||||
|
||||
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${w}</div>`).join('');
|
||||
drawCheapPath();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Werkbaum-Renderer — erzeugt den HTML-String des Diagrammbaums (die <li>-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,'<').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
|
||||
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"');
|
||||
const sizeBadge = n.size
|
||||
? `<span class="size">${n.size}</span>`
|
||||
: (cheapPath ? `<span class="size implicit" title="${implicitTip}">M</span>` : '');
|
||||
const inner = esc(n.label) +
|
||||
(n.url ? '<span class="ext">↗</span>' : '') +
|
||||
sizeBadge +
|
||||
tagsHtml;
|
||||
const html = n.url
|
||||
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'"')}" target="_blank" rel="noopener"${title}>${inner}</a>`
|
||||
: `<div class="${cls}"${title}>${inner}</div>`;
|
||||
const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"');
|
||||
const ghost = `<div class="ghost-node" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
||||
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 `<li${liCls}>` +
|
||||
nodeHtml(k, cheapCls(k, opts.cheapSet), opts) +
|
||||
renderChildren(k, warnings, opts) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
return `<ul class="${gate}">${items}</ul>`;
|
||||
}
|
||||
|
||||
/* 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 `<li${liCls}>` +
|
||||
nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) +
|
||||
renderChildren(root, warnings, opts) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
return { html, warnings };
|
||||
}
|
||||
@@ -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`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node st-geplant" title="st_geplant">WordPress</div></li><li><div class="node st-idee" title="st_idee">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node">Cloud</div></li><li><div class="node">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" title="st_geplant">WordPress<span class="size implicit" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" title="st_idee">Headless CMS<span class="size implicit" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf">Cloud<span class="size implicit" title="implicitSizeTooltip">M</span></div></li><li><div class="node">On-Premise<span class="size implicit" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
|
||||
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node st-geplant" title="st_geplant">WordPress</div></li><li><div class="node st-idee" title="st_idee">Headless CMS</div></li><li><div class="node st-verworfen" title="st_verworfen">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node">Cloud</div></li><li><div class="node">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
@@ -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, '<ul class="or">')).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(`"<li><div class="node root-node st-geplant" title="st_geplant">Großes Paket<span class="size">L</span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li>"`);
|
||||
});
|
||||
|
||||
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.
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user