Files
werkbaum/frontend/tests/render.test.js
T
mhoennigandClaude Opus 4.8 aa3e087ee6 notation: + für optionale Knoten — Zugaben statt Pflicht oder Alternative
Die Notation kannte nur „erforderlich" (-) und „wählbar" (|). Ein einzelnes
zusätzliches Feature — weder nötig noch Alternative zu etwas anderem — musste
als normales -Kind notiert werden und log damit. Feature-Modelle (FODA)
unterscheiden seit den 90ern mandatory/optional/alternative; `+` ergänzt die
fehlende zweite Beziehung. Mnemonik: `-` Teilpaket, `+` Zugabe, `|` Alternative.

Anlass ist nicht die Optik, sondern der günstigste Pfad (D18): markCheapest()
lief bei all-of über ALLE Kinder, jede Zugabe steckte also im errechneten
Minimum — systematisch zu groß, und zwar umso mehr, je ehrlicher ein Plan auch
die Kür notiert. Sichtbar wird es beim Alternativenvergleich: eine Alternative
mit teurer Zugabe verlor gegen eine schlichtere, obwohl die Zugabe gar nicht
dazugehört.

- Parser setzt `optional:true` und lässt `type:'and'` stehen — `+` gehört zum
  Knoten, nicht zur Gruppe. Dadurch bleiben gateOf() und die mixedGate-Warnung
  unverändert richtig: sie meldet weiter genau dann, wenn | mit -/+ gemischt
  wird. `-` neben `+` ist erlaubt und still — „diese drei sind nötig, das hier
  wäre schön" ist der Normalfall, nicht der Fehlerfall.
- Aus dem Pfad fallen optionale Knoten über pathChildren() heraus, die eine
  Stelle, die cheapestCost() und markCheapest() gemeinsam nutzen — deshalb
  wirkt es samt Teilbaum.
- Darstellung: hohler Kreis mittig auf der Knotenkante, wo der Abzweig
  auftrifft (FODA-Konvention). Bewusst KEIN dritter Linienstil: im kompakten
  Modus codiert allein der Stil das Gate (D15), gepunktet müsste sich dort
  gegen gestrichelt-grau behaupten. Der Kreis ist orthogonal dazu.
  CSS-Grundfall ist gestapelt (links/50 %), Ausnahme der horizontale Fächer
  (oben/50 %), Rück-Ausnahme der gestapelte all-of-Teilbaum unter any-of (D18)
  — andersherum wären es vier Ausnahmen statt zwei.
- SVG-Export zeichnet den Kreis NACH den Knoten (optMarks, Schritt 3a): er
  liegt halb außerhalb der Box und würde sonst vom Knoten-Rechteck überdeckt.
- Legende, Knoten-Tooltip und aria-label in allen neun Sprachen; hint_root
  formuliert die neue Mischregel.

Bekannte Schwäche, bewusst in Kauf genommen: Bei aktivem Pfad-Umschalter wird
der optionale Knoten ausgeblasst (opacity:.32) — und mit ihm sein Kreis, der
die Erklärung dafür wäre. `opacity` am Elternteil schlägt auf Pseudoelemente
durch, das lässt sich nicht zurücknehmen. Das Zurücktreten ist hier die
Hauptaussage (wie bei nicht gewählten Alternativen), Tooltip/aria/Legende
liefern die Begründung nach.

Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen und nicht mehr
Teil des Labels (`+ 5 % Puffer` ergibt „5 % Puffer"). Test-abgedeckt.

SPEC §1/§3/§9/§10 zuerst, dann Code (CLAUDE). Das kanonische Beispiel in §10
enthält jetzt eine `+`-Zeile und ist mit der Test-Fixture wieder deckungsgleich.
Der mitgelieferte Werkbaum-Plan markiert Drucklayout, „Was ist neu?" und die
Personenfarben als Zugaben — „Was ist neu?" war der Auslöser der Frage.

Verifiziert: 12 neue Tests (Parser setzt optional/type; Status/Größe/Tags/URL
am +-Knoten; führendes + wird verbraucht; Pfad lässt Zugabe samt Teilbaum aus;
Kosten des Elternknotens ohne Zugabe; Alternativenvergleich ohne Zugaben;
optionale Knoten bleiben sichtbar; opt-Klasse; keine Warnung bei -/+; Warnung
bei |/+; aria-label). Vitest 58/58, Snapshot zeigt `node opt` OHNE `cheap`.
Im Browser in allen drei Modi angesehen: Kreis sitzt in horizontal oben mittig,
in vertikal und kompakt links auf halber Höhe, jeweils genau auf dem Ende des
Abzweigs; SVG-Export enthält beide Kreise an denselben Punkten (gerendert
geprüft, nicht nur im Quelltext).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:58:52 +02:00

142 lines
6.7 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) => {
const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>' + 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/);
});
});