feat: Größen-Konflikt — Teilpakete müssen zur angegebenen Größe passen (D62)

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 15:55:16 +02:00
co-authored by Claude Fable 5
parent 257e1a9c17
commit 5b37c117e1
11 changed files with 305 additions and 11 deletions
+2
View File
@@ -19,6 +19,8 @@ reverse.
## 2026-08-24 ## 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 - 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 - 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 - An optional node (`+`) joins the cheapest path while it is being worked on — started work is the open front
+71
View File
@@ -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 `chosenPool` heraus, genau die zwei zu den Alternativen. Die Tests, die das
**unveränderte** Verhalten festhalten (unangetastete und erledigte Zugaben **unveränderte** Verhalten festhalten (unangetastete und erledigte Zugaben
bleiben draußen), bleiben in beiden Fällen grün. 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.
+32
View File
@@ -325,6 +325,38 @@ effektiv nicht weiter sein als das, was er braucht.
- Anzeige: petrolfarbenes Badge (`--or`, `#0F766E`) mit weißer Schrift oben - Anzeige: petrolfarbenes Badge (`--or`, `#0F766E`) mit weißer Schrift oben
rechts an der Knoten-Ecke. 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 ## 6. Links
- Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar - Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar
+16 -9
View File
@@ -3,7 +3,7 @@
%% Every node carries an id; the block behind --- explains it. Hover a node. %% Every node carries an id; the block behind --- explains it. Hover a node.
[~] #wb: Werkbaum (XXL) https://github.com/mhoennig/werkbaum [~] #wb: Werkbaum (XXL) https://github.com/mhoennig/werkbaum
- [^] #not: Notation (L) - [^] #not: Notation (XL)
- [^] #not.line: Line format (M) - [^] #not.line: Line format (M)
- [^] #not.status: Status box, eight states (S) - [^] #not.status: Status box, eight states (S)
- [^] #not.size: T-shirt size and the "decompose from M" rule (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: 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.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 - [^] #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.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.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) - [^] #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: Benefit per node, not only cost (M)
| [?] #not.benefit.sigil: Another sigil next to the size (S) | [?] #not.benefit.sigil: Another sigil next to the size (S)
| [?] #not.benefit.points: Story points behind the T-shirt sizes (M) | [?] #not.benefit.points: Story points behind the T-shirt sizes (M)
- [~] #ed: Editor (XL) https://werkbaum.javagil.de - [~] #ed: Editor (XXL) https://werkbaum.javagil.de
- [^] #ed.live: Live diagram from text (M) - [^] #ed.live: Live diagram from text (L)
- [^] #ed.parser: Parser (S) - [^] #ed.parser: Parser (S)
- [^] #ed.render: Renderer, three layout modes (M) - [^] #ed.render: Renderer, three layout modes (M)
- [^] #ed.render.horiz: Horizontal fan (S) - [^] #ed.render.horiz: Horizontal fan (S)
@@ -40,7 +41,7 @@
- [^] #ed.deplink: Cross links for dependencies (M) - [^] #ed.deplink: Cross links for dependencies (M)
- [^] #ed.deplink.calm: Visually secondary — the tree carries the message (S) - [^] #ed.deplink.calm: Visually secondary — the tree carries the message (S)
- [^] #ed.deplink.focus: Highlight what a selected node needs and feeds (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.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.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 - [^] #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.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.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.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.marks: Initial state from the text: > collapses, < re-opens (S)
- [^] #ed.fold.click: Fold and unfold in the diagram (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 - [^] #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: Dates and milestones (M)
| [?] #ed.dates.attr: An attribute in the line (S) | [?] #ed.dates.attr: An attribute in the line (S)
| [?] #ed.dates.view: A separate timeline view (L) | [?] #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.bundle: One self-contained file, bundled by Vite (S)
- [^] #bld.tests: Unit tests (S) - [^] #bld.tests: Unit tests (S)
- [^] #bld.pages: Published on every push (S) - [^] #bld.pages: Published on every push (S)
@@ -110,9 +111,9 @@
- [?] #be.auth: Accounts and permissions (L) :#be.docs - [?] #be.auth: Accounts and permissions (L) :#be.docs
| [?] #be.auth.token: Single user, one token (S) | [?] #be.auth.token: Single user, one token (S)
| [?] #be.auth.oidc: Log in with OIDC (L) | [?] #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. " 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.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.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) - [^] #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.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.pad.point: A shared pointer: !!! focuses a node for everyone (XS)
- [?] > #col.auto: Update by itself, within about 2 s (M) - [?] > #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.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.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 - [ ] #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 are its text. There is no closing fence, so there is none to forget — this
section is its own example. 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 #not.store
Rejected on purpose: the text is the format. A second representation of the Rejected on purpose: the text is the format. A second representation of the
structure would need syncing, and the first disagreement between the two structure would need syncing, and the first disagreement between the two
+9
View File
@@ -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 - From `(M)` upward a node **should be decomposed further**; a node ≥ M
without children gets a placeholder hint in the diagram. without children gets a placeholder hint in the diagram.
- For cost estimation a missing size counts as `M`. - 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) ### Token extraction order (why nothing collides)
+28 -1
View File
@@ -608,8 +608,17 @@ function diagramToSvg(){
parts.push(`<circle cx="${rb.cx.toFixed(1)}" cy="${rb.cy.toFixed(1)}" r="${(Math.min(rb.w,rb.h)/2).toFixed(1)}" fill="#ffffff" stroke="#F97316" stroke-width="1.5"/>`); parts.push(`<circle cx="${rb.cx.toFixed(1)}" cy="${rb.cy.toFixed(1)}" r="${(Math.min(rb.w,rb.h)/2).toFixed(1)}" fill="#ffffff" stroke="#F97316" stroke-width="1.5"/>`);
parts.push(`<text x="${rb.cx.toFixed(1)}" y="${(rb.cy+3.5).toFixed(1)}" text-anchor="middle" fill="#F97316" font-size="10">⚠︎</text>`); parts.push(`<text x="${rb.cx.toFixed(1)}" y="${(rb.cy+3.5).toFixed(1)}" text-anchor="middle" fill="#F97316" font-size="10">⚠︎</text>`);
} }
/* 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'); 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 => { node.querySelectorAll('.tag').forEach(tg => {
const t = getComputedStyle(tg); const t = getComputedStyle(tg);
drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); 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.", 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.", 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.", 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).", 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_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", 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.", unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.",
unknownDescWarn:"Line {line}: description for #{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.", 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).", 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_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", 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.", 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.", 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.", 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).", 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_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", 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.", 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.", 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.", 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 dalternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).", cheapApproxWarn:"Trop de groupes dalternatives 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_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é", 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.", 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.", 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.", 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).", 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_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", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
@@ -2460,6 +2479,8 @@ const I18N = {
unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.", unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.",
unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.", unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.",
descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.", descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.",
sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.",
sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).", cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
@@ -2561,6 +2582,8 @@ const I18N = {
unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।", unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।",
unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।", unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।",
descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।", descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।",
sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।",
sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।", cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
@@ -2662,6 +2685,8 @@ const I18N = {
unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。", unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。",
unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。", unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。",
descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。", descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。",
sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。",
sizeConflictTooltip:"子项合计超出所标注的尺寸",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。", cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
@@ -2763,6 +2788,8 @@ const I18N = {
unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。", unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。",
unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。", unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。",
descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。", descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。",
sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。",
sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。", cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
+35
View File
@@ -6,6 +6,13 @@
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */ /* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 }; 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}. /* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische `name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */ Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
@@ -384,5 +391,33 @@ export function parse(text){
kids.forEach(checkXor); kids.forEach(checkXor);
})(virtualRoot); })(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}; return {roots: virtualRoot.children, warnings};
} }
+9 -1
View File
@@ -72,6 +72,9 @@ function nodeAria(n, opts, fold){
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)})); if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
if(n.size) parts.push(t('a11ySize', {size: n.size})); 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'));
/* 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(', ')})); 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 /* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung
im Diagramm sichtbar nur im Tooltip und hier. */ 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 eigenen `title` am Badge der erschiene neben dem Fenster
ein zweites Mal. Sie gehört ohnehin zu den Kurz-Fakten. */ ein zweites Mal. Sie gehört ohnehin zu den Kurz-Fakten. */
(!n.size && cheapPath && !isDone(n)) ? t('implicitSizeTooltip') : '', (!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')] t('jumpHint')]
.filter(Boolean).join(' · '); .filter(Boolean).join(' · ');
const tip = n.desc && facts ? n.desc + '\n\n' + TIP_RULE + '\n' + facts 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) , erledigten Knoten wird keine getroffen er kostet nichts mehr (D46) ,
dort bleibt es deshalb weg. */ dort bleibt es deshalb weg. */
const implicitSize = !n.size && cheapPath && !isDone(n); 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 const sizeBadge = n.size
? `<span class="size" aria-hidden="true">${n.size}</span>` ? `<span class="size${n.sizeConflict ? ' conflict' : ''}" aria-hidden="true">${n.size}</span>`
: (implicitSize ? `<span class="size implicit" aria-hidden="true">M</span>` : ''); : (implicitSize ? `<span class="size implicit" aria-hidden="true">M</span>` : '');
/* High-Risk: Warndreieck (, Textpräsentation via VS15) an der oberen linken /* High-Risk: Warndreieck (, Textpräsentation via VS15) an der oberen linken
Ecke. aria-hidden die Information steckt bereits im Status des aria-label. */ Ecke. aria-hidden die Information steckt bereits im Status des aria-label. */
+4
View File
@@ -910,6 +910,10 @@
.cheap-on .node:not(.cheap).done{opacity:1;filter:none} .cheap-on .node:not(.cheap).done{opacity:1;filter:none}
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */ /* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} .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 /* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol fädelt durch
die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen 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 zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken
+6
View File
@@ -25,6 +25,10 @@
Beschreibungsteil ohne #id-Block; meldet Beschreibungsteil ohne #id-Block; meldet
auch die von einem versehentlichen auch die von einem versehentlichen
`---` verschluckten Knotenzeilen `---` 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: - cheapApprox { } günstigster Pfad nur gierig geschätzt:
zu viele gekoppelte Gruppen für die zu viele gekoppelte Gruppen für die
exakte Suche (D42); zeilenlos exakte Suche (D42); zeilenlos
@@ -72,6 +76,8 @@ function build(w, t, esc){
return t('unknownDescWarn', {line: w.line, id: esc(w.id)}); return t('unknownDescWarn', {line: w.line, id: esc(w.id)});
case 'descStray': case 'descStray':
return t('descStrayWarn', {line: w.line}); return t('descStrayWarn', {line: w.line});
case 'sizeConflict':
return t('sizeConflictWarn', {line: w.line, size: esc(w.size)});
case 'cheapApprox': case 'cheapApprox':
return t('cheapApproxWarn'); return t('cheapApproxWarn');
case 'sourceLoad': case 'sourceLoad':
+93
View File
@@ -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);
});
});