From bb010454886a1363ea251391c13ffbffcb4d9f36 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 24 Aug 2026 19:59:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Fehlende=20Gr=C3=B6=C3=9Fe=20aus=20den?= =?UTF-8?q?=20Teilpaketen=20gesch=C3=A4tzt=20statt=20pauschal=20M=20(D66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mindestens die größte Größe der zählenden Kinder, ab drei Kindern dieser Größe eine Stufe mehr (Deckel XXL); disjunktiv die kleinste Alternative, rekursiv über größenlose Kinder, Blatt bleibt M. Badge, Tooltip und aria-label zeigen die geschätzte Größe („mindestens {size} angenommen"). Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 1 + docs/DECISIONS.md | 62 +++++++++++++ docs/SPEC.md | 24 +++-- frontend/public/llms.md | 6 +- frontend/src/app.js | 36 ++++---- frontend/src/model.js | 48 ++++++++-- frontend/src/render.js | 25 +++--- frontend/tests/assumed.test.js | 158 +++++++++++++++++++++++++++++++++ 8 files changed, 317 insertions(+), 43 deletions(-) create mode 100644 frontend/tests/assumed.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5dd0557..e40fb9b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-24 +- A missing size is now estimated from the sub-packages — at least the largest child size, one step more from three children of that size - Node titles wrap at thirty-two characters now instead of forty — long titles pulled the diagram wide - Fix: in the vertical layout, the line from a parent to one or two children was torn — the collector rail now always reaches the parent's stub - The legend shows the high-risk status `[!]` and mentions the size check diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c1fd2da..f9c5ffd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4928,3 +4928,65 @@ bleiben unberührt. Sichtbare Folge: mehr Knoten brechen um (rund 100 Zeichen ergeben jetzt vier statt drei Zeilen), dafür wird der breiteste Fächer schmaler. Testerwartungen mit dem echten Algorithmus nachgerechnet statt geschätzt. + +## D66 — Fehlende Größe wird aus den Teilpaketen geschätzt statt pauschal M +Gewünscht vom Nutzer: „Die Kostenschätzung für Knoten ohne explizite +T-Shirt-Größe soll anhand der Sub-Knoten geschehen. Mindestens die größte +T-Shirt-Größe der Sub-Knoten; wenn mehr als 2 diese Größe haben, also ab 3, +dann sogar eine T-Shirt-Größe mehr." Die alte D18-Pauschale — fehlende Größe += M — unterschätzte jeden größenlosen Sammelknoten, sobald ein Kind über M +lag; die Kinder sagen mehr, als die Pauschale nutzte. + +**Die Regel:** Angenommen wird **mindestens die größte Größe der zählenden +Kinder**; tragen **drei oder mehr** Kinder diese größte Größe, eine Stufe +mehr (Deckel `XXL`). Ein Knoten ohne Größe und ohne zählende Kinder bleibt +beim M-Rückfall — für ein Blatt gibt es nichts abzuleiten, und D18 nannte M +selbst schon „die konservative Annahme ‚mindestens M'". + +**Es zählen dieselben Kinder wie beim Größen-Konflikt (§5/D62)** — direkte, +verworfene und optionale (`+`) nie, in einer disjunktiven Gruppe (`|`/`=`) +nur die **kleinste** Alternative —, mit genau einem Unterschied: Kinder +**ohne** Größe zählen hier mit, ihre Größe wird nach derselben Regel +**rekursiv** mitgeschätzt. D62 schließt sie aus, weil eine fehlende Größe +keine Autoren-Aussage ist und die Konfliktprüfung nur meldet, was sicher +ist; hier wird ohnehin geschätzt — sie auszuschließen hieße, drei größenlose +Blätter für kostenlos zu halten. Die D62-Zählung zu übernehmen statt eine +dritte Regelmenge zu erfinden hält die Doku bei einem Satz: „dieselben +Kinder wie beim Größen-Konflikt". + +**Disjunktiv gilt das Minimum, und keine Stufe mehr.** Realisiert wird genau +eine Alternative — die kleinste ist der Boden, den jede Wahl mindestens +kostet, und drei gleich große Alternativen sind kein dreifacher Aufwand. Ist +in der Gruppe etwas realisiert, ist die Wahl getroffen (D61, `chosenPool`): +Dann zählt die kleinste der **realisierten**. Bewusst **nicht** die +Alternative, die der günstigste Pfad wählt — die Wahl hängt von den Kosten +ab und die Kosten hingen dann von der Wahl: ein Zirkel. Das Minimum ist +deterministisch und für ein „mindestens" die ehrliche Untergrenze. + +**Nur die Kostenschätzung ändert sich.** Die §5-Semantik bleibt unberührt, +die Konfliktprüfung (D62) rechnet weiter nur mit angegebenen Größen, und die +Falt-Voreinstellung „ab M abwärts" (D44, `atMostM`) klappt weiterhin nichts +ohne Größenangabe zu — dort wäre die Schätzung eine Vermutung, die wie eine +Angabe behandelt würde (die D44-Begründung gilt wörtlich weiter). + +**Das invertierte Badge zeigt jetzt die geschätzte Größe** statt immer „M"; +Tooltip und `aria-label` sagen „mindestens {size} angenommen". Die beiden +vorhandenen i18n-Schlüssel wurden **parameterisiert** statt verdoppelt: Ein +Text mit „mindestens {size}" stimmt für die Ableitung wie für den +M-Rückfall — kein neuer Schlüssel in neun Sprachen für dieselbe Aussage. + +**Memoisiert per WeakMap**, nicht am Knotenobjekt und nicht ungecacht: +`computeCheapPlan` (D42) ruft `ownCost` je Suchbelegung über die ganze +nötige Menge — eine ungecachte Rekursion wäre O(n²) je Belegung, bei 20 000 +Belegungen zu viel. Der Parse-Baum wird bei jedem Tastendruck neu gebaut, +der Cache kann also nie veralten, und die WeakMap gibt alte Bäume von +selbst frei. + +**Nachgemessen:** Der mitgelieferte Plan hat genau **einen** größenlosen +Knoten (`#not.people`, ein Blatt → weiterhin M) — Pfadknoten 135 und +Stationen 27 unverändert, 0 Warnungen. Das kanonische Beispiel (§10) ändert +sich nicht (seine größenlosen Knoten sind Blätter); alle Snapshots bleiben +stehen. 424 Tests, davon 19 neue in `tests/assumed.test.js`. Gegenprobe per +Mutation: Stufe-ab-drei entfernt → genau die drei danach benannten +Zusicherungen fallen; disjunktiv Maximum statt Minimum → genau eine; alte +M-Pauschale in `ownCost` zurückgebaut → genau die zwei Kosten-Tests. diff --git a/docs/SPEC.md b/docs/SPEC.md index 95639f4..db5ab6f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -603,8 +603,17 @@ werden die für die günstigste Realisierung **nötigen** Knoten: entbehrlich (§3). Siehe D61. - Verworfene Knoten zählen nie mit (unabhängig vom „verworfene einblenden"- Filter). -- **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die - SPEC-Semantik der Größen in §5 bleibt unberührt). +- **Fehlende Größe wird aus den Teilpaketen geschätzt** (nur für diese + Kostenschätzung; die SPEC-Semantik der Größen in §5 bleibt unberührt): + Angenommen wird **mindestens die größte Größe der zählenden Kinder**; + tragen **drei oder mehr** Kinder diese größte Größe, eine Stufe mehr + (Deckel: `XXL`). Es zählen dieselben Kinder wie beim Größen-Konflikt (§5) — + nur die direkten, verworfene und optionale (`+`) nie, in einer disjunktiven + Gruppe (`|`/`=`) nur die **kleinste** Alternative (ist dort etwas realisiert, + §3, die kleinste der realisierten) —, mit einem Unterschied: Kinder **ohne** + Größe zählen hier mit, ihre Größe wird nach derselben Regel **rekursiv** + mitgeschätzt — geschätzt wird ohnehin. Ein Knoten ohne Größe und ohne + zählende Kinder wird weiterhin als `M` gewertet. **Mit Abhängigkeiten (§1) zählt die Dependency Closure.** Jeder nötige Knoten zieht seine `:#…`-Ziele samt deren Realisierung in die nötige Menge; gemeinsam @@ -637,10 +646,11 @@ Alternative behalten ihre Farbe. Blass heißt damit einheitlich „hier ist nich getan und wird auch nichts getan". Maßgeblich ist der **intrinsische** Status (die eigene Box), gefärbt wird weiterhin nach dem effektiven (oben) — ein von Abhängigkeiten zurückgehaltenes `[x]` steht also in voller Stärke in seiner -effektiven Farbe. 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 — an einem **erledigten** -Knoten entfällt es, dort wird keine Kostenannahme mehr getroffen. +effektiven Farbe. Wo die Größe **implizit** angenommen wird (aus den +Teilpaketen geschätzt bzw. `M`, oben), zeigt der Knoten die angenommene Größe +als **invertiertes** Größen-Badge (weiß mit petrolfarbenem 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 **offenen Endknoten** des Pfads (Katmull-Rom-Spline in Dokument-Reihenfolge, @@ -1070,7 +1080,7 @@ Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen ### Barrierefreiheit Die visuell codierten Knoten-Eigenschaften werden für Screenreader in einem sprechenden **`aria-label`** je Knoten zusammengefasst — Label, Status, Aufwand -(inkl. „(angenommen)“ beim impliziten M), Zuständige, ob der Knoten optional +(inkl. „(angenommen)“ bei implizit geschätzter Größe), Zuständige, ob der Knoten optional (§3) und ob er verlinkt ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke (Größen-Badge, Tags, ↗-Pfeil) sind `aria-hidden`, damit sie nicht kryptisch doppelt vorgelesen werden. **Alle** Knoten sind fokussierbar (`tabindex="0"` diff --git a/frontend/public/llms.md b/frontend/public/llms.md index 033c7b3..c2bf240 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -108,7 +108,11 @@ One node per line. Everything except the label is optional. - T-shirt sizes in parentheses: `(XS) (S) (M) (L) (XL) (XXL)`. - From `(M)` upward a node **should be decomposed further**; a node ≥ M without children gets a placeholder hint in the diagram. -- For cost estimation a missing size counts as `M`. +- For cost estimation a missing size is **estimated from the sub-packages**: + at least the largest size among the counting children (same children as + the conflict check below, except unsized children count too — estimated + recursively); three or more children at that largest size raise it by one + step, capped at `XXL`. Without counting children it stays `M`. - **Size conflict check:** a given size must fit the direct children. For this one check each size is read as a range (lower bounds double, XXL is open-ended): diff --git a/frontend/src/app.js b/frontend/src/app.js index 5067cde..4151be8 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -2146,7 +2146,7 @@ const I18N = { cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück", leanNextTooltip:"Zur nächsten Station des günstigsten Pfads springen – was als Nächstes dran ist ({n} offen)", foldSmallTooltip:"Knoten der Größe M und kleiner zuklappen – erneut drücken klappt alle wieder auf", - implicitSizeTooltip:"Keine Größe angegeben – für die Kostenschätzung als M angenommen", + implicitSizeTooltip:"Keine Größe angegeben – für die Kostenschätzung mindestens {size} angenommen", fullscreenTooltip:"Vollbild – Panels nutzen die ganze Fensterbreite", brandTooltip:"„Werkbaum“ bedeutet so viel wie ‚Werk-Baum‘ — der Baum des Projektstrukturplans (WBS).", editorTitle:"Struktur (Text)", diagramTitle:"Diagramm", @@ -2202,7 +2202,7 @@ const I18N = { unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.", - a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_xor:"Alternative, genau eine", @@ -2251,7 +2251,7 @@ const I18N = { cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede", leanNextTooltip:"Jump to the next station on the cheapest path – what to tackle next ({n} open)", foldSmallTooltip:"Collapse nodes of size M and smaller – press again to expand them all", - implicitSizeTooltip:"No size given – assumed as M for the cost estimate", + implicitSizeTooltip:"No size given – assumed at least {size} for the cost estimate", fullscreenTooltip:"Full screen – panels use the full window width", brandTooltip:"“Werkbaum” means roughly ‘work tree’ — the tree of the work breakdown structure (WBS).", editorTitle:"Structure (text)", diagramTitle:"Diagram", @@ -2306,7 +2306,7 @@ const I18N = { unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.", - a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_xor:"alternative, exactly one", @@ -2355,7 +2355,7 @@ const I18N = { cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan", leanNextTooltip:"Ir a la siguiente estación de la ruta más económica: lo próximo que toca ({n} pendientes)", foldSmallTooltip:"Plegar los nodos de talla M o menor: pulsa de nuevo para desplegarlos todos", - implicitSizeTooltip:"Sin tamaño indicado: se asume M para el cálculo de costes", + implicitSizeTooltip:"Sin tamaño indicado: se asume al menos {size} para el cálculo de costes", fullscreenTooltip:"Pantalla completa – los paneles usan todo el ancho de la ventana", brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo’ — el árbol de la estructura de desglose del trabajo (EDT).", editorTitle:"Estructura (texto)", diagramTitle:"Diagrama", @@ -2410,7 +2410,7 @@ const I18N = { unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.", - a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_xor:"alternativa, exactamente una", @@ -2459,7 +2459,7 @@ const I18N = { cheapTooltip:"Mettre en évidence le chemin le moins coûteux – les alternatives inutiles s'estompent", leanNextTooltip:"Aller à la station suivante du chemin le moins coûteux – la prochaine chose à faire ({n} en attente)", foldSmallTooltip:"Replier les nœuds de taille M et moins – appuyez à nouveau pour tout déplier", - implicitSizeTooltip:"Aucune taille indiquée – considérée comme M pour l'estimation des coûts", + implicitSizeTooltip:"Aucune taille indiquée – au moins {size} supposé pour l'estimation des coûts", fullscreenTooltip:"Plein écran – les panneaux occupent toute la largeur de la fenêtre", brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — l’arbre de l’organigramme des tâches (WBS).", editorTitle:"Structure (texte)", diagramTitle:"Diagramme", @@ -2514,7 +2514,7 @@ const I18N = { unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (10 fois par 90 s par défaut) ; attends un instant, puis recharge.", - a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_xor:"alternative, exactement une", @@ -2563,7 +2563,7 @@ const I18N = { cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone", leanNextTooltip:"Przejdź do następnej stacji najtańszej ścieżki – co dalej ({n} otwartych)", foldSmallTooltip:"Zwiń węzły o rozmiarze M i mniejsze – naciśnij ponownie, aby rozwinąć wszystkie", - implicitSizeTooltip:"Nie podano rozmiaru – przyjęto M do szacowania kosztów", + implicitSizeTooltip:"Nie podano rozmiaru – do szacowania kosztów przyjęto co najmniej {size}", fullscreenTooltip:"Pełny ekran – panele wykorzystują całą szerokość okna", brandTooltip:"„Werkbaum” znaczy mniej więcej ‚drzewo pracy’ — drzewo struktury podziału pracy (WBS).", editorTitle:"Struktura (tekst)", diagramTitle:"Diagram", @@ -2618,7 +2618,7 @@ const I18N = { unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.", - a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_xor:"alternatywa, dokładnie jedna", @@ -2667,7 +2667,7 @@ const I18N = { cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются", leanNextTooltip:"Перейти к следующей станции самого дешёвого пути — что делать дальше ({n} открыто)", foldSmallTooltip:"Свернуть узлы размера M и меньше — нажмите ещё раз, чтобы развернуть все", - implicitSizeTooltip:"Размер не указан — для оценки затрат принят как M", + implicitSizeTooltip:"Размер не указан — для оценки затрат принят не меньше {size}", fullscreenTooltip:"Полный экран – панели занимают всю ширину окна", brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).", editorTitle:"Структура (текст)", diagramTitle:"Диаграмма", @@ -2722,7 +2722,7 @@ const I18N = { unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.", - a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_xor:"альтернатива, ровно одна", @@ -2771,7 +2771,7 @@ const I18N = { cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं", leanNextTooltip:"सबसे किफ़ायती पथ के अगले पड़ाव पर जाएँ – अगला काम ({n} शेष)", foldSmallTooltip:"आकार M और उससे छोटे नोड समेटें – सभी को खोलने के लिए फिर दबाएँ", - implicitSizeTooltip:"कोई आकार नहीं दिया गया – लागत अनुमान के लिए M माना गया", + implicitSizeTooltip:"कोई आकार नहीं दिया गया – लागत अनुमान के लिए कम से कम {size} माना गया", fullscreenTooltip:"पूर्ण स्क्रीन – पैनल पूरी विंडो चौड़ाई का उपयोग करते हैं", brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।", editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख", @@ -2826,7 +2826,7 @@ const I18N = { unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", - a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_xor:"विकल्प, ठीक एक", @@ -2860,7 +2860,7 @@ const I18N = { cheapTooltip:"突出显示成本最低的路径——不需要的备选项将淡化", leanNextTooltip:"跳到成本最低路径的下一站——接下来该做的事(还有 {n} 项)", foldSmallTooltip:"折叠尺寸 M 及更小的节点——再次点击可全部展开", - implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计", + implicitSizeTooltip:"未指定尺寸——成本估算时按至少 {size} 计", ghostTooltip:"从 M 号起,元素应进一步细分。", jumpHint:"Alt+点击:跳转到文本中的该行", jumpHintTouch:"长按:跳转到文本中的该行", @@ -2930,7 +2930,7 @@ const I18N = { unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。", - a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_xor:"备选项,恰好一个", @@ -2964,7 +2964,7 @@ const I18N = { cheapTooltip:"最も低コストの経路を強調 – 不要な選択肢は控えめに表示", leanNextTooltip:"最も低コストの経路の次の駅へ移動 – 次にやること(残り {n} 件)", foldSmallTooltip:"サイズ M 以下のノードを折りたたむ – もう一度押すとすべて展開", - implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う", + implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため少なくとも {size} として扱う", ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。", jumpHint:"Alt+クリック:テキストの該当行へ移動", jumpHintTouch:"長押し:テキストの該当行へ移動", @@ -3034,7 +3034,7 @@ const I18N = { unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。", - a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_xor:"選択肢、ちょうど1つ", diff --git a/frontend/src/model.js b/frontend/src/model.js index 00b2833..c90469a 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -24,9 +24,9 @@ export function needsBreakdown(n){ /* Faltung-Voreinstellung „Größe M und kleiner zuklappen" (SPEC §9, D44): Knoten mit ANGEGEBENER Größe bis einschließlich M — offen bleiben also nur L, XL, XXL. Fehlt die Angabe, wird nichts angenommen: Der günstigste Pfad - rechnet sie zwar als M (D18), das ist aber eine Kostenannahme des Werkzeugs - und keine Aussage des Autors; danach den Baum zuzuklappen hieße, eine - Vermutung wie eine Angabe zu behandeln. */ + schätzt sie zwar (`assumedSize`, D18/D66), das ist aber eine Kostenannahme + des Werkzeugs und keine Aussage des Autors; danach den Baum zuzuklappen + hieße, eine Vermutung wie eine Angabe zu behandeln. */ export function atMostM(n){ return !!n.size && SIZE_RANK[n.size] <= SIZE_RANK.M; } /* Sichtbare Kinder: verworfene ausblenden, außer showDiscarded ist gesetzt. */ @@ -40,7 +40,8 @@ export function visibleChildren(n, showDiscarded){ any-of und XOR ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende - Größe = M. Ist in einer disjunktiven Gruppe etwas realisiert, wird nur noch + Größe wird aus den Teilpaketen geschätzt (`assumedSize`, D66). + Ist in einer disjunktiven Gruppe etwas realisiert, wird nur noch unter den realisierten gewählt (`chosenPool`, D61). Optionale Kinder (`+`, SPEC §3/D29) fallen hier ebenfalls heraus — sie sind per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da alle @@ -80,8 +81,43 @@ export function chosenPool(kids){ const real = kids.filter(isRealized); return real.length ? real : kids; } -/* 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; } +/* ---------- Geschätzte Größe bei fehlender Angabe (SPEC §9, D66) ---------- + Statt pauschal M (die alte D18-Annahme) wird aus den Teilpaketen geschätzt: + MINDESTENS die größte Größe der zählenden Kinder; tragen drei oder mehr + Kinder diese größte Größe, eine Stufe mehr (Deckel XXL). Es zählen dieselben + Kinder wie beim Größen-Konflikt (§5/D62) — direkte, verworfene und optionale + nie, disjunktiv (`|`/`=`) nur die kleinste Alternative (aus `chosenPool`, + D61: eine getroffene Wahl gilt) — nur dass Kinder OHNE Größe hier rekursiv + mitgeschätzt werden: geschätzt wird ohnehin. Ohne zählende Kinder bleibt es + beim M-Rückfall. Memo per WeakMap: `computeCheapPlan` ruft `ownCost` je + Suchbelegung über die ganze Menge — ungecacht wäre das O(n²) je Belegung; + der Parse-Baum wird bei jedem Tastendruck neu gebaut, der Cache kann also + nie veralten. */ +const SIZE_BY_RANK = ['XS', 'S', 'M', 'L', 'XL', 'XXL']; +const ASSUMED = new WeakMap(); +export function assumedSize(n){ + if(n.size) return n.size; + const memo = ASSUMED.get(n); + if(memo) return memo; + const kids = n.children.filter(k => + !k.optional && (!k.status || k.status.key !== 'verworfen')); + let size = 'M'; + if(kids.length){ + if(gateOf(kids) !== 'and'){ + /* nur eine Alternative wird realisiert — die kleinste ist der Boden */ + size = SIZE_BY_RANK[Math.min(...chosenPool(kids).map(k => SIZE_RANK[assumedSize(k)]))]; + } else { + const ranks = kids.map(k => SIZE_RANK[assumedSize(k)]); + const max = Math.max(...ranks); + const atMax = ranks.filter(r => r === max).length; + size = SIZE_BY_RANK[Math.min(max + (atMax >= 3 ? 1 : 0), SIZE_RANK.XXL)]; + } + } + ASSUMED.set(n, size); + return size; +} +/* fehlende Größe wird geschätzt (assumedSize); Erledigtes kostet nichts mehr */ +export function ownCost(n){ return isDone(n) ? 0 : SIZE_RANK[assumedSize(n)] + 1; } export function cheapestCost(n){ const kids = pathChildren(n); let c = ownCost(n); diff --git a/frontend/src/render.js b/frontend/src/render.js index 7dc1bed..a9105b8 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -7,7 +7,7 @@ opts = { t, // i18n-Funktion (key, vars?) -> String showDiscarded, // verworfene einblenden? - cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge) + cheapPath, // günstigster Pfad aktiv? (steuert das implizite Größen-Badge) cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus) freshSet, // optional: Knoten, die neu in Produktion sind (D28) collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38) @@ -15,7 +15,7 @@ // Diskrepanzen (effectiveStatus() in model.js, D39) } */ -import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone } from './model.js'; +import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone, assumedSize } from './model.js'; /* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion" gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und @@ -106,7 +106,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 && !isDone(n)) parts.push(t('a11ySizeImplicit')); + else if(cheapPath && !isDone(n)) parts.push(t('a11ySizeImplicit', {size: assumedSize(n)})); /* Größen-Konflikt (SPEC §5/D62): die Warnfärbung des Badges kommt beim Screenreader sonst nicht an. */ if(n.sizeConflict) parts.push(t('sizeConflictTooltip')); @@ -172,10 +172,12 @@ function nodeHtml(n, extra, opts, fold){ ? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)}) : (n.status ? t('st_' + n.status.key) : ''), n.optional ? t('a11yOptional') : '', - /* Die Kostenannahme „Größe fehlt, gilt als M" (D18) hatte einen - eigenen `title` am Badge — der erschiene neben dem Fenster - ein zweites Mal. Sie gehört ohnehin zu den Kurz-Fakten. */ - (!n.size && cheapPath && !isDone(n)) ? t('implicitSizeTooltip') : '', + /* Die Kostenannahme „Größe fehlt, mindestens … angenommen" + (D18/D66) hatte einen eigenen `title` am Badge — der + erschiene neben dem Fenster ein zweites Mal. Sie gehört + ohnehin zu den Kurz-Fakten. */ + (!n.size && cheapPath && !isDone(n)) + ? t('implicitSizeTooltip', {size: assumedSize(n)}) : '', /* Größen-Konflikt (SPEC §5/D62): das warnfarbene Badge braucht seine Begründung dort, wo man nachsieht. */ n.sizeConflict ? t('sizeConflictTooltip') : '', @@ -191,15 +193,16 @@ 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. */ + /* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) — + seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An + einem erledigten Knoten wird keine getroffen — er kostet nichts mehr + (D46) —, dort bleibt es deshalb weg. */ const implicitSize = !n.size && cheapPath && !isDone(n); /* Größen-Konflikt (SPEC §5/D62): das Badge wechselt auf die Warnfarbe — die Größe selbst bleibt stehen, korrigiert wird nichts. */ const sizeBadge = n.size ? `` - : (implicitSize ? `` : ''); + : (implicitSize ? `` : ''); /* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */ /* Kein eigener `title` mehr: Er zeigte sonst zusätzlich zum Knoten-Fenster diff --git a/frontend/tests/assumed.test.js b/frontend/tests/assumed.test.js new file mode 100644 index 0000000..89864d2 --- /dev/null +++ b/frontend/tests/assumed.test.js @@ -0,0 +1,158 @@ +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('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(''); + }); + + 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(''); + }); +});