Files
werkbaum/frontend/tests/render.test.js
T
mhoennigandClaude Opus 5 3fbc772d81 feat(diagram): das Knoten-Fenster löst den nativen Tooltip überall ab
Das Fenster aus D52 war für Touch gebaut — damit standen zwei Darstellungen
desselben Inhalts nebeneinander, und ausgerechnet die schlechtere bekam der
Zeiger. Jetzt zeigt es überall: am Zeiger nach 350 ms beim Überfahren, bei
Tastaturfokus sofort, auf Touch unverändert beim Tipp.

Was ein `title` prinzipiell nicht kann:
- keine Absätze (die Beschreibungen im Plan brechen bei ~76 Zeichen, das sah
  ausgefranst aus),
- keine Linie (der Trennstrich war aus 24 `─` GEMALT, D40-Nachtrag),
- nichts bei Tastaturfokus — wer mit Tab durch das Diagramm geht, sah bisher
  keine Beschreibung. Nachgemessen mit ECHTEM Tastendruck: Fokus von
  #not.line auf #not.status, Fenster folgt, 10 px unter dem Knoten, Ring am
  fokussierten Knoten.

- Inhalt zieht von `title` nach `data-tip` (sonst zeigte der Browser seinen
  eigenen zusätzlich) — dasselbe Attribut unter anderem Namen, keine
  Verdopplung der 20 kB DOM-Text.
- Die letzten drei nativen Tooltips IN Knoten mussten weichen: Das
  Warndreieck war redundant (Statusname steht in den Fakten), die Annahme
  „Größe fehlt, gilt als M" wandert in die Faktenzeile. Nachgemessen: 0 von
  161 Knoten und 0 Kinder tragen noch einen `title`.
- Absicht-Erkennung: 350 ms Verzögerung, beim Wechsel zwischen Knoten ohne
  Warten, 120 ms Nachfrist beim Verlassen (der Weg ins Fenster führt über
  einen Zwischenraum).
- `aria-hidden` am Fenster: Der Inhalt steht schon im aria-label, sonst würde
  er doppelt vorgelesen; × und ↗ deshalb `tabindex="-1"`.

Werkzeuggrenze notiert: `element.focus()` feuert ohne Fensterfokus keine
Fokus-Ereignisse (`document.hasFocus()` false) — der erste Prüflauf zeigte
fälschlich „öffnet nichts".

SPEC §9 + D57; Plan: #ed.tipwindow [x] (161 Knoten, 0 Warnungen).
293 Tests grün, 5 Snapshots/Zusicherungen auf `data-tip` nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:07:00 +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" data-tip="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');
});
});