Files
werkbaum/frontend/tests/render.test.js
T
mhoennigandClaude Opus 5 f0fe877d45 feat(diagram): #-Umschalter blendet die Knoten-IDs vor dem Titel ein
Die ID ist die Adresse eines Knotens — Ziel der Abhängigkeiten, Schlüssel der
Beschreibungsblöcke —, stand im Diagramm aber nur im Tooltip (D36). Jetzt
umschaltbar und geschrieben wie im Text: `#some.id: Titel`.

- Als RENDERER-Option, nicht per CSS versteckt: `diagramToSvg()` zieht den
  Knotentext aus `textContent`, ein `display:none` hätte die IDs trotzdem in
  jeden Export geschrieben. Nachgemessen im SVG: `>#not.line: Line format<`
  mit Umschalter, `>Line format<` ohne.
- Zurückgenommen dargestellt (Mono wie im Textfeld, --muted), `aria-hidden` —
  der Screenreader bekommt die ID über a11yId (D36). Zustand persistiert.

Das zehnte Bedienelement hat die Kopfzeile gekippt, zweimal dieselbe Rechnung
wie D17-Nachtrag 5:
- Schreibtisch: 738 nötig, 728 da -> 82 statt 49 px. Nicht die Knöpfe (566 px
  für elf Elemente), sondern zehn Lücken à 14 px = 140 px. `gap:10` gibt
  40 px zurück; danach 698 von 728 und wieder 49 px mit UND ohne Knopf.
- Telefon (375 px): alle Ziele schon auf 29 px, Lücken auf 6 — ein zehntes
  passt arithmetisch nicht (386 gegen 359). D50s Umbruch-Schwelle wandert von
  360 auf 440 px. Greift nur mit dem „Was ist neu?"-Knopf: mit allen zehn
  78 px/zwei Reihen, ohne ihn 49 px/eine Reihe.

SPEC §9 + D56; Plan: #ed.showids [x] (160 Knoten, 0 Warnungen).
293 Tests grün (288+5); i18n `idsTooltip` in 9 Sprachen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:55:04 +02:00

183 lines
8.5 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
+ [?] Dark Mode (S) %% Zugabe, nicht erforderlich
- [ ] 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(14); // 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(html).toMatch(/class="node opt st-idee"[^>]*>Dark Mode/); // `+` (SPEC §3)
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(15);
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" tabindex="0" data-line="1" aria-label="Großes Paket, a11yStatus, a11ySize" title="st_geplant · jumpHint">Großes Paket<span class="size" aria-hidden="true">L</span></div><div class="ghost-node" aria-label="ghostTooltip" 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 — strukturierte Warnungen', () => {
it('gemischte Gates ergeben eine {type:mixedGate, line, label}-Warnung', () => {
let {roots} = parse('- Eltern\n - und-Kind\n | oder-Kind');
const {warnings} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Eltern'}]);
});
it('einheitliche Gates ergeben keine Warnung', () => {
let {roots} = parse('- Eltern\n - a\n - b');
const {warnings} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
expect(warnings).toEqual([]);
});
});
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.
});
});
/* Grundlage für den Sprung Diagramm <-> Text (D25, SPEC §9): Jeder Knoten muss
die Zeilennummer aus dem Parser als data-line tragen — auch verlinkte Knoten
(die als <a> gerendert werden) und Knoten in any-of-Gruppen. */
describe('renderTreeHtml — data-line je Knoten (D25)', () => {
const lineOf = (html, label) => {
/* Eltern-Knoten tragen seit D38 das Falt-Zeichen (▾/▸) vor dem Label. */
const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>'
+ '(?:<span class="fold"[^>]*>[^<]*</span>)?' + label);
const m = html.match(re);
return m ? Number(m[1]) : null;
};
it('trägt die Parser-Zeilennummer an jeden Knoten', () => {
const {html} = renderExample({showDiscarded: true});
expect(lineOf(html, 'Website-Relaunch')).toBe(2); // Zeile 1 ist ein %%-Kommentar
expect(lineOf(html, 'Zielgruppenanalyse')).toBe(4);
expect(lineOf(html, 'Frontend')).toBe(7); // verlinkt -> <a>
expect(lineOf(html, 'Dark Mode')).toBe(9); // optionaler Knoten (+)
expect(lineOf(html, 'Headless CMS')).toBe(12); // any-of-Alternative
expect(lineOf(html, 'Eigenentwicklung')).toBe(13); // verworfen, eingeblendet
});
it('vergibt jede Zeilennummer genau einmal', () => {
const {html} = renderExample({showDiscarded: true});
const lines = [...html.matchAll(/class="node[^"]*"[^>]*?data-line="(\d+)"/g)].map(m => m[1]);
expect(lines.length).toBe(new Set(lines).size);
expect(lines.length).toBe(15); // alle Zeilen des SPEC-Beispiels außer dem Kommentar
});
it('der Geister-Knoten bekommt keine Zeilennummer (er steht für keine Zeile)', () => {
const {html} = renderTreeHtml(parse('[ ] Großes Paket (L)').roots,
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
expect(html).toMatch(/class="ghost-node"/);
expect(html).not.toMatch(/ghost-node[^>]*data-line/);
});
});
/* Knoten-IDs im Diagramm (D56). Als Renderer-Option gebaut und nicht per CSS
versteckt — nur so folgt der Grafikexport, der den Knotentext ausliest. */
describe('showIds — die ID vor dem Titel, geschrieben wie im Text', () => {
const ids = (text, showIds) => renderTreeHtml(parse(text).roots,
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set(), showIds}).html;
it('bleibt ohne Umschalter aus dem Knotentext heraus', () => {
const html = ids('#auth: Backend', false);
expect(html).toContain('>Backend<');
expect(html).not.toContain('nid');
});
it('stellt die ID mit Doppelpunkt und Leerzeichen davor', () => {
expect(ids('#auth: Backend', true))
.toContain('<span class="nid" aria-hidden="true">#auth:</span> Backend');
});
it('lässt Knoten ohne ID unangetastet', () => {
const html = ids('- [x] Ohne ID', true);
expect(html).toContain('>Ohne ID<');
expect(html).not.toContain('nid');
});
/* Die ID steht ohnehin im aria-label (D36) — sichtbar doppelt vorgelesen
würde sie sonst. */
it('versteckt die sichtbare ID vor dem Screenreader', () => {
expect(ids('#auth: Backend', true)).toContain('<span class="nid" aria-hidden="true">');
});
/* Markup kann in einer ID gar nicht vorkommen — `<`, `&` und `"` gehören
nicht zur Zeichenmenge (SPEC §1), die ID endet dort. Geprüft wird deshalb
genau das: `#a<b` ergibt die ID `a`, der Rest bleibt (escaped) im Label. */
it('endet an einem Zeichen außerhalb der ID-Zeichenmenge', () => {
const html = ids('#a<b: X', true);
expect(html).toContain('<span class="nid" aria-hidden="true">#a:</span>');
expect(html).toContain('&lt;b: X');
});
});