diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c3d9997..03b26b6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -734,3 +734,81 @@ stimmte, aber kein einziger Knoten leuchtete, weil `Set.has()` auf Objektidentität prüft und die gerenderten Knoten aus einem anderen Parse kamen. `render()` bildet die Menge daher bei jedem Durchlauf neu; vorgehalten wird nur der **geparste Basisbaum**. + +## D29 — `+` für optionale Knoten: Zugaben sind weder Pflicht noch Alternative +Die Notation kannte bisher nur zwei Beziehungen zwischen Geschwistern: +erforderlich (`-`) und wählbar (`|`). Für ein einzelnes zusätzliches Feature, +das weder nötig ist noch eine Alternative zu etwas anderem, passte keine von +beiden. Man schrieb es als normales `-`-Kind — und log damit. + +**Die Lücke hat einen Namen.** Feature-Modelle (FODA) unterscheiden seit den +90ern *mandatory*, *optional* und *alternative*. Werkbaum hatte die erste und +die dritte; `+` ergänzt die zweite. Mnemonik in der Reihe: `-` Teilpaket, +`+` Zugabe, `|` Alternative. Deckt sich mit MoSCoW (Must / Could / Auswahl). + +**Der eigentliche Anlass ist der günstigste Pfad (D18), nicht die Optik.** +`markCheapest()` lief bei all-of über *alle* Kinder — jede Zugabe steckte damit +im errechneten Minimum. Das Ergebnis war systematisch zu groß, und zwar umso +mehr, je ehrlicher der Plan auch Kür notierte. `pathChildren()` filtert +optionale Knoten jetzt mit heraus; da beide Nutzer (`cheapestCost`, +`markCheapest`) über diese eine Funktion gehen, gilt das samt Teilbaum. Sichtbar +wird es beim Vergleich von Alternativen: eine Alternative mit teurer Zugabe +verlor vorher gegen eine schlichtere, obwohl die Zugabe gar nicht dazugehört. + +**`+` gehört zum Knoten, nicht zur Gruppe** — anders als `-` und `|`. Der Parser +setzt deshalb `optional:true` und lässt `type:'and'` stehen. Zwei Dinge fallen +dadurch von selbst richtig aus: `gateOf()` bleibt unverändert, und die +`mixedGate`-Warnung schlägt weiterhin genau dann an, wenn `|` mit `-`/`+` +gemischt wird — `-` neben `+` ist erlaubt und still. Genau so soll es sein: +„diese drei sind nötig, das hier wäre schön" ist der Normalfall, nicht der +Fehlerfall. Die Regel dahinter: eine Gruppe ist entweder **konjunktiv** +(`-`/`+` frei gemischt) oder **disjunktiv** (`|`). + +**Darstellung: hohler Kreis am Abzweig, kein dritter Linienstil.** Erwogen und +verworfen war eine **gepunktete** Abzweiglinie. Sie wäre pro Kind trivial zu +setzen gewesen (den Abzweig zeichnet ohnehin ein `li`-Pseudoelement), kollidiert +aber mit D15: Im **kompakten** Modus laufen beide Gates nach unten und werden +*allein* über den Linienstil unterschieden. Ein dritter Stil müsste sich dort +gegen „gestrichelt grau" behaupten — zu wenig Abstand für ein Merkmal, das man +auf einen Blick lesen können muss. Der Kreis dagegen ist **orthogonal** zum +Linienstil und lässt D15 unangetastet; er ist zudem die etablierte +FODA-Konvention (gefüllter Punkt = erforderlich, hohler = optional). + +Er sitzt **mittig auf der Knotenkante**, wo der Abzweig auftrifft, und +unterbricht die Linie dort sichtbar. Grundfall im CSS ist die **gestapelte** +Anordnung (links auf halber Höhe) — sie deckt vertikal, kompakt und die +any-of-Gruppen ab; die **eine** Ausnahme ist der horizontale Fächer (oben +mittig), die **eine** Rück-Ausnahme davon der gestapelte all-of-Teilbaum unter +einer any-of-Gruppe (D18). Umgekehrt herum aufgezogen wären es vier Ausnahmen +statt zwei. `.node::before`/`::after` waren beide frei; die `li`-Pseudoelemente +sind von Abzweig und Sammelleiste belegt. + +**Im Export** wird der Kreis **nach** den Knoten gezeichnet. Er liegt zur Hälfte +außerhalb der Knotenbox — in der Zeichenreihenfolge der Linien (Schritt 1) +hätte das Knoten-Rechteck ihn später halb überdeckt. Die Auftreffpunkte werden +beim Linienzeichnen gesammelt und in einem eigenen Schritt 3a ausgegeben. + +**Bekannte Schwäche:** Bei aktivem Günstigster-Pfad-Umschalter (Default an) wird +der optionale Knoten ausgeblasst (`opacity:.32`) — und mit ihm sein Kreis, der +die Erklärung *dafür* wäre. Undoen lässt sich das nicht: `opacity` am Elternteil +schlägt auf jedes Kind durch, auch auf ein Pseudoelement. Bewusst in Kauf +genommen, weil das Zurücktreten hier die *Hauptaussage* ist (dieselbe Logik wie +bei nicht gewählten Alternativen) und Tooltip, `aria-label` und Legende die +Begründung nachliefern. Bei ausgeschaltetem Umschalter steht der Kreis in voller +Stärke. + +**Verworfene Alternativen:** +- **Den Status `[?]` (Idee) dafür nehmen** — falsche Achse. Status ist + Fortschritt, `+` ist Notwendigkeit; eine Zugabe kann längst `[^]` sein (genau + der Fall, der die Frage ausgelöst hat). SPEC §3 hält beide Achsen getrennt. +- **`@optional` als Personen-Tag** — missbraucht §7 für etwas Strukturelles. +- **`#optional` als Schlagwort** (§11 reserviert) — hätte keine Syntaxänderung + gekostet, bringt aber weder Darstellung noch die Korrektur am Kostenmodell, + also gerade das nicht, wofür sich der Aufwand lohnt. +- **`*` statt `+`** — in Regex „null oder mehr" und damit nah an der Begründung + von D1. Verworfen, weil `*` in Markdown zugleich Betonung auszeichnet und + eher wie eine Fußnote gelesen wird; `+` liest sich als „Zugabe". + +**Verhaltensänderung:** Ein `+` am Zeilenanfang ist jetzt ein Zeichen und +gehört nicht mehr zum Label (`+ 5 % Puffer` ergibt das Label „5 % Puffer"). +Test-abgedeckt, damit es niemanden unbemerkt trifft. diff --git a/docs/SPEC.md b/docs/SPEC.md index 799b0fb..e37c05e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -14,7 +14,7 @@ Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in dieser Reihenfolge (wichtig für Kollisionsfreiheit): 1. Kommentar entfernen: alles ab `%%` bis Zeilenende. -2. Einrückung, Zeichen (`-` / `|`) und Statusbox `[…]` per Zeilen-Regex. +2. Einrückung, Zeichen (`-` / `+` / `|`) und Statusbox `[…]` per Zeilen-Regex. 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 5. Tags: alle `@name`-Vorkommen. @@ -23,7 +23,7 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit): Referenz-Regex der Implementierung: ``` -^([ \t]*)([-|])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ +^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ``` ## 2. Hierarchie @@ -31,7 +31,7 @@ Referenz-Regex der Implementierung: - Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite: Elternknoten ist die nächste vorangehende Zeile mit **kleinerer** Einrückungsbreite (Tab zählt als 2 Leerzeichen). -- Zeilen ohne Zeichen (`-`/`|`) sind Wurzelknoten. Mehrere Wurzeln = mehrere +- Zeilen ohne Zeichen (`-`/`+`/`|`) sind Wurzelknoten. Mehrere Wurzeln = mehrere Bäume nebeneinander. ## 3. Zerlegungsart (Gate) @@ -39,12 +39,21 @@ Referenz-Regex der Implementierung: | Zeichen | Bedeutung | Semantik | |---|---|---| | `-` | all of (Und-Zerlegung) | Alle Teilpakete sind erforderlich. | +| `+` | optional (Zugabe) | Einzelnes zusätzliches Teilpaket, nicht erforderlich. | | `\|` | any of (Oder-Zerlegung) | Mindestens eine Alternative wird gewählt. | -- Das Gate ist eine Eigenschaft der Geschwistergruppe; alle Geschwister sollen - dasselbe Zeichen tragen. -- Gemischte Geschwister: Darstellung nach dem **ersten** Kind, plus Warnung - mit Zeilennummer. +- `-` und `|` sind Eigenschaften der **Geschwistergruppe**; `+` ist eine + Eigenschaft des **einzelnen Knotens** (er hängt an derselben Und-Zerlegung, + ist darin aber entbehrlich). +- Daraus folgt die Mischregel: Eine Gruppe ist entweder **konjunktiv** — dann + dürfen `-` und `+` frei nebeneinander stehen — oder **disjunktiv** (`|`). + `|` mit `-`/`+` zu mischen ist ungültig: Darstellung nach dem **ersten** Kind, + plus Warnung `mixedGate` mit Zeilennummer. +- Ein `+`-Knoten zerlegt sich weiter wie jeder andere; das Gate seiner eigenen + Kinder ist davon unabhängig. Optionalität vererbt sich nicht ausdrücklich — + wer unter einem `+`-Knoten hängt, ist mit ihm zusammen entbehrlich. +- `+` sagt nichts über den Fortschritt: Eine Zugabe kann längst `[^]` sein. Die + beiden Achsen (Status §4, Notwendigkeit §3) sind unabhängig. ## 4. Status @@ -122,6 +131,18 @@ nebeneinander (schmales Diagramm rechts). ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die **Anordnung**, nicht die Linienfarbe. +**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Linie — +Anordnung und Linienstil bleiben unverändert. Gekennzeichnet wird der Knoten +selbst durch einen **kleinen hohlen Kreis** (weiß gefüllt, Rand in Tinte) genau +dort, wo der Abzweig ihn trifft: in der horizontalen Fächer-Anordnung **oben +mittig**, in den gestapelten Anordnungen (vertikal, kompakt, unterhalb einer +any-of-Gruppe) **links auf halber Höhe**. Übernommen aus den Feature-Diagrammen +(FODA: gefüllter Punkt = erforderlich, hohler Punkt = optional). Bewusst **kein +weiterer Linienstil**: gestrichelt gehört der any-of-Zerlegung, und im kompakten +Modus trägt allein der Linienstil die Gate-Codierung (D15) — ein dritter Stil +wäre dort nicht mehr sicher unterscheidbar. Der Kreis erscheint auch im +Grafikexport. Siehe D29. + ### Horizontal (Normalmodus) - **all of:** Kinder nebeneinander, klassischer Organigramm-Fächer. - **any of:** Alternativen untereinander; gestrichelte graue Sammelleiste links @@ -190,13 +211,18 @@ werden die für die günstigste Realisierung **nötigen** Knoten: - **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe plus — je Gate — Summe bzw. Minimum der Kinder). Bei Gleichstand gewinnt die **erste** Alternative. +- **Optionale Knoten (`+`, §3) sind nie nötig** — sie zählen weder zu den + Kosten ihres Elternknotens noch liegen sie auf dem Pfad, und der Teilbaum + unter ihnen ebenso wenig. Genau dafür gibt es das Zeichen: Ohne `+` rechnet + der günstigste Pfad jede Zugabe ins Minimum ein und überschätzt es. - 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). Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte -any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt); der +any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück +(blass, entsättigt); der günstige Pfad hebt sich dadurch von selbst ab — kein zusätzlicher Rahmen an den ohnehin dichten Knoten-Ecken. Wo die Größe **implizit** als `M` angenommen wird, zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit petrolfarbenem @@ -296,8 +322,8 @@ 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 und ob der Knoten verlinkt -ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke +(inkl. „(angenommen)“ beim impliziten M), 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"` bzw. der Link selbst); die **Fokusreihenfolge entspricht der Dokument-/ @@ -327,6 +353,7 @@ Druckdialog „an Seite anpassen“ bzw. Querformat wählen. - [~] Umsetzung (XL) - [/] Frontend (S) https://git.example.de/frontend @anna - [ ] Backend (L) @ben @carla + + [?] Dark Mode (S) %% Zugabe, nicht erforderlich - [ ] CMS-Anbindung (M) | [ ] WordPress | [?] Headless CMS diff --git a/docs/examples/example-werkbaum.werkbaum b/docs/examples/example-werkbaum.werkbaum index 6379028..1787275 100644 --- a/docs/examples/example-werkbaum.werkbaum +++ b/docs/examples/example-werkbaum.werkbaum @@ -8,6 +8,7 @@ - [^] T-shirt size and the "decompose from M" rule (S) - [^] People tags, bare URLs, %% comments (XS) - [^] And/or decomposition (S) + - [^] Optional nodes — neither required nor an alternative (S) - [-] A separate storage format for the structure (L) %% the text is the format - [ ] Ticket references #123 (S) - [?] Benefit per node, not only cost (M) @@ -26,7 +27,7 @@ - [^] Export (M) - [^] SVG and PNG download (S) - [^] PNG to the clipboard (S) - - [^] Print stylesheet (XS) + + [^] Print stylesheet (XS) - [^] Nine interface languages (M) - [^] Translations (S) - [^] Default taken from the browser (XS) @@ -36,9 +37,9 @@ - [^] Switcher in the editor title bar (S) - [^] Load a document from ?sourceUrl= (S) - [^] Jump between diagram and text (S) - - [^] Show what is new since your last visit (S) + + [^] Show what is new since your last visit (S) - [ ] Open and save .werkbaum files (S) - - [?] A pastel colour per person (S) + + [?] A pastel colour per person (S) - [?] Dates and milestones (M) | [?] An attribute in the line (S) | [?] A separate timeline view (L) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 164cb04..32b9394 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -182,6 +182,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur `freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`, fortgeschrieben **erst beim Bestätigen** über `#freshBtn`. +- Optionale Knoten `+` (D29): Der Parser setzt **`optional:true` und lässt + `type:'and'`** — `+` gehört zum Knoten, nicht zur Gruppe. Deshalb bleiben + `gateOf()` und die `mixedGate`-Warnung unverändert richtig (sie meldet nur + `|` neben `-`/`+`). Aus dem günstigsten Pfad fallen optionale Knoten über + **`pathChildren()`** heraus — die eine Stelle, die `cheapestCost()` und + `markCheapest()` gemeinsam nutzen; deshalb wirkt sie samt Teilbaum. Der hohle + Kreis ist `.node.opt::before`: **Grundfall links/50 %** (gestapelt), Ausnahme + **oben/50 %** im horizontalen Fächer, Rück-Ausnahme wieder links für + `ul.or>li.has-and>ul.and>li` (D18). Im SVG-Export muss er **nach** den Knoten + gezeichnet werden (`optMarks`, Schritt 3a) — er liegt halb außerhalb der Box + und würde sonst vom Knoten-Rechteck überdeckt. - Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den diff --git a/frontend/index.html b/frontend/index.html index bd81fbd..8d990b8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -79,8 +79,9 @@
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.
-  Teilpaket, alle erforderlich (all of)
+ +  Zugabe, nicht erforderlich (optional)
|  Alternative, eine wählen (any of)
- Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.
+ Zeile ohne Zeichen = Wurzelknoten. | nicht mit -/+ mischen.
Status als Kästchen nach dem Zeichen, z. B. - [~] Frontend:
[?] Idee diff --git a/frontend/src/app.js b/frontend/src/app.js index 9b3a285..87c6164 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -174,6 +174,11 @@ function diagramToSvg(){ /* 1) Verbindungslinien (hinter den Knoten) */ const seg = (x1,y1,x2,y2,stroke,dash) => ``; + /* Auftreffpunkte der Abzweige an optionalen Knoten (`+`, SPEC §3/D29). + Gesammelt beim Linienzeichnen, gezeichnet erst NACH den Knoten — der Kreis + sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb + überdecken. */ + const optMarks = []; nodes.forEach(parentEl => { const li = parentEl.closest('li'); const childUl = li && [...li.children].find(c => c.tagName === 'UL'); @@ -181,9 +186,11 @@ function diagramToSvg(){ const gate = childUl.classList.contains('or') ? 'or' : 'and'; const stroke = gate === 'or' ? '#6B7A8C' : '#41556E'; const dash = gate === 'or'; - const kids = [...childUl.children] - .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean).map(R); + const kidEls = [...childUl.children] + .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean); + const kids = kidEls.map(R); if(!kids.length) return; + const markOpt = (i, x, y) => { if(kidEls[i].classList.contains('opt')) optMarks.push({x, y}); }; const p = R(parentEl); const avgdx = kids.reduce((s,k)=>s+(k.cx-p.cx),0)/kids.length; const avgdy = kids.reduce((s,k)=>s+(k.cy-p.cy),0)/kids.length; @@ -194,7 +201,10 @@ function diagramToSvg(){ const ys = kids.map(k=>k.cy).concat(p.cy); parts.push(seg(px, p.cy, busX, p.cy, stroke, dash)); parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); - kids.forEach(k => parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash))); + kids.forEach((k, i) => { + parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash)); + markOpt(i, toRight?k.x:k.r, k.cy); + }); } else { /* oben→unten */ const toDown = avgdy >= 0; const py = toDown ? p.b : p.y; @@ -202,7 +212,10 @@ function diagramToSvg(){ const xs = kids.map(k=>k.cx).concat(p.cx); parts.push(seg(p.cx, py, p.cx, busY, stroke, dash)); parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); - kids.forEach(k => parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash))); + kids.forEach((k, i) => { + parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash)); + markOpt(i, k.cx, toDown?k.y:k.b); + }); } }); @@ -240,6 +253,10 @@ function diagramToSvg(){ }); }); + /* 3a) Optionale Knoten: hohler Kreis auf der Kante (nach den Knoten) */ + optMarks.forEach(m => parts.push( + ``)); + /* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */ if(cheapPts.length >= 2){ parts.push(cheapLine('0.2')); @@ -804,10 +821,11 @@ const I18N = { st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", 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).", - a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yLink:"verlinkt", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", 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_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.", + hint_opt:"Zugabe, nicht erforderlich", + hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.", hint_status:"Status als Kästchen nach dem Zeichen, z. B.", hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:", hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.", @@ -855,10 +873,11 @@ const I18N = { st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", 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).", - a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yLink:"has link", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", 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_root:"Line without a marker = root node. Siblings should share the same marker.", + hint_opt:"extra, not required", + hint_root:"Line without a marker = root node. Do not mix | with - / +.", hint_status:"Status as a checkbox after the marker, e.g.", hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:", hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.", @@ -906,10 +925,11 @@ const I18N = { st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", 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).", - a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yLink:"con enlace", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", 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_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.", + hint_opt:"extra, no obligatorio", + hint_root:"Línea sin marcador = nodo raíz. No mezcles | con - / +.", hint_status:"Estado como casilla tras el marcador, p. ej.", hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:", hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.", @@ -957,10 +977,11 @@ const I18N = { st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", 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).", - a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yLink:"avec lien", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", 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_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.", + hint_opt:"supplément, non requis", + hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas | avec - / +.", hint_status:"Statut sous forme de case après le marqueur, p. ex.", hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :", hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.", @@ -1008,10 +1029,11 @@ const I18N = { st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", 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).", - a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", - hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.", + hint_opt:"dodatek, niewymagany", + hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj | z - / +.", hint_status:"Status jako pole wyboru po znaczniku, np.", hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:", hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.", @@ -1059,10 +1081,11 @@ const I18N = { st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", - a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", - hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.", + hint_opt:"дополнение, не обязательно", + hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.", hint_status:"Статус в виде флажка после маркера, напр.", hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", @@ -1110,10 +1133,11 @@ const I18N = { st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", - a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", - hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।", + hint_opt:"अतिरिक्त, आवश्यक नहीं", + hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।", hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे", hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", @@ -1161,10 +1185,11 @@ const I18N = { st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", - a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", - hint_root:"无标记的行 = 根节点。同级应使用相同的标记。", + hint_opt:"附加项,非必需", + hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。", hint_status:"在标记后用方框表示状态,例如", hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", @@ -1212,10 +1237,11 @@ const I18N = { st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", - a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", - hint_root:"マーカーのない行 = ルートノード。兄弟は同じマーカーを使うべきです。", + hint_opt:"追加、必須ではない", + hint_root:"マーカーのない行 = ルートノード。| を - / + と混在させないでください。", hint_status:"マーカーの後にチェックボックスで状態、例:", hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", @@ -1234,6 +1260,7 @@ function buildHint(){ const chip = (key, code) => `${code} ${esc(t('st_'+key))}`; return `${esc(t('hint_indent'))}
-  ${esc(t('hint_all'))} (all of)
+ +  ${esc(t('hint_opt'))} (optional)
|  ${esc(t('hint_any'))} (any of)
${esc(t('hint_root'))}
${esc(t('hint_status'))} - [~] Frontend: diff --git a/frontend/src/model.js b/frontend/src/model.js index 308830c..5f6354e 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -28,9 +28,14 @@ export function visibleChildren(n, showDiscarded){ any-of ⇒ 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. */ + Größe = M. + Optionale Kinder (`+`, SPEC §3/D29) fallen hier ebenfalls heraus — sie sind + per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da beide + Nutzer (`cheapestCost`, `markCheapest`) über diese Funktion gehen, gilt das + samt Teilbaum. */ export function pathChildren(n){ - return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); + return n.children.filter(k => + !k.optional && (!k.status || k.status.key !== 'verworfen')); } /* fehlende Größe wird als M interpretiert */ export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; } diff --git a/frontend/src/parser.js b/frontend/src/parser.js index cad691e..e322b9c 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -20,7 +20,13 @@ export const STATUS_BY_CODE = { }; /* Parst den Notationstext zu { roots, warnings }. - Jeder Knoten: {label, type:'and'|'or', status, url, size, tags, children, line}. + Jeder Knoten: {label, type:'and'|'or', optional, status, url, size, tags, + children, line}. + `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3) + eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung + (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die + Gemischt-Warnung unverändert richtig — sie schlägt nur an, wenn `|` mit + `-`/`+` gemischt wird. Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe -> Tags -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen); Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */ @@ -35,9 +41,10 @@ export function parse(text){ /* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral (fehlertolerant: die Zeile geht nicht verloren). */ - const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([^\]])\]\s*)?(.*)$/); + const m = raw.match(/^([ \t]*)([-|+])?\s*(?:\[([^\]])\]\s*)?(.*)$/); const width = m[1].replace(/\t/g,' ').length; const type = m[2] === '|' ? 'or' : 'and'; + const optional = m[2] === '+'; const boxChar = m[3]; // undefined, wenn keine Statusbox let rest = m[4], url = null, size = null; @@ -57,7 +64,7 @@ export function parse(text){ while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); const parent = stack[stack.length-1].node; - const node = {label, type, status, url, size, tags, children:[], line:i+1}; + const node = {label, type, optional, status, url, size, tags, children:[], line:i+1}; parent.children.push(node); stack.push({node, width}); }); diff --git a/frontend/src/render.js b/frontend/src/render.js index ab3d2c0..3c4a38e 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -14,12 +14,13 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; -/* Zusatzklassen eines Knotens: günstigster Pfad (D18) + „neu in Produktion" - gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional). */ +/* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion" + gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und + optionale Knoten (`+`, SPEC §3/D29 — trägt den hohlen Kreis am Abzweig). */ function extraCls(n, opts){ const cheap = cheapCls(n, opts.cheapSet); const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : ''; - return [cheap, fresh].filter(Boolean).join(' '); + return [cheap, fresh, n.optional ? 'opt' : ''].filter(Boolean).join(' '); } export function esc(s){ @@ -39,6 +40,7 @@ function nodeAria(n, opts){ if(n.size) parts.push(t('a11ySize', {size: n.size})); else if(cheapPath) parts.push(t('a11ySizeImplicit')); if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); + if(n.optional) parts.push(t('a11yOptional')); if(n.url) parts.push(t('a11yLink')); return parts.join(', '); } @@ -52,7 +54,8 @@ function nodeHtml(n, extra, opts){ für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */ const lineAttr = n.line ? ` data-line="${n.line}"` : ''; - const tip = [n.status ? t('st_' + n.status.key) : '', t('jumpHint')] + const tip = [n.status ? t('st_' + n.status.key) : '', + n.optional ? t('a11yOptional') : '', t('jumpHint')] .filter(Boolean).join(' · '); const title = ` title="${attr(tip)}"`; const tagsHtml = n.tags && n.tags.length @@ -84,6 +87,9 @@ function nodeHtml(n, extra, opts){ function renderChildren(node, warnings, opts){ const kids = visibleChildren(node, opts.showDiscarded); if(!kids.length) return ''; + /* Gemischte Gates (SPEC §3): Da `+` nur `optional` setzt und `type:'and'` + behält, schlägt das hier weiterhin genau dann an, wenn `|` mit `-`/`+` + gemischt wird — `-` neben `+` ist erlaubt und still. */ const types = new Set(kids.map(k => k.type)); if(types.size > 1){ /* strukturierte Warnung (Typ + Zeile); Formatierung in warnings.js */ diff --git a/frontend/src/style.css b/frontend/src/style.css index 76e56dc..b2a93e4 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -631,6 +631,30 @@ font-weight:400; } + /* --- OPTIONAL (`+`, SPEC §3/D29): hohler Kreis am Abzweig --- + Kein dritter Linienstil: gestrichelt gehört any-of, und im kompakten Modus + codiert allein der Stil das Gate (D15). Stattdessen die FODA-Konvention — + gefüllter Punkt = erforderlich (hier: gar keiner), hohler Punkt = optional. + Der Kreis sitzt MITTIG AUF der Knotenkante, wo der Abzweig auftrifft. + Grundfall ist die gestapelte Anordnung (vertikal, kompakt, unter any-of): + Abzweig kommt von links auf halber Knotenhöhe. */ + .node.opt::before{ + content:'';position:absolute; + left:-5px;top:50%;transform:translateY(-50%); + width:10px;height:10px;border-radius:50%; + background:var(--card);border:2px solid var(--line); + box-sizing:border-box; + } + /* Horizontaler Fächer: der Abzweig kommt von oben mittig. */ + .tree:not(.vertical):not(.kompakt) ul.and>li>.node.opt::before{ + left:50%;top:-5px;transform:translateX(-50%); + } + /* …ausgenommen der gestapelte all-of-Teilbaum unter einer any-of-Gruppe + (siehe oben): dort kommt der Abzweig wieder von links. */ + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li>.node.opt::before{ + left:-5px;top:50%;transform:translateY(-50%); + } + /* ---------- Status (Pastell) ---------- */ .node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)} .node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)} diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index 39218d2..ff1a780 100644 --- a/frontend/tests/__snapshots__/render.test.js.snap +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • CMS-Anbindung
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • Hosting
      • Cloud
      • On-Premise
  • "`; diff --git a/frontend/tests/optional.test.js b/frontend/tests/optional.test.js new file mode 100644 index 0000000..b3d6a26 --- /dev/null +++ b/frontend/tests/optional.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { computeCheapSet, cheapestCost, visibleChildren } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; + +const t = key => key; +const roots = txt => parse(txt).roots; +const render = txt => renderTreeHtml(roots(txt), + {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()}); +const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort(); + +/* Optionale Knoten: `+` (SPEC §3, D29). Eigenschaft des einzelnen Knotens, + nicht der Gruppe — deshalb bleibt `type` 'and'. */ +describe('Parser — `+` setzt optional, nicht das Gate', () => { + it('erkennt `+` als optionalen Knoten innerhalb einer Und-Gruppe', () => { + const [wurzel] = roots(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`); + expect(wurzel.children.map(k => [k.label, k.type, k.optional])) + .toEqual([['Pflicht', 'and', false], ['Zugabe', 'and', true]]); + }); + + it('lässt `-` und Zeilen ohne Zeichen nicht-optional', () => { + const [wurzel] = roots(`Wurzel\n - Kind`); + expect(wurzel.optional).toBe(false); + expect(wurzel.children[0].optional).toBe(false); + }); + + it('parst Status, Größe, Tags und URL am `+`-Knoten wie sonst auch', () => { + const [wurzel] = roots(`[ ] Wurzel\n + [^] Zugabe (S) https://example.org/x @ana`); + const k = wurzel.children[0]; + expect([k.label, k.status.key, k.size, k.tags, k.url]) + .toEqual(['Zugabe', 'prod', 'S', ['ana'], 'https://example.org/x']); + }); + + /* Bewusste Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen. + Vorher wäre es Teil des Labels gewesen. */ + it('verbraucht ein führendes `+` als Zeichen, nicht als Label-Text', () => { + expect(roots(`+ 5 % Puffer`)[0].label).toBe('5 % Puffer'); + }); +}); + +describe('Günstigster Pfad — optionale Knoten sind nie nötig', () => { + const BAUM = `[ ] Wurzel (S) + - [ ] Pflicht (S) + + [ ] Zugabe (XXL) + - [ ] Unterpunkt (S)`; + + it('lässt den optionalen Knoten samt Teilbaum aus dem Pfad', () => { + expect(cheapLabels(BAUM)).toEqual(['Pflicht', 'Wurzel']); + }); + + it('rechnet die Zugabe nicht in die Kosten des Elternknotens', () => { + /* Wurzel (S=2) + Pflicht (S=2) = 4; mit der XXL-Zugabe wären es 10. */ + expect(cheapestCost(roots(BAUM)[0])).toBe(4); + }); + + it('vergleicht Alternativen ohne deren Zugaben', () => { + /* Ohne die Ausnahme kostete A 2+6=8 und B (3) gewänne. */ + const BAUM = `[ ] Wahl (XS) + | [ ] A (S) + + [ ] Extra (XXL) + | [ ] B (M)`; + expect(cheapLabels(BAUM)).toEqual(['A', 'Wahl']); + }); + + it('blendet optionale Knoten aber nicht aus dem Diagramm aus', () => { + const [wurzel] = roots(BAUM); + expect(visibleChildren(wurzel, false).map(k => k.label)) + .toEqual(['Pflicht', 'Zugabe']); + }); +}); + +describe('Renderer — Kennzeichnung und Gemischt-Warnung', () => { + it('gibt dem optionalen Knoten die Klasse `opt`', () => { + const {html} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`); + expect((html.match(/class="node opt[ "]/g) || []).length).toBe(1); + expect(html).toContain('>Zugabe<'); + }); + + it('warnt NICHT, wenn `-` und `+` nebeneinander stehen', () => { + const {warnings} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`); + expect(warnings).toEqual([]); + }); + + it('warnt weiterhin, wenn `|` mit `+` gemischt wird', () => { + const {warnings} = render(`[ ] Wurzel\n | [ ] Alternative\n + [ ] Zugabe`); + expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Wurzel'}]); + }); + + it('nennt „optional" im aria-label des Knotens', () => { + const {html} = render(`[ ] Wurzel\n + [ ] Zugabe`); + expect(html).toContain('aria-label="Zugabe, a11yStatus, a11yOptional"'); + }); +}); diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js index 3292752..2f3b86e 100644 --- a/frontend/tests/render.test.js +++ b/frontend/tests/render.test.js @@ -16,6 +16,7 @@ const SPEC_EXAMPLE = `%% Projektstruktur – Stand Sprint 14 - [~] Umsetzung (XL) - [/] Frontend (S) https://git.example.de/frontend @anna - [ ] Backend (L) @ben @carla + + [?] Dark Mode (S) %% Zugabe, nicht erforderlich - [ ] CMS-Anbindung (M) | [ ] WordPress | [?] Headless CMS @@ -39,11 +40,12 @@ const count = (html, needle) => html.split(needle).length - 1; describe('renderTreeHtml — kanonisches Beispiel', () => { it('Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot', () => { const {html, warnings} = renderExample(); - expect(count(html, 'class="node')).toBe(13); // Eigenentwicklung ausgeblendet + expect(count(html, 'class="node')).toBe(14); // Eigenentwicklung ausgeblendet expect(count(html, 'cheap-leaf')).toBe(0); expect(count(html, 'size implicit')).toBe(0); expect(count(html, 'ghost-node')).toBe(1); // Backend (L) ist ein M+-Blatt expect(count(html, '