import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { assumedSize, ownCost, cheapestCost } from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
/* Geschätzte Größe bei fehlender Angabe (SPEC §9, D66): statt pauschal M wird
aus den Teilpaketen geschätzt — mindestens die größte Größe der zählenden
Kinder, ab drei Kindern dieser Größe eine Stufe mehr (Deckel XXL). Es zählen
dieselben Kinder wie beim Größen-Konflikt (§5/D62), nur dass fehlende
Kindgrößen rekursiv mitgeschätzt werden. */
const roots = txt => parse(txt).roots;
const t = (key, vars) => {
let s = key;
if(vars) for(const k in vars) s += ':' + vars[k];
return s;
};
const opts = () => ({ t, showDiscarded: false, cheapPath: true, cheapSet: new Set() });
describe('assumedSize — Schätzung aus den Teilpaketen (D66)', () => {
it('Blatt ohne Größe bleibt M (der alte D18-Rückfall)', () => {
expect(assumedSize(roots('[ ] Blatt')[0])).toBe('M');
});
it('explizite Größe gewinnt immer', () => {
const [n] = roots(`[ ] Eltern (S)
- [ ] Kind (XL)`);
expect(assumedSize(n)).toBe('S');
});
it('mindestens die größte Kindgröße', () => {
const [n] = roots(`[ ] Eltern
- [ ] A (L)
- [ ] B (S)`);
expect(assumedSize(n)).toBe('L');
});
it('zwei Kinder der größten Größe: noch keine Stufe mehr', () => {
const [n] = roots(`[ ] Eltern
- [ ] A (S)
- [ ] B (S)`);
expect(assumedSize(n)).toBe('S');
});
it('drei Kinder der größten Größe: eine Stufe mehr', () => {
const [n] = roots(`[ ] Eltern
- [ ] A (S)
- [ ] B (S)
- [ ] C (S)
- [ ] D (XS)`);
expect(assumedSize(n)).toBe('M');
});
it('Deckel XXL: drei XXL-Kinder bleiben XXL', () => {
const [n] = roots(`[ ] Eltern
- [ ] A (XXL)
- [ ] B (XXL)
- [ ] C (XXL)`);
expect(assumedSize(n)).toBe('XXL');
});
it('Kinder ohne Größe werden rekursiv mitgeschätzt', () => {
/* Das Kind hat selbst 3×M-Kinder ⇒ geschätzt L; der Elternknoten erbt L. */
const [n] = roots(`[ ] Eltern
- [ ] Kind
- [ ] a (M)
- [ ] b (M)
- [ ] c (M)`);
expect(assumedSize(n.children[0])).toBe('L');
expect(assumedSize(n)).toBe('L');
});
it('drei größenlose Blätter zählen als 3×M ⇒ L', () => {
const [n] = roots(`[ ] Eltern
- [ ] a
- [ ] b
- [ ] c`);
expect(assumedSize(n)).toBe('L');
});
it('optionale und verworfene Kinder zählen nicht', () => {
const [n] = roots(`[ ] Eltern
- [ ] A (S)
+ [ ] Zugabe (XXL)
- [-] Weg (XXL)`);
expect(assumedSize(n)).toBe('S');
});
it('nur optionale Kinder ⇒ M-Rückfall', () => {
const [n] = roots(`[ ] Eltern
+ [ ] Zugabe (XL)`);
expect(assumedSize(n)).toBe('M');
});
it('disjunktive Gruppe: die kleinste Alternative ist der Boden', () => {
const [n] = roots(`[ ] Eltern
| [ ] A (XL)
| [ ] B (S)`);
expect(assumedSize(n)).toBe('S');
});
it('disjunktiv mit getroffener Wahl (D61): die kleinste der realisierten', () => {
const [n] = roots(`[ ] Eltern
= [~] A (XL)
= [ ] B (S)`);
expect(assumedSize(n)).toBe('XL');
});
it('drei gleich große Alternativen bleiben ohne Stufe mehr — nur eine wird realisiert', () => {
const [n] = roots(`[ ] Eltern
| [ ] A (L)
| [ ] B (L)
| [ ] C (L)`);
expect(assumedSize(n)).toBe('L');
});
});
describe('die Schätzung zählt nur offene Kinder (D70)', () => {
it('erledigte Kinder fallen heraus — geschätzt wird der Rest', () => {
const [n] = roots(`[ ] Paket
- [x] Großteil (L)
- [ ] Rest (S)`);
expect(assumedSize(n)).toBe('S');
});
it('die Stufe ab drei zählt nur die offenen', () => {
const [n] = roots(`[ ] Paket
- [x] A (S)
- [x] B (S)
- [ ] C (S)
- [ ] D (S)`);
expect(assumedSize(n)).toBe('S');
});
it('alles Benannte erledigt: XS — der eigene Rest, nicht der M-Rückfall', () => {
/* Mit M-Rückfall ERHÖHTE das Fertigstellen des letzten Kindes den
Preis von S auf M — die Schätzung muss monoton sinken. */
const [n] = roots(`[ ] Paket
- [x] A (S)
- [x] B (S)`);
expect(assumedSize(n)).toBe('XS');
expect(ownCost(n)).toBe(1);
});
it('ein echtes Blatt ohne Kinder bleibt beim M-Rückfall', () => {
expect(assumedSize(roots('[ ] Blatt')[0])).toBe('M');
});
it('disjunktiv: eine erledigte realisierte Alternative stellt die Gruppe fertig', () => {
const [n] = roots(`[ ] Paket
| [x] Gemacht (S)
| [ ] Teuer (M)`);
expect(assumedSize(n)).toBe('XS');
});
it('disjunktiv: erledigt neben angefangen — die Gruppe trägt nichts mehr bei', () => {
/* Der Pfad wählte unter den realisierten die erledigte (Kosten 0, D61) —
die Schätzung folgt derselben Wahl. */
const [n] = roots(`[ ] Paket
| [x] Fertig (L)
| [~] Angefangen (S)`);
expect(assumedSize(n)).toBe('XS');
});
it('eine ANGEGEBENE Größe bleibt trotz erledigter Kinder stehen (D69)', () => {
const [n] = roots(`[ ] Paket (L)
- [x] Großteil (L)`);
expect(assumedSize(n)).toBe('L');
});
});
describe('ownCost und Pfad rechnen mit der Schätzung', () => {
it('ownCost eines größenlosen Elternknotens folgt der Schätzung', () => {
const [n] = roots(`[ ] Eltern
- [ ] Kind (XL)`);
expect(ownCost(n)).toBe(ownCost(roots('[ ] X (XL)')[0]));
});
it('cheapestCost steigt gegenüber der alten M-Annahme', () => {
const derived = cheapestCost(roots(`[ ] Eltern
- [ ] Kind (XL)`)[0]);
const stated = cheapestCost(roots(`[ ] Eltern (M)
- [ ] Kind (XL)`)[0]);
expect(derived).toBeGreaterThan(stated);
});
it('am erledigten Knoten wird weiterhin nichts angenommen (Kosten 0)', () => {
const [n] = roots(`[x] Eltern
- [x] Kind (XL)`);
expect(ownCost(n)).toBe(0);
});
});
describe('Badge zeigt die geschätzte Größe', () => {
it('das invertierte Badge trägt die abgeleitete Größe statt M', () => {
const { html } = renderTreeHtml(roots(`[ ] Eltern
- [ ] Kind (XL)`), opts());
expect(html).toContain('XL');
});
it('Tooltip und aria-label nennen die geschätzte Größe', () => {
const { html } = renderTreeHtml(roots(`[ ] Eltern
- [ ] Kind (L)`), opts());
expect(html).toContain('implicitSizeTooltip:L');
expect(html).toContain('a11ySizeImplicit:L');
});
it('Blatt ohne Größe zeigt weiterhin M', () => {
const { html } = renderTreeHtml(roots('[ ] Blatt'), opts());
expect(html).toContain('M');
});
});