Files
werkbaum/frontend/tests/render.test.js
T
mhoennigandClaude Opus 4.8 5905712aa5 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>
2026-07-21 20:58:47 +02:00

91 lines
3.9 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 } 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.
});
});