fix: eingeklappter Knoten vertritt seinen Teilbaum auf dem günstigsten Pfad

D38 hielt fest, ein eingeklappter Zweig dürfe Pfadknoten verbergen, „die
Inversion an den sichtbaren Knoten bleibt richtig". Zu kurz gedacht: Die LINIE
blieb eben nicht richtig. Sie fädelt durch die cheap-leaf-Knoten im DOM; ein
eingeklappter Pfadknoten ist zwar `cheap`, aber kein Blatt (seine Pfad-Kinder
existieren im Modell) — also bekam er keine Station, und die Linie umging den
ganzen Zweig.

Im Beispiel nachgemessen: „Concept" war `node cheap folded`, verbarg zwei
Pfadknoten, trug keinen Punkt; die Linie begann erst bei „PWA". Das Bild
behauptete damit, in diesem Zweig sei nichts zu tun — eine Aussage über den
Plan, nicht über die Ansicht. Genau die Grenze, die D38 sonst zieht.

Regel: Eingeklappt überspringt cheapCls() die Blatt-Prüfung und fragt den
Teilbaum (hidesCheap). Liegt darin etwas auf dem Pfad, ist der Knoten dessen
tiefste noch sichtbare Station. Beim Aufklappen geben die Kinder sie zurück —
gemessen 6 -> 7 -> 6 Stationen beim Hin- und Herklappen.

Auch ohne eigene Pfad-Mitgliedschaft: Ein per :#… gezogenes Ziel kann unter
einem +-Knoten liegen, der selbst nicht gebraucht wird (D42). Eingeklappt ist
dieser der einzige sichtbare Griff auf nötige Arbeit — er wird Station und
zählt als cheap, tritt also nicht zurück. Ihn auszublassen wäre die Lüge.

4 neue Tests (208 gesamt). SPEC §9, D38 (Nachtrag) und frontend/CLAUDE.md
nachgezogen; die alte „hinnehmbar"-Stelle in D38 ist als falsch markiert statt
gelöscht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 08:37:44 +02:00
co-authored by Claude Fable 5
parent 6463a76e1e
commit 38104a4c5d
6 changed files with 138 additions and 5 deletions
+33
View File
@@ -2223,6 +2223,7 @@ Und der günstigste Pfad rechnet unverändert über den ganzen Baum; seine
Spline-Linie läuft ohnehin nur durch DOM-Knoten, führt also durch die
sichtbaren Endknoten. Ein eingeklappter Zweig kann Pfadknoten verbergen —
hinnehmbar, die Inversion an den sichtbaren Knoten bleibt richtig.
**Das war falsch — siehe den Nachtrag am Ende dieses Eintrags.**
**Export und Druck:** Verborgene Kinder stehen nicht im DOM — Export,
Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent
@@ -2231,6 +2232,38 @@ per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts u
wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im
Druck entfernt — es ist Bedienelement, keine Aussage über den Plan.
**Nachtrag — der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem
günstigsten Pfad.** Oben steht, ein eingeklappter Zweig dürfe Pfadknoten
verbergen, „die Inversion an den sichtbaren Knoten bleibt richtig". Das war zu
kurz gedacht, und der Nutzer hat es benannt: Die **Linie** blieb eben nicht
richtig. Sie fädelt durch die `cheap-leaf`-Knoten im DOM; ein eingeklappter
Pfadknoten ist `cheap`, aber kein Blatt (seine Pfad-Kinder sind ja im Modell
vorhanden) — also bekam er keine Station, und die Linie **umging den ganzen
Zweig**. Im mitgelieferten Beispiel nachgemessen: „Concept" war
`node cheap folded`, verbarg zwei Pfadknoten und trug keinen Punkt; die Linie
begann erst bei „PWA". Das Bild behauptete damit, in diesem Zweig sei nichts
zu tun — und das ist eine Aussage über den Plan, keine über die Ansicht. Genau
die Grenze, die dieser Eintrag sonst zieht.
**Regel: Eingeklappt ist der Knoten die tiefste noch sichtbare Station.**
`cheapCls(n, cheapSet, collapsed)` überspringt für eingeklappte Knoten die
Blatt-Prüfung und fragt stattdessen den Teilbaum: Liegt darin etwas auf dem
Pfad (oder der Knoten selbst), ist er `cheap cheap-leaf`. Beim Aufklappen
geben die Kinder die Station zurück — nachgemessen 6 → 7 → 6 Stationen beim
Hin- und Herklappen, Punkte jeweils gleich.
**Auch ohne eigene Pfad-Mitgliedschaft.** Ein per `:#…` gezogenes Ziel kann in
einem Zweig liegen, dessen Wurzel selbst nicht gebraucht wird (D42, etwa unter
einem `+`-Knoten). Ist dieser Zweig eingeklappt, ist seine Wurzel der einzige
sichtbare Griff auf nötige Arbeit — sie wird deshalb Station **und** zählt als
`cheap`, tritt also nicht zurück. Das ist keine Ausnahme, sondern dieselbe
Regel: Der eingeklappte Knoten **steht für** seinen Teilbaum, und der enthält
Nötiges. Ihn auszublassen wäre die Lüge.
Der Preis ist benannt und klein: Ein eingeklappter Zweig zeigt **eine** Station
statt mehrerer. Das ist keine Ungenauigkeit, sondern die Aussage der Faltung —
„hier drin liegt noch Pfad", genauer geht es ohne Aufklappen nicht.
## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box
Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der
Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4).
+10 -2
View File
@@ -670,8 +670,16 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom).
dauerhafte Aussage steht im Text (D34). Ein Dokumentwechsel setzt zurück.
- Faltung ist **reine Ansicht**: Warnungen aus eingeklappten Teilbäumen werden
weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet
unverändert über den ganzen Baum — seine Linie zeigt nur die sichtbaren
Endknoten.
unverändert über den ganzen Baum.
- **Ein eingeklappter Knoten vertritt seinen Teilbaum auch auf dem günstigsten
Pfad:** Liegt darin etwas auf dem Pfad, ist er dessen tiefste noch sichtbare
Station — die Linie führt zu ihm und endet dort mit einem Stationspunkt
(„hier drin liegt noch Pfad"). Ohne das überspränge sie den ganzen Zweig, als
wäre dort nichts zu tun. Beim Aufklappen geben die Station wieder die
Endknoten darunter. Das gilt auch, wenn der eingeklappte Knoten selbst nicht
gebraucht wird, sein Teilbaum aber schon (etwa ein per `:#…` gezogenes Ziel):
Er ist dann der einzige sichtbare Griff auf nötige Arbeit und tritt deshalb
auch nicht zurück.
- **Export und Druck folgen der sichtbaren Faltung** (dieselbe Regel wie beim
„verworfene einblenden“-Filter): Verborgene Teilbäume fehlen, eingeklappte
Knoten behalten die Kennzeichnung „▸ n“ — das Bild behauptet damit keine
+9 -1
View File
@@ -303,7 +303,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Dokumentwechsel geleert) und übergibt `collapsedSet` an den Renderer; der
lässt eingeklappte Kinder **weg** (nicht CSS-verstecken — Export, Messungen
und Pfadlinie bleiben so von selbst konsistent), meldet deren Warnungen aber
weiter (`walkFolded`, zählt zugleich fürs „▸ n"). Umklappen: Klick aufs
weiter (`walkFolded`, zählt zugleich fürs „▸ n").
**Auf dem günstigsten Pfad vertritt ein eingeklappter Knoten seinen
Teilbaum** (D38-Nachtrag): `cheapCls(n, cheapSet, collapsed)` überspringt für
ihn die Blatt-Prüfung und fragt `hidesCheap()` — sonst umginge die Pfad-Linie
den ganzen Zweig, als wäre dort nichts zu tun. Gilt auch ohne eigene
Pfad-Mitgliedschaft (ein per `:#…` gezogenes Ziel unter einem `+`-Knoten):
Dann ist er der einzige sichtbare Griff darauf und zählt als `cheap`.
`extraCls()` muss dieselbe Eingeklappt-Bedingung bilden wie `itemHtml`
(nur mit sichtbaren Kindern). Umklappen: Klick aufs
`.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im `<a>`) oder
←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line`
wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg
+19 -1
View File
@@ -168,7 +168,25 @@ export function computeCheapSet(roots){
}
/* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ''.
Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */
export function cheapCls(n, cheapSet){
/* Liegt im Teilbaum eines Knotens etwas auf dem Pfad? Gebraucht für
eingeklappte Knoten (siehe `cheapCls`). Verworfene sind nie in `cheapSet`
(SPEC §9), es braucht also keine eigene Filterung. */
export function hidesCheap(n, cheapSet){
for(const k of n.children || []){
if(cheapSet.has(k) || hidesCheap(k, cheapSet)) return true;
}
return false;
}
export function cheapCls(n, cheapSet, collapsed){
/* Eingeklappt steht der Knoten stellvertretend für seinen ganzen Teilbaum
(SPEC §9/D38): Liegt darin etwas auf dem Pfad, ist er dessen tiefste noch
SICHTBARE Station sonst überspränge die Linie den Zweig, als wäre dort
nichts zu tun. Das gilt auch, wenn er selbst nicht gebraucht wird, sein
Teilbaum aber schon (eine per `:#…` gezogene Alternative, D42): Er ist
dann der einzige sichtbare Griff auf nötige Arbeit und darf deshalb auch
nicht von der Inversion ausgeblasst werden. */
if(collapsed) return (cheapSet.has(n) || hidesCheap(n, cheapSet)) ? 'cheap cheap-leaf' : '';
if(!cheapSet.has(n)) return '';
const leaf = !pathChildren(n).some(k => cheapSet.has(k));
return leaf ? 'cheap cheap-leaf' : 'cheap';
+6 -1
View File
@@ -21,7 +21,12 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und
optionale Knoten (`+`, SPEC §3/D29 trägt den hohlen Kreis am Abzweig). */
function extraCls(n, opts){
const cheap = cheapCls(n, opts.cheapSet);
/* Dieselbe Bedingung wie in `itemHtml`: eingeklappt ist ein Knoten nur, wenn
er überhaupt sichtbare Kinder hat. Der Pfad braucht sie hier, weil ein
eingeklappter Knoten seine verborgenen Pfad-Knoten vertritt (D38). */
const collapsed = !!(opts.collapsedSet && opts.collapsedSet.has(n))
&& visibleChildren(n, opts.showDiscarded).length > 0;
const cheap = cheapCls(n, opts.cheapSet, collapsed);
const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : '';
return [cheap, fresh, n.optional ? 'opt' : '', n.focus ? 'focusmark' : ''].filter(Boolean).join(' ');
}
+61
View File
@@ -126,3 +126,64 @@ describe('Renderer — eingeklappte Teilbäume', () => {
.toEqual(['A', 'A2', 'W']);
});
});
/* Der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem günstigsten
Pfad (SPEC §9, D38-Nachtrag): Sonst überspränge die Pfad-Linie den ganzen
Zweig, als wäre dort nichts zu tun. */
describe('Günstigster Pfad an eingeklappten Knoten', () => {
/* Nimmt die BEREITS geparsten Wurzeln: `collapsedSet` und `cheapSet` prüfen
auf Objektidentität ein zweiter Parse-Durchlauf liefert andere Objekte
und die Mengen träfen nie zu (dieselbe Falle wie in D28). */
const cheapRender = (r, collapsedSet) =>
renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true,
cheapSet: computeCheapSet(r), collapsedSet: collapsedSet || new Set()}).html;
/* Labels der Stationen (`cheap-leaf`) in Dokumentreihenfolge genau die
Menge, durch die drawCheapPath() die Linie fädelt. Gelesen aus dem
`aria-label` (erstes Glied = Label): Im Knoteninneren steht bei
eingeklappten Knoten das Falt-Zeichen vor dem Text. */
const stations = html =>
[...html.matchAll(/class="node[^"]*cheap-leaf[^"]*"[^>]*aria-label="([^",]*)/g)]
.map(m => m[1]);
const TXT = `[ ] W (XS)\n - [ ] A (S)\n - [ ] A1 (S)\n - [ ] A2 (S)\n - [ ] B (S)`;
it('macht den eingeklappten Knoten zur Station statt den Zweig zu überspringen', () => {
const r = roots(TXT);
const a = r[0].children[0];
expect(stations(cheapRender(r))).toEqual(['A1', 'A2', 'B']); /* offen */
expect(stations(cheapRender(r, new Set([a])))).toEqual(['A', 'B']); /* zu */
});
it('gibt die Station beim Aufklappen wieder an die Kinder ab', () => {
const r = roots(TXT);
const a = r[0].children[0];
const zu = stations(cheapRender(r, new Set([a])));
const auf = stations(cheapRender(r, new Set()));
expect(zu).not.toContain('A1');
expect(auf).toContain('A1');
expect(auf).not.toContain('A');
});
it('vertritt auch einen Teilbaum, den der Knoten selbst nicht braucht', () => {
/* `+ O` ist entbehrlich und nicht auf dem Pfad sein Kind wird aber per
Abhängigkeit gezogen (D42). Eingeklappt ist `O` der einzige sichtbare
Griff darauf und darf deshalb weder fehlen noch ausgeblasst sein. */
const txt = `[ ] W (XS)\n - [ ] N (S) :#t\n + [ ] O (S)\n - [ ] #t: T (S)`;
const r = roots(txt);
const o = r[0].children[1];
const set = computeCheapSet(r);
expect([...set].map(n => n.label).sort()).toEqual(['N', 'T', 'W']); /* O selbst nicht */
const {html} = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true,
cheapSet: set, collapsedSet: new Set([o])});
expect(stations(html)).toContain('O');
});
it('macht einen eingeklappten Knoten ohne Pfad im Teilbaum NICHT zur Station', () => {
const txt = `[ ] W (XS)\n - [ ] N (S)\n + [ ] O (S)\n - [ ] P (S)`;
const r = roots(txt);
const o = r[0].children[1];
const {html} = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true,
cheapSet: computeCheapSet(r), collapsedSet: new Set([o])});
expect(stations(html)).toEqual(['N']);
});
});