diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cb41ea3..836977b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2909,3 +2909,91 @@ auf dem Dev-Server ist das gleichgültig, weil dort HMR arbeitet. | Server liefert anderen Commit | — | „✅ Neuer Build 2222222“, Banner + Footer-Symbol | | Danach wieder derselbe Commit | Banner blieb stehen | Banner und Symbol verschwinden | | „Später“, dann F5 | Banner sofort wieder da | weg und bleibt weg | + +## D46 — Der günstigste Pfad zeigt die offene Front: `[x]` kostet nichts mehr +Der Pfad (D18/D42) rechnete rein aus T-Shirt-Größen — `ownCost()` war +`SIZE_RANK[size] + 1`, der **Status kam in der Kostenrechnung überhaupt nicht +vor** (nur `[-]` flog heraus). Zwei Folgen, beide falsch für die Frage, für die +man auf den Pfad schaut: + +- Längst Erledigtes wurde voll eingepreist und lag weiter hell auf dem Pfad. Im + mitgelieferten Werkbaum-Plan zeichnete die Linie damit überwiegend **fertige + Arbeit** nach: 69 Stationen, fast alle auf `[^]`-Knoten. +- In einer Alternativgruppe hatte eine **bereits realisierte** Alternative + keinerlei Kostenvorteil. Steht in einer `=`-Gruppe eine auf `[x]` und daneben + eine billigere auf `[?]`, empfahl der Pfad die billigere — obwohl die Wahl + faktisch getroffen und bezahlt ist. Die `xorConflict`-Warnung (D35) meldete + den Widerspruch bereits, die Pfadrechnung ignorierte ihn. + +**Entschieden: Erledigtes kostet 0.** Der Pfad beantwortet damit „was ist als +Nächstes am günstigsten?" statt „was hätte der Plan von vorn gekostet?". + +**Die Schwelle liegt bei `[x]` fertig** (Nutzer). Begründung: Die Beförderung +auf `[^]` ist keine Kostenfrage — sie sagt etwas über das Deployment, und das +tut per D30 ohnehin ein eigener Commit. Wer fertig ist, hat bezahlt. + +**Angefangenes (`[~]`, `[/]`) zählt weiterhin voll.** Erwogen und verworfen +waren anteilige Restkosten (etwa 2/3 bzw. 1/3): Die Bruchteile wären erfunden — +die Größen sind **ordinal, nicht additiv** (ROADMAP, Aufwands-Rollup: `S+S ≠ M`), +eine Skala für „ein Drittel von L" gibt es nicht. Ebenfalls verworfen: alles ab +`[~]` als bezahlt zu werten („versunkene Kosten sind versunken"). Das zöge den +Pfad in jeden angefangenen Zweig, auch wenn dort noch fast alles offen ist — +und `[~]` heißt laut §4 gerade „Kosten investiert, **Risiko hoch**". + +**Maßgeblich ist der intrinsische Status, nicht der effektive.** Ein `[x]`, das +von einer Abhängigkeit zurückgehalten wird, ist effektiv weiter unten (D39) — +die **Arbeit daran** ist trotzdem getan. Dieselbe Linie, die schon die XOR-Regel +(D35: „investiert ist investiert") und „Was ist neu?" (D28: `[^]` im Text ist +die Deploy-Aussage) ziehen. Nebenbei verhindert es doppeltes Zählen: Die +Abhängigkeit steht mit ihren eigenen Kosten ohnehin selbst auf dem Pfad. + +**Abgezogen werden nur die eigenen Kosten, nicht der Teilbaum.** Ein `[x]`-Knoten +mit offenen Kindern bleibt also teuer — was stimmt, und was eine unstimmige +Stelle im Plan sichtbar lässt, statt sie zuzudecken. + +**Darstellung: Farbe bleibt, nur Linie und Punkte lassen Erledigtes aus** +(Nutzer). Erledigte Knoten bleiben `cheap` und behalten ihre volle Statusfarbe — +grün bzw. blau sagt bereits „hier ist nichts mehr zu tun". Verworfen war, +sie wie nicht benötigte Knoten auszublassen: Das ist der Kanal für „gehört nicht +zum Plan" (D18), und ein fertiger Knoten sähe damit aus wie eine verworfene +Alternative. Ebenfalls verworfen: eine **zweite, schwächere** Abblendstufe — sie +müsste sich von der Pfad-Inversion unterscheiden lassen, und der Unterschied +zwischen 32 % und, sagen wir, 65 % Deckkraft ist kein Unterschied in der Art. +Drei Zustände sind so ohne neuen Farbkanal unterscheidbar: nicht nötig (blass), +nötig und erledigt (voll, ohne Punkt), nötig und offen (voll, mit Punkt und +Linie). + +**Station ist der tiefste noch OFFENE Knoten eines Zweigs.** Die alte Regel +(„kein Kind liegt auf dem Pfad") reicht dafür nicht: Sind alle Kinder erledigt +und der Elternknoten nicht, hätte der Zweig gar keine Station, obwohl dort noch +Arbeit liegt — nämlich seine. `hidesOpenCheap()` fragt deshalb den **Teilbaum** +statt nur die direkten Kinder. Der eingeklappte Knoten (D38-Nachtrag) erbt +dieselbe Verschärfung: Er vertritt seinen Teilbaum nur noch, solange darin etwas +offen ist; ein fertig zusammengefalteter Zweig bekommt keinen Punkt mehr. + +**Das implizite M-Badge entfällt an erledigten Knoten.** Es macht eine +**Kostenannahme** sichtbar (D18) — wo keine getroffen wird, gibt es nichts zu +zeigen. Gilt für Badge und `aria-label` gemeinsam. + +**Kein neuer Umschalter** (Nutzer). Der vorhandene „günstigster Pfad" ändert +seine Bedeutung; erwogen war ein zweiter Knopf „offene Front ⇄ Gesamtplan", der +auch „was kostet der Plan insgesamt?" beantwortet hätte. Verworfen: ein neunter +Knopf im Diagramm-Kopf plus i18n in neun Sprachen für eine Frage, die man beim +Planen selten stellt — und die Kopfzeile wurde gerade erst entlastet +(D17-Nachtrag 5). + +**Nachgemessen am mitgelieferten Werkbaum-Plan:** 110 Knoten auf dem Pfad +(unverändert), Stationen **69 → 24**. Die verbleibenden sind exakt die offene +Front — Ticket-Referenzen, Öffnen/Speichern, Backend-Gerüst samt REST und +Persistenz, Websocket-Transport, Text-CRDT, die Mermaid-Layout-Teile, das +IDEA-Plugin. Die Linie wird weiterhin gezogen (zwei Pfade: kräftig hinten, +abgetönt vorn). Grafikexport und Druck folgen ohne Zusatzcode — beide lesen +`.node.cheap-leaf` aus dem DOM. 236 Tests grün, davon 14 neue +(`tests/frontier.test.js`); der Snapshot des kanonischen Beispiels ändert sich +um genau zwei Knoten (`[x] Zielgruppenanalyse`, `[x] Sitemap` verlieren +`cheap-leaf`). + +**Was das für den gestaffelten Pfad aus der ROADMAP bedeutet:** Dessen +Status-Hälfte ist damit gebaut. Offen bleibt die **Nutzen-Achse** (Ausbaustufen: +ist eine Gruppe komplett realisiert, zur nächsten per Nutzen gewählten Stufe +weiterspringen) — die braucht erst ein Nutzen-Attribut und den Aufwands-Rollup. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 99aba5a..515a051 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -95,14 +95,13 @@ Tastendruck ein Commit); und wer bei einem Backend eigentlich was darf reiner Kostenminimierung. Rollt analog zum Aufwands-Rollup hoch. (Syntax offen — SPEC-first; `(…)`/`@`/`#`/`%%` sind belegt, es braucht ein eigenes kollisionsfreies Zeichen.) -- Gestaffelter „günstigster Pfad" (Ausbaustufen): die Pfad-Rechnung (D18) - status-bewusst machen — `fertig`/`prod` gelten als erledigt (Hintergrund), - hervorgehoben wird nur der günstigste noch **offene** Rest (aktuelle Front). - Ist eine Alternativen-Gruppe komplett realisiert, springt der Pfad zur - nächsten, per Nutzen gewählten Ausbaustufe weiter (Regel: erst die Kette auf - `fertig`, dann die nächste Stufe). Baut auf Nutzen + Aufwands-Rollup auf und - erweitert D18 (heute rein statisch/kostenminimierend) um Nutzen- und - Status-/Zeitachse. +- Gestaffelter „günstigster Pfad" (Ausbaustufen) — **die Status-Hälfte ist + gebaut** (D46): `fertig`/`prod` kosten nichts mehr, hervorgehoben wird der + günstigste noch **offene** Rest (aktuelle Front); eine realisierte Alternative + gewinnt ihre Gruppe. Offen bleibt die **Nutzen-Achse**: Ist eine + Alternativen-Gruppe komplett realisiert, zur nächsten, per Nutzen gewählten + Ausbaustufe weiterspringen (Regel: erst die Kette auf `fertig`, dann die + nächste Stufe). Das braucht erst Nutzen + Aufwands-Rollup (oben). - Attribut-Syntax für Termine/Meilensteine. - Schlagworte `&tag` mit einer Filter-/Hervorheben-Linse im Diagramm: Der Baum ist genau eine Zerlegung — ein Tag benennt eine Menge von Knoten quer dazu diff --git a/docs/SPEC.md b/docs/SPEC.md index c1e15dd..4c05524 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -459,9 +459,19 @@ Voreinstellung auf kleinem Bildschirm ist **Vollbild** (siehe D17). ### Günstigster Pfad (Kosten-Hervorhebung) Ein Umschalter (Icon-Button im Diagramm-Kopf, Voreinstellung **an**, Zustand -persistiert) hebt den kostengünstigsten Weg durch den Baum hervor. Ermittelt +persistiert) hebt den kostengünstigsten Weg durch den Baum hervor. Gerechnet +wird die **noch offene** Arbeit — der Pfad beantwortet „was ist als Nächstes +am günstigsten?", nicht „was hätte der Plan von vorn gekostet?". Ermittelt werden die für die günstigste Realisierung **nötigen** Knoten: +- **Erledigtes kostet nichts mehr:** Ein Knoten mit `[x]` oder `[^]` (§4) geht + mit **0** in die Rechnung, unabhängig von seiner Größe. Angefangenes (`[~]`, + `[/]`) zählt dagegen **voll** — die Arbeit ist noch offen. Maßgeblich ist der + **intrinsische** Status: Investiert ist investiert, auch wenn Abhängigkeiten + den Knoten effektiv zurückhalten (§4). Abgezogen werden nur die **eigenen** + Kosten des Knotens, nicht die seines Teilbaums. Folge in Alternativgruppen: + Eine bereits realisierte Alternative gewinnt, auch wenn eine unangetastete + nominell billiger wäre — die Wahl ist getroffen und bezahlt. Siehe D46. - **all of:** alle Kinder sind nötig. - **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe plus — je Gate — Summe bzw. @@ -497,20 +507,27 @@ Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück (blass, entsättigt); der günstige Pfad hebt sich dadurch von selbst ab — kein zusätzlicher Rahmen an den -ohnehin dichten Knoten-Ecken. Wo die Größe **implizit** als `M` angenommen wird, +ohnehin dichten Knoten-Ecken. **Erledigte Knoten treten nicht zurück**: Sie +gehören zum Pfad und behalten ihre volle Statusfarbe (§4) — grün bzw. blau sagt +bereits „hier ist nichts mehr zu tun"; sie auszublassen hieße, sie sähen aus wie +eine verworfene Alternative. Wo die Größe **implizit** als `M` angenommen wird, zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit petrolfarbenem -Rand/Text statt gefüllt) mit erläuterndem Tooltip. +Rand/Text statt gefüllt) mit erläuterndem Tooltip — an einem **erledigten** +Knoten entfällt es, dort wird keine Kostenannahme mehr getroffen. Zusätzlich fädelt eine **gestrichelte, geschwungene Petrol-Linie** durch die -**Endknoten (Blätter)** des Pfads (Katmull-Rom-Spline in Dokument-Reihenfolge, +**offenen Endknoten** des Pfads (Katmull-Rom-Spline in Dokument-Reihenfolge, in allen Modi). Die kräftige Linie liegt **hinter** den Knoten (nur in den Lücken voll sichtbar), eine **abgetönte Kopie** davor deutet den Verlauf beim -Durchschreiten eines Knotens nur schwach an. An jedem echten Endknoten sitzt ein -großer, **blasser Petrol-Stationspunkt** (U-Bahn-Plan-Prinzip: der Knotentext -bleibt lesbar) — nur **durchquerte** Fremd- oder Zwischenknoten tragen keinen -Punkt, sodass eindeutig bleibt, welche Knoten auf dem Pfad **enden**. Linie, +Durchschreiten eines Knotens nur schwach an. An jedem solchen Endknoten sitzt +ein großer, **blasser Petrol-Stationspunkt** (U-Bahn-Plan-Prinzip: der +Knotentext bleibt lesbar) — **durchquerte** Fremd- oder Zwischenknoten und +**erledigte** Knoten tragen keinen, sodass eindeutig bleibt, wo noch Arbeit +**endet**. Station ist damit der tiefste noch offene Knoten eines Zweigs: Sind +alle Kinder erledigt, wird der offene Elternknoten selbst die Station (die +Restarbeit ist dann seine); ist ein ganzer Zweig erledigt, hat er keine. Linie, abgetönte Kopie und Punkte erscheinen bei aktivem Umschalter auch im -Grafikexport. Siehe D18. +Grafikexport. Siehe D18, D46. ### Diagramm aus einer URL laden (`?sourceUrl=`) Der Editor kann den Notationstext aus einer externen Textdatei beziehen: @@ -723,10 +740,13 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom). weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet 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 + Pfad:** Liegt darin noch **offene** Pfadarbeit (§9, Erledigtes zählt nicht), + ist er deren 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 + wäre dort nichts zu tun. Ist der verborgene Teilbaum dagegen **fertig**, + bekommt der eingeklappte Knoten keinen Punkt — dort ist wirklich nichts mehr + 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 diff --git a/docs/TASKS.md b/docs/TASKS.md index 0e19c07..2e9d126 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -126,3 +126,19 @@ entscheiden, **bevor** Code entsteht. einem `---`-Trenner (SPEC §11). Umgesetzt (D40): Parser (`desc` am Knoten, Warnungen `unknownDesc`/`descStray`), Anzeige im Tooltip + `aria-label` mit ”-Marke am Knoten; `tests/desc.test.js`. + +## Phase 5 — Der Pfad zeigt, was als Nächstes dran ist +- [x] Günstigsten Pfad status-bewusst rechnen: Erledigtes (`[x]`/`[^]`) kostet + nichts mehr, hervorgehoben wird die offene Front. + → Umgesetzt (D46): `isDone()` + `ownCost()` in model.js; Stationen sind + die tiefsten noch **offenen** Pfadknoten (`hidesOpenCheap()`), erledigte + behalten Farbe und Pfadzugehörigkeit, verlieren aber Punkt und implizites + M-Badge. Angefangenes (`[~]`/`[/]`) zählt weiter voll, maßgeblich ist der + intrinsische Status. Kein neuer Umschalter — der vorhandene ändert seine + Bedeutung. `tests/frontier.test.js`. +- [ ] Nutzen je Knoten (Gegenstück zur Kosten-Größe) — Schreibweise offen, + SPEC-first (siehe ROADMAP „Kleinere Ideen"). +- [ ] Aufwands-Rollup (Größen der Kinder aggregieren) — braucht erst eine + Mapping-Konvention, Größen sind ordinal. +- [ ] Ausbaustufen: ist eine Alternativgruppe komplett realisiert, zur nächsten + per Nutzen gewählten Stufe weiterspringen. Baut auf beidem oben auf. diff --git a/frontend/public/llms.md b/frontend/public/llms.md index 1aa670f..151b820 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -74,6 +74,13 @@ One node per line. Everything except the label is optional. - No box = neutral node. `x` may also be uppercase `X`. - Unknown codes are tolerated: the node renders neutral, with a warning — the line is never lost. +- **`[x]` and `[^]` cost nothing any more.** The cheapest path prices the + work that is *left*, so a done node adds zero regardless of its size and + carries no station on the path line; started work (`[~]`, `[/]`) still + counts in full. A realized alternative therefore wins its `|`/`=` group + even when a cheaper unstarted one sits next to it. The *intrinsic* status + decides — dependencies may hold a node back effectively, but the work on + it is paid for. ### Size (effort) diff --git a/frontend/src/model.js b/frontend/src/model.js index 8334823..1458a68 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -49,8 +49,19 @@ export function pathChildren(n){ return n.children.filter(k => !k.optional && (!k.status || k.status.key !== 'verworfen')); } -/* fehlende Größe wird als M interpretiert */ -export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; } +/* ---------- Erledigt: was nichts mehr kostet (SPEC §9, D46) ---------- + `[x]` fertig und `[^]` in Produktion. Die Beförderung auf Prod ist keine + Kostenfrage (D30: das tut der Deploy), fertig ist also die Schwelle. + Angefangenes (`[~]`, `[/]`) zählt bewusst voll: Die Arbeit ist noch offen, + und Bruchteile ordinaler T-Shirt-Größen wären erfunden. + Maßgeblich ist der INTRINSISCHE Status — investiert ist investiert, auch + wenn Abhängigkeiten den Knoten effektiv zurückhalten (D39); deren eigene + Kosten stehen ohnehin an ihnen selbst. */ +export function isDone(n){ + return !!n.status && (n.status.key === 'fertig' || n.status.key === 'prod'); +} +/* fehlende Größe wird als M interpretiert; Erledigtes kostet nichts mehr */ +export function ownCost(n){ return isDone(n) ? 0 : SIZE_RANK[n.size || 'M'] + 1; } export function cheapestCost(n){ const kids = pathChildren(n); let c = ownCost(n); @@ -185,19 +196,35 @@ export function hidesCheap(n, cheapSet){ } return false; } +/* Dasselbe, aber nur für noch OFFENE Pfadarbeit (D46): Erledigtes zählt nicht + mehr, es ist keine Station und hält auch keine unter sich. */ +export function hidesOpenCheap(n, cheapSet){ + for(const k of n.children || []){ + if((cheapSet.has(k) && !isDone(k)) || hidesOpenCheap(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'; + /* Stationen sind die tiefsten noch OFFENEN Knoten des Pfads (D46). Ein + erledigter Knoten bleibt `cheap` — er gehört zum Pfad und behält seine + volle Statusfarbe —, trägt aber keinen Punkt und wird von der Linie + übergangen; die zeigt den günstigsten noch zu gehenden Rest. + Eingeklappt steht der Knoten stellvertretend für seinen ganzen Teilbaum + (SPEC §9/D38): Liegt darin noch offene Pfadarbeit, ist er deren 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. */ + const onPath = cheapSet.has(n); + if(collapsed){ + if((onPath && !isDone(n)) || hidesOpenCheap(n, cheapSet)) return 'cheap cheap-leaf'; + return (onPath || hidesCheap(n, cheapSet)) ? 'cheap' : ''; + } + if(!onPath) return ''; + if(isDone(n)) return 'cheap'; + return hidesOpenCheap(n, cheapSet) ? 'cheap' : 'cheap cheap-leaf'; } /* ---------- Effektiver Status (SPEC §4/§9, D39) ---------- diff --git a/frontend/src/render.js b/frontend/src/render.js index 97f2be4..b026147 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -15,7 +15,7 @@ // Diskrepanzen (effectiveStatus() in model.js, D39) } */ -import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; +import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone } from './model.js'; /* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion" gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und @@ -68,7 +68,7 @@ function nodeAria(n, opts, fold){ const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined; if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)})); if(n.size) parts.push(t('a11ySize', {size: n.size})); - else if(cheapPath) parts.push(t('a11ySizeImplicit')); + else if(cheapPath && !isDone(n)) parts.push(t('a11ySizeImplicit')); if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); /* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung im Diagramm — sichtbar nur im Tooltip und hier. */ @@ -131,10 +131,13 @@ function nodeHtml(n, extra, opts, fold){ const tagsHtml = n.tags && n.tags.length ? `` : ''; + /* Das implizite M-Badge macht eine KOSTENANNAHME sichtbar (D18). An einem + erledigten Knoten wird keine getroffen — er kostet nichts mehr (D46) —, + dort bleibt es deshalb weg. */ const implicitTip = attr(t('implicitSizeTooltip')); const sizeBadge = n.size ? `` - : (cheapPath ? `` : ''); + : (cheapPath && !isDone(n) ? `` : ''); /* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */ const riskMark = n.status && n.status.key === 'highrisk' diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index 9b01342..e6eab39 100644 --- a/frontend/tests/__snapshots__/render.test.js.snap +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -2,6 +2,6 @@ exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
  • "`; diff --git a/frontend/tests/frontier.test.js b/frontend/tests/frontier.test.js new file mode 100644 index 0000000..a1cc335 --- /dev/null +++ b/frontend/tests/frontier.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { + isDone, ownCost, cheapestCost, cheapCls, computeCheapSet, +} from '../src/model.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 */ + }); +});