Files
werkbaum/frontend/tests/frontier.test.js
T
mhoennigandClaude Opus 5 b6e83b4f2a fix: Erledigte Knoten treten nie zurück (D46-Nachtrag)
Gemeldet: manche Knoten in Produktion werden grau dargestellt, etwa
`#ed.fresh`. Ursache war nicht der Status, sondern die Pfad-Inversion:
Der Knoten ist eine Zugabe (`+`) und liegt damit per D29 nie auf dem
günstigsten Pfad — `.cheap-on .node:not(.cheap)` blasste ihn aus
(Deckkraft 0,32, Sättigung 0,4; Pastellblau liest sich so als Grau).

D46 hatte „erledigt tritt nicht zurück" bereits entschieden, die Regel
hing aber an `cheapCls()` und griff nur für Knoten AUF dem Pfad. Durch
das Raster fielen die fertige Zugabe und die fertige, nicht gewählte
Alternative. Jetzt gilt sie unabhängig vom Pfad; Blässe heißt damit
einheitlich „hier ist nichts getan und wird auch nichts getan".

Umgesetzt als eigene Klasse `done` per `isDone(n)` in render.js, nicht
über `st-fertig`/`st-prod`: Die Statusklasse trägt den EFFEKTIVEN Status
(D39), die Ausnahme muss dem intrinsischen folgen — dieselbe Linie wie
bei D35, D28 und der Kostenrechnung aus D46. Gefärbt wird unverändert
nach dem effektiven Status.

Nachgemessen (Werkbaum-Plan, 149 sichtbare Knoten, Pfad an): vorher
blass und fertig waren genau drei Knoten (#ed.fresh, #ed.export.print,
#ed.closure.greedy) — jetzt alle drei opacity 1, filter none, Füllung
rgb(219,234,248) wie die fertigen Knoten auf dem Pfad. Weiterhin blass:
31 Knoten, darunter kein einziger fertiger. Der Grafikexport hatte den
Fehler nie (er liest backgroundColor, nicht opacity).

240 Tests grün (4 neue in tests/frontier.test.js).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:15:22 +02:00

195 lines
6.5 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import {
isDone, ownCost, cheapestCost, cheapCls, computeCheapSet,
} from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
/* Status-bewusster günstigster Pfad (SPEC §9, D46): Erledigtes kostet nichts
mehr und ist keine Station; hervorgehoben wird der günstigste noch OFFENE
Rest — die aktuelle Front. */
const roots = txt => parse(txt).roots;
const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort();
/* Stationen = Knoten mit 'cheap-leaf' (die Pfadlinie fädelt durch sie). Der
Baum wird EINMAL geparst und beides daraus abgeleitet — `Set.has()` prüft
auf Objektidentität, ein zweiter Parse-Durchlauf träfe nie (Falle aus D28). */
function plan(txt){
const rs = roots(txt);
const set = computeCheapSet(rs);
const st = [];
(function walk(ns){
for(const n of ns){
if(cheapCls(n, set, false).includes('cheap-leaf')) st.push(n.label);
walk(n.children);
}
})(rs);
return { rs, set, stationen: st.sort(), aufPfad: [...set].map(n => n.label).sort() };
}
describe('isDone — die Schwelle liegt bei „fertig"', () => {
it('erkennt [x] und [^], sonst nichts', () => {
const rs = roots(`[?] a
[ ] b
[!] c
[~] d
[/] e
[x] f
[^] g
[-] h
i`);
expect(rs.map(isDone)).toEqual([false, false, false, false, false, true, true, false, false]);
});
});
describe('Kosten — Erledigtes zählt nicht mehr', () => {
it('kostet 0, unabhängig von der Größe', () => {
const [a, b, c] = roots(`[x] Fertig (XXL)
[^] Prod (XL)
[~] Arbeit (XS)`);
expect(ownCost(a)).toBe(0);
expect(ownCost(b)).toBe(0);
expect(ownCost(c)).toBeGreaterThan(0);
});
it('Angefangenes ([~], [/]) zählt weiterhin voll', () => {
const [a, b] = roots(`[~] A (L)
[ ] B (L)`);
expect(ownCost(a)).toBe(ownCost(b));
});
it('zählt den intrinsischen Status — Abhängigkeiten halten ihn nicht auf', () => {
/* Effektiv ist A erst „geplant" (D39), investiert ist trotzdem investiert. */
const [a] = roots(`[x] A (XL) :#b
[ ] B #b (XS)`);
expect(ownCost(a)).toBe(0);
});
it('zieht nur die eigenen Kosten ab, nicht den Teilbaum', () => {
const [n] = roots(`[x] Eltern (L)
- [ ] Kind (S)`);
expect(cheapestCost(n)).toBe(cheapestCost(roots('[ ] Kind (S)')[0]));
});
});
describe('Auswahl — die getroffene Wahl gewinnt', () => {
it('eine realisierte Alternative schlägt die nominell billigere', () => {
/* Ohne Status-Bewusstsein gewönne „Billig" (XS gegen L). */
expect(cheapLabels(`[ ] Wahl (XS)
| [x] Gemacht (L)
| [ ] Billig (XS)`)).toEqual(['Gemacht', 'Wahl']);
});
it('eine erst angefangene Alternative gewinnt dadurch NICHT', () => {
expect(cheapLabels(`[ ] Wahl (XS)
| [~] Angefangen (L)
| [ ] Billig (XS)`)).toEqual(['Billig', 'Wahl']);
});
it('gilt auch in einer XOR-Gruppe', () => {
expect(cheapLabels(`[ ] Wahl (XS)
= [ ] Billig (XS)
= [^] Live (XL)`)).toEqual(['Live', 'Wahl']);
});
});
describe('Stationen — die Linie zeigt den offenen Rest', () => {
it('erledigte Blätter tragen keinen Punkt', () => {
const p = plan(`[ ] W (XS)
- [x] Fertig (S)
- [ ] Offen (S)`);
expect(p.aufPfad).toEqual(['Fertig', 'Offen', 'W']); /* beide bleiben auf dem Pfad */
expect(p.stationen).toEqual(['Offen']);
});
it('ein offener Knoten mit lauter erledigten Kindern wird selbst zur Station', () => {
/* Die Restarbeit ist dann die des Elternknotens — sonst hätte der Zweig
gar keine Station, obwohl dort noch etwas offen ist. */
const p = plan(`[~] Eltern (M)
- [x] A (S)
- [^] B (S)`);
expect(p.stationen).toEqual(['Eltern']);
});
it('ein erledigter Knoten mit offenem Kind bleibt Durchgang', () => {
const p = plan(`[x] Eltern (M)
- [ ] Kind (S)`);
expect(p.stationen).toEqual(['Kind']);
});
it('ein durchweg erledigter Baum hat keine Station mehr', () => {
const p = plan(`[^] W (L)
- [x] A (S)
- [x] B (S)`);
expect(p.stationen).toEqual([]);
expect(p.aufPfad).toEqual(['A', 'B', 'W']); /* der Pfad selbst bleibt */
});
});
describe('Eingeklappt — der Knoten vertritt offene Arbeit, nicht erledigte', () => {
it('vertritt einen Teilbaum mit offener Pfadarbeit als Station (D38)', () => {
const rs = roots(`[ ] W (XS)
- [ ] Zweig (M)
- [ ] Tief (S)`);
const set = computeCheapSet(rs);
const zweig = rs[0].children[0];
expect(cheapCls(zweig, set, true)).toBe('cheap cheap-leaf');
});
it('bleibt ohne Punkt, wenn darunter alles erledigt ist', () => {
const rs = roots(`[ ] W (XS)
- [x] Zweig (M)
- [x] Tief (S)`);
const set = computeCheapSet(rs);
const zweig = rs[0].children[0];
expect(cheapCls(zweig, set, true)).toBe('cheap'); /* auf dem Pfad, aber nichts zu tun */
});
});
/* Die Klasse `done` trägt allein die Ausnahme von der Pfad-Inversion
(D46-Nachtrag): Erledigtes wird nie ausgeblasst, auch wenn es nicht auf
dem Pfad liegt. Sie folgt dem INTRINSISCHEN Status. */
describe('Klasse `done` — Erledigtes tritt nie zurück', () => {
const html = txt => {
const rs = roots(txt);
return renderTreeHtml(rs, {t: k => k, showDiscarded: false,
cheapPath: true, cheapSet: computeCheapSet(rs)}).html;
};
it('setzt sie an [x] und [^], nicht an offenen Knoten', () => {
const h = html(`[ ] W (XS)
- [x] Fertig (S)
- [^] Live (S)
- [~] Offen (S)`);
expect(h).toContain('class="node cheap done st-fertig"');
expect(h).toContain('class="node cheap done st-prod"');
expect(h).toContain('class="node cheap cheap-leaf st-arbeit"');
});
it('setzt sie auch am fertigen OPTIONALEN Knoten, der nie auf dem Pfad liegt', () => {
const rs = roots(`[ ] W (XS)
- [ ] Pflicht (S)
+ [^] Zugabe (S)`);
const set = computeCheapSet(rs);
const zugabe = rs[0].children[1];
expect(set.has(zugabe)).toBe(false); /* `+` ist nie auf dem Pfad (D29) */
const h = renderTreeHtml(rs, {t: k => k, showDiscarded: false,
cheapPath: true, cheapSet: set}).html;
expect(h).toContain('class="node opt done st-prod"');
});
it('setzt sie an der fertigen, nicht gewählten Alternative', () => {
const h = html(`[ ] W (XS)
| [^] Alt A (XS)
| [^] Alt B (XS)`);
expect(h.match(/class="node[^"]*done[^"]*st-prod"/g)).toHaveLength(2);
});
it('setzt sie nicht am verworfenen Knoten', () => {
const h = html(`[ ] W (XS)
- [ ] A (S)
- [-] Weg (S)`);
expect(h).not.toContain('done st-verworfen');
});
});