Files
werkbaum/frontend/tests/frontier.test.js
T
mhoennigandClaude Opus 5 36b78efa53 lean-path: Angefangenes liegt auf dem Pfad (SPEC §9, D61)
Eine Zugabe (`+`) fällt aus dem günstigsten Pfad — außer sie ist
realisiert und noch nicht erledigt (`[~]`/`[/]`). Angefangene Arbeit ist
die offene Front, und seit D46 zeigt der Pfad genau die; bisher führte
der Von-Station-zu-Station-Knopf nie dorthin, wo gearbeitet wird.
Erledigte Zugaben bleiben draußen: dort ist nichts mehr zu tun, und was
darunter offen blieb, ist mit ihnen zusammen entbehrlich (§3).

Dabei die fehlende Hälfte von D46 gefunden: SPEC §9 sagt seit jeher
„eine bereits realisierte Alternative gewinnt", umgesetzt war das aber
nur über die Kosten — und die sind allein bei `[x]`/`[^]` null. Ein
`[~]`-Zweig verlor damit gegen eine unangetastete billigere, obwohl die
XOR-Regel gerade ihn als die realisierte Alternative ausweist.
`chosenPool()` schränkt die Wahlmenge auf die realisierten ein; unter
mehreren entscheiden wieder die Kosten. Nebengewinn: eine entschiedene
Gruppe ist keine freie Variable mehr und verkleinert die Suche (D42).

10 neue Tests; Gegenprobe geführt (jede Regel einzeln herausgenommen
lässt genau die nach ihr benannten Zusicherungen fallen). Der
mitgelieferte Plan ändert sich nicht — er hat keine angefangene Zugabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:11:38 +02:00

207 lines
7.1 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']);
});
/* 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');
});
});