Files
werkbaum/frontend/tests/optional.test.js
T
mhoennigandClaude Opus 4.8 091e8849d8 frontend: optionale Abzweige gestrichelt + Stiel trifft die Knotenmitte
Zwei Nachbesserungen aus dem ersten Blick auf `+` (D29).

**Gestrichelter Abzweig.** Der hohle Kreis allein war zu leise. Der Einwand
gegen einen dritten Linienstil bleibt richtig, greift aber nicht so weit wie
gedacht: Gestrichelt wird NUR der Abzweig zum Knoten (nicht die Sammelleiste —
die gehört der ganzen Gruppe) und zwar in TINTE, während die any-of-Linien
gestrichelt in Grau sind. Entscheidend ist, dass beide sich nie am selben
Verteiler treffen können: | darf nach SPEC §3 nicht mit -/+ gemischt werden.
Der Kreis bleibt zusätzlich — er sagt, WELCHER Knoten gemeint ist.

Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: im
Fächer der senkrechte Stiel (border-left von ::after, beim letzten Kind
border-right von ::before), gestapelt der waagerechte Ast (border-top von
::before); die jeweils andere Kante ist die Leiste und bleibt durchgezogen.
Dafür braucht auch das <li> die Klasse `opt` — den Abzweig zeichnet es, nicht
der Knoten. Der SVG-Export zieht mit (dash || isOpt).

**Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).** Dabei fiel ein
älterer Fehler auf, gemeldet als „die Linie zu Wahl trifft den Knoten nicht":
Im horizontalen Fächer lief der Stiel zu einem Knoten mit any-of-Kindern neben
dem Knoten vorbei (gemessen 13,4 px). Dieselbe Verwechslung, die D10 senkrecht
schon behoben hat, nur in der anderen Achse — der Stiel saß bei 50 % der ZELLE,
und das ist nur die Knotenmitte, solange der Knoten darin zentriert steht.
`li.has-or` ist aber flex-start (der Knoten steht links, damit die
any-of-Sammelleiste unter ihm aufsetzt), und die Zelle ist so breit wie der
Teilbaum.

Rein in CSS nicht lösbar: gebraucht wird die Knotenbreite, und kein Selektor
macht sie einer Elternregel zugänglich (Anchor Positioning ist Chrome-only).
`alignStems()` misst deshalb nach jedem render() und in applyLayout() die
Knotenmitte der betroffenen Zellen und setzt `--stem-x`; die Pseudoelemente
rechnen über left:var(--stem-x, 50%) bzw. right:calc(100% - var(--stem-x, 50%)).
Der Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, die
transponierten Modi setzen left/right ohnehin fest. Messwerte werden wie in
drawCheapPath() durch `zoom` zurückgerechnet.

Verifiziert: Vitest 60/60 (2 neue Tests: `opt` auch am <li> neben dem Gate der
eigenen Kinder; <li> ohne Attribut, wenn weder Kinder noch optional).
Im Browser gemessen: Abweichung Stiel↔Knotenmitte bei „Wahl" 13,4 px → 0,0 px,
alle übrigen Zellen unverändert 0,0 px ohne gesetzte Variable. Angesehen in
horizontal und kompakt: gestrichelter Ast in Tinte zum Kreis, Sammelleiste
durchgezogen, deutlich unterscheidbar vom gestrichelt-grauen any-of-Ast
daneben. SVG-Export gerendert geprüft: 2 von 9 Tinte-Linien gestrichelt, beide
Kreise am Auftreffpunkt.

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

106 lines
4.2 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { computeCheapSet, cheapestCost, visibleChildren } from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
const t = key => key;
const roots = txt => parse(txt).roots;
const render = txt => renderTreeHtml(roots(txt),
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort();
/* Optionale Knoten: `+` (SPEC §3, D29). Eigenschaft des einzelnen Knotens,
nicht der Gruppe — deshalb bleibt `type` 'and'. */
describe('Parser — `+` setzt optional, nicht das Gate', () => {
it('erkennt `+` als optionalen Knoten innerhalb einer Und-Gruppe', () => {
const [wurzel] = roots(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
expect(wurzel.children.map(k => [k.label, k.type, k.optional]))
.toEqual([['Pflicht', 'and', false], ['Zugabe', 'and', true]]);
});
it('lässt `-` und Zeilen ohne Zeichen nicht-optional', () => {
const [wurzel] = roots(`Wurzel\n - Kind`);
expect(wurzel.optional).toBe(false);
expect(wurzel.children[0].optional).toBe(false);
});
it('parst Status, Größe, Tags und URL am `+`-Knoten wie sonst auch', () => {
const [wurzel] = roots(`[ ] Wurzel\n + [^] Zugabe (S) https://example.org/x @ana`);
const k = wurzel.children[0];
expect([k.label, k.status.key, k.size, k.tags, k.url])
.toEqual(['Zugabe', 'prod', 'S', ['ana'], 'https://example.org/x']);
});
/* Bewusste Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen.
Vorher wäre es Teil des Labels gewesen. */
it('verbraucht ein führendes `+` als Zeichen, nicht als Label-Text', () => {
expect(roots(`+ 5 % Puffer`)[0].label).toBe('5 % Puffer');
});
});
describe('Günstigster Pfad — optionale Knoten sind nie nötig', () => {
const BAUM = `[ ] Wurzel (S)
- [ ] Pflicht (S)
+ [ ] Zugabe (XXL)
- [ ] Unterpunkt (S)`;
it('lässt den optionalen Knoten samt Teilbaum aus dem Pfad', () => {
expect(cheapLabels(BAUM)).toEqual(['Pflicht', 'Wurzel']);
});
it('rechnet die Zugabe nicht in die Kosten des Elternknotens', () => {
/* Wurzel (S=2) + Pflicht (S=2) = 4; mit der XXL-Zugabe wären es 10. */
expect(cheapestCost(roots(BAUM)[0])).toBe(4);
});
it('vergleicht Alternativen ohne deren Zugaben', () => {
/* Ohne die Ausnahme kostete A 2+6=8 und B (3) gewänne. */
const BAUM = `[ ] Wahl (XS)
| [ ] A (S)
+ [ ] Extra (XXL)
| [ ] B (M)`;
expect(cheapLabels(BAUM)).toEqual(['A', 'Wahl']);
});
it('blendet optionale Knoten aber nicht aus dem Diagramm aus', () => {
const [wurzel] = roots(BAUM);
expect(visibleChildren(wurzel, false).map(k => k.label))
.toEqual(['Pflicht', 'Zugabe']);
});
});
describe('Renderer — Kennzeichnung und Gemischt-Warnung', () => {
it('gibt dem optionalen Knoten die Klasse `opt`', () => {
const {html} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
expect((html.match(/class="node opt[ "]/g) || []).length).toBe(1);
expect(html).toContain('>Zugabe<');
});
/* Der Abzweig wird von den <li>-Pseudoelementen gezeichnet — für den
gestrichelten Ast braucht das <li> die Klasse ebenfalls (D29). */
it('gibt auch dem <li> die Klasse `opt`, neben dem Gate der eigenen Kinder', () => {
const {html} = render(`[ ] Wurzel\n + [ ] Zugabe\n | [ ] A\n | [ ] B`);
expect(html).toContain('<li class="has-or opt">');
});
it('lässt das <li> ohne Attribut, wenn weder Kinder noch optional', () => {
const {html} = render(`[ ] Wurzel\n - [ ] Pflicht`);
expect(html).toContain('<li>');
});
it('warnt NICHT, wenn `-` und `+` nebeneinander stehen', () => {
const {warnings} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
expect(warnings).toEqual([]);
});
it('warnt weiterhin, wenn `|` mit `+` gemischt wird', () => {
const {warnings} = render(`[ ] Wurzel\n | [ ] Alternative\n + [ ] Zugabe`);
expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Wurzel'}]);
});
it('nennt „optional" im aria-label des Knotens', () => {
const {html} = render(`[ ] Wurzel\n + [ ] Zugabe`);
expect(html).toContain('aria-label="Zugabe, a11yStatus, a11yOptional"');
});
});