Files
werkbaum/frontend/tests/frontier.test.js
T
mhoennigandClaude Fable 5 4aaa19becd feat: Die Größe bepreist den ganzen Teilbaum — Kinder zählen nicht obendrauf (D69)
Der Preis eines Knotens ist seine angegebene (oder D66-geschätzte) Größe;
die frühere Rekursion (eigene Größe plus Summe/Minimum der Kinder) zählte
doppelt und bestrafte sorgfältig zerlegte Pakete — eine zerlegte
S-Alternative verlor gegen ein grobes L. Das Vereinigungs-Maß der
Closure-Suche (D42) zählt je Knoten nur noch die Marge über seine nötigen
Teilpakete hinaus. Mitgelieferter Plan wählt identisch (per Vergleich
gemessen); SPEC §9 und llms.md nachgezogen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:45:12 +02:00

212 lines
7.3 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('der erledigte Knoten bepreist 0; sein offenes Kind bleibt auf dem Pfad', () => {
/* Seit D69 bepreist die Größe den ganzen Teilbaum — der erledigte
Elternknoten kostet nichts mehr, das offene Kind bleibt trotzdem
nötig und behält seinen eigenen Preis. */
const [n] = roots(`[x] Eltern (L)
- [ ] Kind (S)`);
expect(cheapestCost(n)).toBe(0);
const set = computeCheapSet([n]);
expect(set.has(n.children[0])).toBe(true);
});
});
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']);
});
/* Bis D61 gewann hier „Billig": Die Wahl lief allein über die Kosten, und
die sind nur bei `[x]`/`[^]` null. Angefangenes kostet voll und verlor
damit — obwohl SPEC §9 die Regel schon so formuliert hatte. */
it('gilt auch für eine erst angefangene Alternative', () => {
expect(cheapLabels(`[ ] Wahl (XS)
| [~] Angefangen (L)
| [ ] Billig (XS)`)).toEqual(['Angefangen', 'Wahl']);
});
/* Die unangetastete ist hier die billigste von allen — gewählt wird
trotzdem unter den realisierten, und dort entscheiden die Kosten. */
it('lässt unter mehreren realisierten wieder die Kosten entscheiden', () => {
expect(cheapLabels(`[ ] Wahl (XS)
| [~] Teuer (L)
| [/] Günstig (S)
| [ ] Unangetastet (XS)`)).toEqual(['Günstig', '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');
});
});