From 5b37c117e1b6361e51a49135765776cf3dcb9f1a Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 24 Aug 2026 15:55:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Gr=C3=B6=C3=9Fen-Konflikt=20=E2=80=94?= =?UTF-8?q?=20Teilpakete=20m=C3=BCssen=20zur=20angegebenen=20Gr=C3=B6?= =?UTF-8?q?=C3=9Fe=20passen=20(D62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Größen zählen für diese eine Prüfung als Bereiche (Untergrenzen verdoppeln sich, XXL nach oben offen); Konflikt erst, wenn selbst die günstigste Lesart der Kinder die großzügigste des Elternknotens erreicht. Warnung sizeConflict an der Elternzeile, Badge in --warn, Grund in Tooltip und aria-label. Nichts wird automatisch korrigiert. Nebenbefund behoben: der Grafikexport zeichnete das Größen-Badge mit festen Farben — das invertierte implizite M stand seit jeher gefüllt im Bild. Die acht berechtigten Konflikte im eigenen Plan sind nachgezogen. 372 Tests, 16 neue. Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 2 + docs/DECISIONS.md | 71 +++++++++++++++++++++++++ docs/SPEC.md | 32 ++++++++++++ docs/examples/werkbaum.werkbaum | 25 +++++---- frontend/public/llms.md | 9 ++++ frontend/src/app.js | 29 +++++++++- frontend/src/parser.js | 35 +++++++++++++ frontend/src/render.js | 10 +++- frontend/src/style.css | 4 ++ frontend/src/warnings.js | 6 +++ frontend/tests/sizes.test.js | 93 +++++++++++++++++++++++++++++++++ 11 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/sizes.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 477616a..9c64a34 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ reverse. ## 2026-08-24 +- A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported +- Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted - Dependency links now run behind every node, highlighted ones included — no more lines struck through node titles - Fix: the line to an only child was torn where the child does not sit centred in its cell - An optional node (`+`) joins the cheapest path while it is being worked on — started work is the open front diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9b9aa96..4de3d42 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4582,3 +4582,74 @@ heraus, fallen genau die fünf danach benannten Zusicherungen; nimmt man `chosenPool` heraus, genau die zwei zu den Alternativen. Die Tests, die das **unveränderte** Verhalten festhalten (unangetastete und erledigte Zugaben bleiben draußen), bleiben in beiden Fällen grün. + +## D62 — Größen-Konflikt: Bereiche statt Punkt-Zahlen, XXL nach oben offen +Gewünscht war eine Anzeige, „wenn die angegebenen T-Shirt-Größen mit den +Unterknoten nicht zusammenpassen" — ohne automatische Korrektur. Die Skala +dafür gab es schon (`SIZE_RANK`, D18 nutzt Rang+1 als Kosten); die eigentliche +Entscheidung war, **wann** „passt nicht" gilt. + +**Eine Punkt-Zahl je Größe trägt nicht — egal wie steil die Skala steigt.** +Der erste Vorschlag war eine additive Verdopplungsskala (XS=1 … XXL=32): +Summe der Kinder > Elternwert ⇒ Konflikt. Der Nutzer wandte ein, dass damit +bei jedem größeren Baum die oberen Knoten selbst mit XXL nicht auskommen — +und die Prüfung der Steilheit führte auf einen härteren Befund: Schon das +**kanonische Beispiel aus SPEC §10** warnte. `Website-Relaunch (XL)` mit +Kindern `(M) + (XL) + (M)` ergibt bei Faktor 2 wie bei Faktor 3 eine Summe +über dem Elternwert — sobald ein Kind die Elterngröße teilt, sprengt jedes +Geschwister die Summe. Das widerspricht der gelebten Praxis, dass „XL = +XL-Kind plus etwas Kleinkram" eine völlig normale Schätzung ist. Der Fehler +liegt nicht im Faktor, sondern darin, dass eine Punkt-Zahl so tut, als wäre +`(M)` exakt. + +**Entschieden: Größen sind Bereiche.** XS=[1,2), S=[2,4), M=[4,8), L=[8,16), +XL=[16,32), **XXL=[32,∞)**. Konflikt erst, wenn die Summe der **Untergrenzen** +der Kinder die **Obergrenze** des Elternknotens erreicht — also erst, wenn es +unter *jeder* Lesart falsch ist. Das hält das kanonische Beispiel sauber +(24 < 32), meldet vier `(S)` unter `(M)` (8 ≥ 8), enthält den Ordinal-Fall +(ein strikt größeres Kind warnt immer) und beantwortet den Einwand direkt: +**XXL hat keine Obergrenze**, ein XXL-Knoten warnt nie — für die großen +Sammelknoten behauptet die Skala schlicht keine Schranke mehr. Das ist +dieselbe Haltung wie bei `unknownStatus` und `descStray`: lieber laut, aber +nur, wo es sicher ist. D46 („die Größen sind ordinal, nicht additiv") bleibt +für alles andere unangetastet — die Bereichs-Lesart gilt genau dieser einen +Prüfung. + +**Was zählt:** nur die direkten Kinder (je Ebene eine eigene Prüfung, die +Warnung zeigt auf die Elternzeile), davon nur die mit **angegebener** Größe — +fehlende Größe ist keine Autoren-Aussage (D44-Linie; anders als bei den +Pfadkosten wird kein M angenommen). Verworfene und optionale (`+`) Kinder +zählen nicht; in einer disjunktiven Gruppe (`|`/`=`) zählt die **kleinste** +Alternative, denn nur eine wird realisiert. Die Gegenrichtung (Eltern größer +als die Kindersumme) warnt nicht — sie heißt nur unvollständige Zerlegung, +und dafür gibt es den Geister-Knoten (D8). Ein Elternknoten ohne Größe wird +nie geprüft. + +**Darstellung: das Badge wechselt auf `--warn`, nicht auf Rot.** Der Nutzer +hatte Rot vorgeschlagen und sich für die Warnfarbe entschieden: „Der Plan +widerspricht sich" hat mit `#B45309` schon eine Farbe (Geister-Knoten, +Warnzeilen), und Rot bliebe unvergeben (D34). Dazu die Warnung `sizeConflict` +mit Zeilennummer (eine Stelle: `build()` in warnings.js), der Grund im +Tooltip und im `aria-label`. **Nichts wird automatisch korrigiert.** + +**Nebenbefund: Der Grafikexport zeichnete das Größen-Badge mit festen +Farben** (`drawBadge(sizeEl, '#0F766E', '#ffffff')`) — das invertierte +implizite M (D18) stand damit seit jeher gefüllt im exportierten Bild. Jetzt +liest der Export die gemessenen Farben wie bei Tags und Diskrepanz-Marke; +damit folgt auch das Konflikt-Badge von selbst. + +**Der eigene Plan hatte acht solcher Konflikte** — alle berechtigt, die +Größen stammten aus der Zeit ohne Prüfung. Nachgezogen (`#not` L→XL, `#ed` +XL→XXL, `#ed.live`/`#ed.path`/`#ed.fold`/`#bld`/`#col.own` M→L, `#col.pad` +S→L, und als Kaskade davon `#col` XL→XXL): 0 Warnungen. Dass die erste +Anwendung der Regel den eigenen Plan korrigiert, ist kein schlechtes Zeichen — +genau dafür ist sie da. + +**Nachgemessen:** 372 Tests (16 neue in `tests/sizes.test.js`); Gegenprobe +per Mutation — XXL-Obergrenze wieder eingeführt: genau die zwei danach +benannten Zusicherungen fallen; optionale Kinder mitgezählt: genau eine; +disjunktive Gruppen summiert statt Minimum: genau zwei. Im Browser: Badge +`rgb(180, 83, 9)`, Warnung „Zeile 1: …", Tooltip und `aria-label` benennen +den Grund; im exportierten SVG das Konflikt-Badge bernstein und das implizite +M weiß mit Petrol-Rand. Kanonisches Beispiel §10 und der mitgelieferte Plan: +0 Warnungen. diff --git a/docs/SPEC.md b/docs/SPEC.md index 2ffafc6..22a332f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -325,6 +325,38 @@ effektiv nicht weiter sein als das, was er braucht. - Anzeige: petrolfarbenes Badge (`--or`, `#0F766E`) mit weißer Schrift oben rechts an der Knoten-Ecke. +**Größen-Konflikt** — die angegebene Größe eines Elements muss zu seinen +Teilpaketen passen. Die Skala ist ordinal; für diese eine Prüfung wird jede +Größe als **Bereich** gelesen (Untergrenzen verdoppeln sich): + +| | XS | S | M | L | XL | XXL | +|---|---|---|---|---|---|---| +| Bereich | [1, 2) | [2, 4) | [4, 8) | [8, 16) | [16, 32) | [32, ∞) | + +- **Konflikt** ist, wenn selbst die **günstigste Lesart der Kinder** die + **großzügigste Lesart des Elternknotens** erreicht: Summe der Untergrenzen + der zählenden Kinder ≥ Obergrenze der Elterngröße. Gemeldet wird also nur, + was unter *jeder* Lesart falsch ist — `(XL) = XL-Kind + 2 × M` (16+4+4 = 24 + < 32) ist deshalb kein Konflikt, vier `(S)` unter einem `(M)` (8 ≥ 8) schon. + Ein Kind, das **größer** ist als sein Elternknoten, ist immer ein Konflikt + (Teilmenge der Regel). +- **XXL hat keine Obergrenze** — ein XXL-Element warnt nie: Für die großen + Sammelknoten eines Plans behauptet die Skala schlicht keine Schranke mehr. +- **Es zählen nur die direkten Kinder**, und davon nur die mit **angegebener** + Größe (fehlende Größe ist keine Aussage; anders als beim günstigsten Pfad + wird hier kein `M` angenommen). Verworfene und optionale (`+`) Kinder zählen + nicht. In einer disjunktiven Gruppe (`|`/`=`) wird nur eine Alternative + realisiert — dort zählt die **kleinste**. +- Ein Elternknoten **ohne** Größe macht keine Aussage und wird nie geprüft; + die Gegenrichtung (Eltern größer als die Kindersumme) warnt nicht — sie + heißt nur, dass die Zerlegung unvollständig ist (dafür gibt es den + Geister-Knoten). +- **Meldung und Anzeige:** Warnung `sizeConflict` mit der Zeilennummer des + Elternknotens; sein Größen-Badge wechselt auf die Warnfarbe (`--warn`, + `#B45309`, weiße Schrift), Tooltip und `aria-label` benennen den Grund. + Badge-Färbung auch im Grafikexport und im Druck. **Nichts wird automatisch + korrigiert** — die Größen bleiben, wie sie geschrieben sind. Siehe D62. + ## 6. Links - Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 0c69c14..adc666e 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -3,7 +3,7 @@ %% Every node carries an id; the block behind --- explains it. Hover a node. [~] #wb: Werkbaum (XXL) https://github.com/mhoennig/werkbaum - - [^] #not: Notation (L) + - [^] #not: Notation (XL) - [^] #not.line: Line format (M) - [^] #not.status: Status box, eight states (S) - [^] #not.size: T-shirt size and the "decompose from M" rule (S) @@ -23,6 +23,7 @@ - [^] #not.desc: Node descriptions, shown as a tooltip (M) - [^] #not.desc.short: A short text right at the node (S) %% a " line below the node - [^] #not.desc.long: A long block at the end, addressed by its id (S) %% behind a --- separator + - [x] #not.sizes: Size conflict — sub-packages must fit the given size (S) %% ranges, XXL open-ended, see D62 - [-] #not.store: A separate storage format for the structure (L) %% the text is the format - [ ] #not.ticket: Ticket references (#123,#US-123) (S) %% Taiga writes #US-123 for a user story - [^] #not.llms: llms.md, the notation explained to AI agents (S) @@ -30,8 +31,8 @@ - [?] #not.benefit: Benefit per node, not only cost (M) | [?] #not.benefit.sigil: Another sigil next to the size (S) | [?] #not.benefit.points: Story points behind the T-shirt sizes (M) - - [~] #ed: Editor (XL) https://werkbaum.javagil.de - - [^] #ed.live: Live diagram from text (M) + - [~] #ed: Editor (XXL) https://werkbaum.javagil.de + - [^] #ed.live: Live diagram from text (L) - [^] #ed.parser: Parser (S) - [^] #ed.render: Renderer, three layout modes (M) - [^] #ed.render.horiz: Horizontal fan (S) @@ -40,7 +41,7 @@ - [^] #ed.deplink: Cross links for dependencies (M) - [^] #ed.deplink.calm: Visually secondary — the tree carries the message (S) - [^] #ed.deplink.focus: Highlight what a selected node needs and feeds (S) - - [^] #ed.path: Cheapest path (M) + - [^] #ed.path: Cheapest path (L) - [^] #ed.path.cost: Cost model, missing size counts as M (S) - [^] #ed.path.front: Only the open front: what is done costs nothing (S) %% see D46 - [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61 @@ -73,7 +74,7 @@ - [^] #ed.shortid: Type #.kc under #prod-stage, get #prod-stage.kc (XS) %% an input aid, not notation - [^] #ed.showids: A # button shows the ids before the titles (XS) %% written as in the text, with a colon - [^] #ed.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list - - [^] #ed.fold: Collapsible subtrees (M) + - [^] #ed.fold: Collapsible subtrees (L) - [^] #ed.fold.marks: Initial state from the text: > collapses, < re-opens (S) - [^] #ed.fold.click: Fold and unfold in the diagram (S) - [^] #ed.fold.write: Folding writes the mark back into the text (S) %% one undo step @@ -87,7 +88,7 @@ - [?] #ed.dates: Dates and milestones (M) | [?] #ed.dates.attr: An attribute in the line (S) | [?] #ed.dates.view: A separate timeline view (L) - - [^] #bld: Build and delivery (M) + - [^] #bld: Build and delivery (L) - [^] #bld.bundle: One self-contained file, bundled by Vite (S) - [^] #bld.tests: Unit tests (S) - [^] #bld.pages: Published on every push (S) @@ -110,9 +111,9 @@ - [?] #be.auth: Accounts and permissions (L) :#be.docs | [?] #be.auth.token: Single user, one token (S) | [?] #be.auth.oidc: Log in with OIDC (L) - - [~] #col: Collaborating on a Diagram (XL) + - [~] #col: Collaborating on a Diagram (XXL) " The pad merges concurrent edits, Werkbaum renders the plan. - - [^] #col.pad: Watch a shared Etherpad — it merges, we render (S) + - [^] #col.pad: Watch a shared Etherpad — it merges, we render (L) - [^] #col.pad.export: Load the pad's plain-text export (XS) - [^] #col.pad.reload: Reload on demand (XS) %% a timer only trips Etherpad's rate limit - [^] #col.pad.embed: Embed the pad, keep the text mirror beside it (M) @@ -123,7 +124,7 @@ | [ ] #col.pad.owntab: Live with it: edit in the pad's own tab (XS) - [^] #col.pad.point: A shared pointer: !!! focuses a node for everyone (XS) - [?] > #col.auto: Update by itself, within about 2 s (M) - | [?] #col.own: Our own Etherpad beside Werkbaum (M) %% then every setting below is ours + | [?] #col.own: Our own Etherpad beside Werkbaum (L) %% then every setting below is ours - [ ] #col.own.db: Node and a real database in userland (S) %% the flatfile store is not for production - [ ] #col.own.ram: Book the RAM quota for an own daemon (XS) - [ ] #col.own.proxy: Reverse proxy in .htaccess, websockets included (S) %% Mattermost does it, wiki shows how @@ -295,6 +296,12 @@ are its text. There is no closing fence, so there is none to forget — this section is its own example. +#not.sizes + Every size is read as a range whose lower bounds double, XXL open-ended. A + warning appears only when even the most optimistic reading of the children + exceeds the most generous reading of the parent — nothing is corrected + automatically, the badge just turns amber. + #not.store Rejected on purpose: the text is the format. A second representation of the structure would need syncing, and the first disagreement between the two diff --git a/frontend/public/llms.md b/frontend/public/llms.md index b7963dc..15f1bc1 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -109,6 +109,15 @@ One node per line. Everything except the label is optional. - 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`. +- **Size conflict check:** a given size must fit the direct children. Each + size is read as a range (lower bounds double: XS=1, S=2, M=4, L=8, XL=16, + XXL=32; the upper bound is the next lower bound, XXL is open-ended). It is + a conflict — warning `sizeConflict` at the parent's line — only when the sum + of the children's lower bounds reaches the parent's upper bound, i.e. when + it is wrong under *every* reading. Only sized, non-optional, non-discarded + direct children count; in an `|`/`=` group only the smallest alternative. + An XXL parent never warns; nothing is corrected automatically. When you + write plans, pick parent sizes that pass this check. ### Token extraction order (why nothing collides) diff --git a/frontend/src/app.js b/frontend/src/app.js index 8f193e8..6254d28 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -608,8 +608,17 @@ function diagramToSvg(){ parts.push(``); parts.push(`⚠︎`); } + /* Gemessene Farben statt festem Petrol: Das invertierte implizite M (D18) + und das warnfarbene Konflikt-Badge (SPEC §5/D62) kämen sonst als + gewöhnliches gefülltes Badge ins Bild — beim impliziten M war genau das + seit jeher der Fall (Nebenbefund D62). */ const sizeEl = node.querySelector('.size'); - if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff'); + if(sizeEl){ + const s = getComputedStyle(sizeEl); + /* Der Rand gehört mit: beim invertierten Badge ist er die einzige Kontur + (weiße Füllung auf weißer Karte). */ + drawBadge(sizeEl, s.backgroundColor, s.color, s.borderTopColor); + } node.querySelectorAll('.tag').forEach(tg => { const t = getComputedStyle(tg); drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); @@ -1955,6 +1964,8 @@ const I18N = { unknownDepWarn:"Zeile {line}: Abhängigkeit #{id} — es gibt keinen Knoten mit dieser ID.", unknownDescWarn:"Zeile {line}: Beschreibung für #{id} — es gibt keinen Knoten mit dieser ID.", descStrayWarn:"Zeile {line}: Beschreibungszeile ohne Bezug — ihr fehlt der Knoten bzw. der #id-Block davor.", + sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.", + sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe", cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", @@ -2056,6 +2067,8 @@ const I18N = { unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.", unknownDescWarn:"Line {line}: description for #{id} — no node has this ID.", descStrayWarn:"Line {line}: description line with nothing to attach to — it needs a node or an #id block before it.", + sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.", + sizeConflictTooltip:"The sub-packages together exceed the given size", cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", @@ -2157,6 +2170,8 @@ const I18N = { unknownDepWarn:"Línea {line}: dependencia #{id} — ningún nodo tiene esta ID.", unknownDescWarn:"Línea {line}: descripción para #{id} — ningún nodo tiene esta ID.", descStrayWarn:"Línea {line}: línea de descripción sin referencia — le falta un nodo o un bloque #id delante.", + sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.", + sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado", cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", @@ -2258,6 +2273,8 @@ const I18N = { unknownDepWarn:"Ligne {line} : dépendance #{id} — aucun nœud ne porte cet ID.", unknownDescWarn:"Ligne {line} : description pour #{id} — aucun nœud ne porte cet ID.", descStrayWarn:"Ligne {line} : ligne de description sans rattachement — il lui manque un nœud ou un bloc #id avant.", + sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.", + sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée", cheapApproxWarn:"Trop de groupes d’alternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", @@ -2359,6 +2376,8 @@ const I18N = { unknownDepWarn:"Wiersz {line}: zależność #{id} — żaden węzeł nie ma tego ID.", unknownDescWarn:"Wiersz {line}: opis dla #{id} — żaden węzeł nie ma tego ID.", descStrayWarn:"Wiersz {line}: wiersz opisu bez odniesienia — brakuje węzła lub bloku #id przed nim.", + sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.", + sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar", cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", @@ -2460,6 +2479,8 @@ const I18N = { unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.", unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.", descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.", + sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.", + sizeConflictTooltip:"Подзадачи вместе превышают указанный размер", cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", @@ -2561,6 +2582,8 @@ const I18N = { unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।", unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।", descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।", + sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।", + sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं", cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", @@ -2662,6 +2685,8 @@ const I18N = { unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。", unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。", descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。", + sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。", + sizeConflictTooltip:"子项合计超出所标注的尺寸", cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", @@ -2763,6 +2788,8 @@ const I18N = { unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。", unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。", descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。", + sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。", + sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています", cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", diff --git a/frontend/src/parser.js b/frontend/src/parser.js index 8c47ba3..f2a62bf 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -6,6 +6,13 @@ /* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */ export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 }; +/* Größen als BEREICHE für die Konflikt-Prüfung (SPEC §5/D62): Untergrenze + 2^Rang, Obergrenze die Untergrenze der nächsten Größe — XXL ist nach oben + offen, ein XXL-Element warnt also nie. Die Skala bleibt ansonsten ordinal + (D46: „S+S ≠ M" gilt weiter für alles außer dieser einen Prüfung). */ +export const sizeMin = size => 2 ** SIZE_RANK[size]; +export const sizeMax = size => size === 'XXL' ? Infinity : 2 ** (SIZE_RANK[size] + 1); + /* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}. `name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */ @@ -384,5 +391,33 @@ export function parse(text){ kids.forEach(checkXor); })(virtualRoot); + /* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten + Kindern passen. Jede Größe zählt als BEREICH (sizeMin/sizeMax); Konflikt + erst, wenn selbst die günstigste Lesart der Kinder die großzügigste des + Elternknotens erreicht — gemeldet wird nur, was unter jeder Lesart falsch + ist. Es zählen nur Kinder MIT Größe (fehlende Größe ist keine Aussage — + anders als bei den Pfadkosten wird hier kein M angenommen, D44-Linie), + ohne verworfene und ohne optionale; in einer disjunktiven Gruppe wird nur + eine Alternative realisiert, dort zählt die kleinste. Nichts wird + korrigiert — Warnung an der Elternzeile plus Flag für das Badge. */ + (function checkSizes(node){ + const kids = node.children; + if(node.size && kids.length && sizeMax(node.size) !== Infinity){ + const counted = kids.filter(k => + k.size && !k.optional && !(k.status && k.status.key === 'verworfen')); + if(counted.length){ + const mins = counted.map(k => sizeMin(k.size)); + const need = kids[0].type !== 'and' + ? Math.min(...mins) + : mins.reduce((a, b) => a + b, 0); + if(need >= sizeMax(node.size)){ + node.sizeConflict = true; + warnings.push({type:'sizeConflict', line: node.line, size: node.size}); + } + } + } + kids.forEach(checkSizes); + })(virtualRoot); + return {roots: virtualRoot.children, warnings}; } diff --git a/frontend/src/render.js b/frontend/src/render.js index 78728eb..be0ee92 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -72,6 +72,9 @@ function nodeAria(n, opts, fold){ 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')); + /* Größen-Konflikt (SPEC §5/D62): die Warnfärbung des Badges kommt beim + Screenreader sonst nicht an. */ + if(n.sizeConflict) parts.push(t('sizeConflictTooltip')); 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. */ @@ -138,6 +141,9 @@ function nodeHtml(n, extra, opts, fold){ 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') : '', + /* Größen-Konflikt (SPEC §5/D62): das warnfarbene Badge braucht + seine Begründung dort, wo man nachsieht. */ + n.sizeConflict ? t('sizeConflictTooltip') : '', t('jumpHint')] .filter(Boolean).join(' · '); const tip = n.desc && facts ? n.desc + '\n\n' + TIP_RULE + '\n' + facts @@ -154,8 +160,10 @@ function nodeHtml(n, extra, opts, fold){ 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 ? `` : ''); /* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */ diff --git a/frontend/src/style.css b/frontend/src/style.css index 0579077..1dea072 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -910,6 +910,10 @@ .cheap-on .node:not(.cheap).done{opacity:1;filter:none} /* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */ .size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} + /* Größen-Konflikt (SPEC §5/D62): die Teilpakete übersteigen zusammen die + angegebene Größe — das Badge wechselt auf die Warnfarbe, der weiße + Trennrand zur Knotenkante bleibt. */ + .size.conflict{background:var(--warn)} /* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js index 999c211..78e20be 100644 --- a/frontend/src/warnings.js +++ b/frontend/src/warnings.js @@ -25,6 +25,10 @@ Beschreibungsteil ohne #id-Block; meldet auch die von einem versehentlichen `---` verschluckten Knotenzeilen + - sizeConflict { line, size } — Teilpakete übersteigen zusammen die + angegebene Größe des Elternknotens, selbst + in der günstigsten Bereichs-Lesart + (SPEC §5/D62); Zeile = Elternknoten - cheapApprox { } — günstigster Pfad nur gierig geschätzt: zu viele gekoppelte Gruppen für die exakte Suche (D42); zeilenlos @@ -72,6 +76,8 @@ function build(w, t, esc){ return t('unknownDescWarn', {line: w.line, id: esc(w.id)}); case 'descStray': return t('descStrayWarn', {line: w.line}); + case 'sizeConflict': + return t('sizeConflictWarn', {line: w.line, size: esc(w.size)}); case 'cheapApprox': return t('cheapApproxWarn'); case 'sourceLoad': diff --git a/frontend/tests/sizes.test.js b/frontend/tests/sizes.test.js new file mode 100644 index 0000000..a2013ed --- /dev/null +++ b/frontend/tests/sizes.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { parse, sizeMin, sizeMax } from '../src/parser.js'; + +/* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten + Kindern passen. Jede Größe zählt als Bereich (Untergrenze 2^Rang, Obergrenze + die nächste Untergrenze, XXL offen); Konflikt erst, wenn selbst die + günstigste Lesart der Kinder die großzügigste des Elternknotens erreicht. + Nichts wird automatisch korrigiert. */ + +const conflicts = txt => parse(txt).warnings.filter(w => w.type === 'sizeConflict'); +const node = (txt, label) => { + const find = ns => { + for(const n of ns){ + if(n.label === label) return n; + const hit = find(n.children); + if(hit) return hit; + } + return null; + }; + return find(parse(txt).roots); +}; + +describe('Bereichs-Skala', () => { + it('Untergrenzen verdoppeln sich, Obergrenze ist die nächste Untergrenze', () => { + expect(['XS','S','M','L','XL'].map(sizeMin)).toEqual([1,2,4,8,16]); + expect(['XS','S','M','L','XL'].map(sizeMax)).toEqual([2,4,8,16,32]); + }); + it('XXL ist nach oben offen', () => { + expect(sizeMin('XXL')).toBe(32); + expect(sizeMax('XXL')).toBe(Infinity); + }); +}); + +describe('Konflikt-Regel (konjunktiv: Summe der Untergrenzen)', () => { + it('vier S unter einem M sind ein Konflikt (8 >= 8), drei nicht', () => { + const vier = 'A (M)\n - B (S)\n - C (S)\n - D (S)\n - E (S)'; + expect(conflicts(vier)).toEqual([{type:'sizeConflict', line:1, size:'M'}]); + expect(node(vier, 'A').sizeConflict).toBe(true); + expect(conflicts('A (M)\n - B (S)\n - C (S)\n - D (S)')).toEqual([]); + }); + it('ein einzelnes Kind derselben Größe passt, ein größeres nie', () => { + expect(conflicts('A (M)\n - B (M)')).toEqual([]); + expect(conflicts('A (M)\n - B (L)')) + .toEqual([{type:'sizeConflict', line:1, size:'M'}]); + }); + it('das kanonische Beispiel bleibt sauber: XL = XL-Kind + 2×M', () => { + expect(conflicts('A (XL)\n - B (M)\n - C (XL)\n - D (M)')).toEqual([]); + }); + it('zwei M unter einem M sind ein Konflikt (4+4 >= 8)', () => { + expect(conflicts('A (M)\n - B (M)\n - C (M)').length).toBe(1); + }); + it('ein XXL-Elternknoten warnt nie', () => { + const txt = 'A (XXL)\n' + Array.from({length: 40}, (_, i) => ` - K${i} (XL)`).join('\n'); + expect(conflicts(txt)).toEqual([]); + }); + it('geprüft wird je Ebene, die Warnung zeigt auf die Elternzeile', () => { + const txt = 'A (XXL)\n - B (S)\n - C (S)\n - D (S)'; + expect(conflicts(txt)).toEqual([{type:'sizeConflict', line:2, size:'S'}]); + }); +}); + +describe('Was nicht zählt', () => { + it('ein Elternknoten ohne Größe macht keine Aussage', () => { + expect(conflicts('A\n - B (XXL)\n - C (XXL)')).toEqual([]); + }); + it('Kinder ohne Größe zählen nicht — hier wird kein M angenommen', () => { + expect(conflicts('A (XS)\n - B\n - C\n - D')).toEqual([]); + /* die größenlosen bleiben auch neben gezählten außen vor */ + expect(conflicts('A (M)\n - B (S)\n - C\n - D')).toEqual([]); + }); + it('verworfene Kinder zählen nicht', () => { + expect(conflicts('A (M)\n - [-] B (XL)\n - C (S)')).toEqual([]); + }); + it('optionale Kinder (`+`) zählen nicht', () => { + expect(conflicts('A (M)\n - B (S)\n + C (XL)')).toEqual([]); + }); + it('die Gegenrichtung warnt nicht: Eltern größer als die Kindersumme', () => { + expect(conflicts('A (XXL)\n - B (XS)')).toEqual([]); + }); +}); + +describe('Disjunktive Gruppen: nur die kleinste Alternative zählt', () => { + it('| mit einer passenden Alternative ist kein Konflikt', () => { + expect(conflicts('A (M)\n | B (L)\n | C (S)')).toEqual([]); + }); + it('| warnt erst, wenn selbst die kleinste Alternative sprengt', () => { + expect(conflicts('A (M)\n | B (L)\n | C (L)').length).toBe(1); + }); + it('= verhält sich wie |', () => { + expect(conflicts('A (M)\n = B (L)\n = C (S)')).toEqual([]); + expect(conflicts('A (M)\n = B (L)\n = C (L)').length).toBe(1); + }); +});