notation: + für optionale Knoten — Zugaben statt Pflicht oder Alternative

Die Notation kannte nur „erforderlich" (-) und „wählbar" (|). Ein einzelnes
zusätzliches Feature — weder nötig noch Alternative zu etwas anderem — musste
als normales -Kind notiert werden und log damit. Feature-Modelle (FODA)
unterscheiden seit den 90ern mandatory/optional/alternative; `+` ergänzt die
fehlende zweite Beziehung. Mnemonik: `-` Teilpaket, `+` Zugabe, `|` Alternative.

Anlass ist nicht die Optik, sondern der günstigste Pfad (D18): markCheapest()
lief bei all-of über ALLE Kinder, jede Zugabe steckte also im errechneten
Minimum — systematisch zu groß, und zwar umso mehr, je ehrlicher ein Plan auch
die Kür notiert. Sichtbar wird es beim Alternativenvergleich: eine Alternative
mit teurer Zugabe verlor gegen eine schlichtere, obwohl die Zugabe gar nicht
dazugehört.

- Parser setzt `optional:true` und lässt `type:'and'` stehen — `+` gehört zum
  Knoten, nicht zur Gruppe. Dadurch bleiben gateOf() und die mixedGate-Warnung
  unverändert richtig: sie meldet weiter genau dann, wenn | mit -/+ gemischt
  wird. `-` neben `+` ist erlaubt und still — „diese drei sind nötig, das hier
  wäre schön" ist der Normalfall, nicht der Fehlerfall.
- Aus dem Pfad fallen optionale Knoten über pathChildren() heraus, die eine
  Stelle, die cheapestCost() und markCheapest() gemeinsam nutzen — deshalb
  wirkt es samt Teilbaum.
- Darstellung: hohler Kreis mittig auf der Knotenkante, wo der Abzweig
  auftrifft (FODA-Konvention). Bewusst KEIN dritter Linienstil: im kompakten
  Modus codiert allein der Stil das Gate (D15), gepunktet müsste sich dort
  gegen gestrichelt-grau behaupten. Der Kreis ist orthogonal dazu.
  CSS-Grundfall ist gestapelt (links/50 %), Ausnahme der horizontale Fächer
  (oben/50 %), Rück-Ausnahme der gestapelte all-of-Teilbaum unter any-of (D18)
  — andersherum wären es vier Ausnahmen statt zwei.
- SVG-Export zeichnet den Kreis NACH den Knoten (optMarks, Schritt 3a): er
  liegt halb außerhalb der Box und würde sonst vom Knoten-Rechteck überdeckt.
- Legende, Knoten-Tooltip und aria-label in allen neun Sprachen; hint_root
  formuliert die neue Mischregel.

Bekannte Schwäche, bewusst in Kauf genommen: Bei aktivem Pfad-Umschalter wird
der optionale Knoten ausgeblasst (opacity:.32) — und mit ihm sein Kreis, der
die Erklärung dafür wäre. `opacity` am Elternteil schlägt auf Pseudoelemente
durch, das lässt sich nicht zurücknehmen. Das Zurücktreten ist hier die
Hauptaussage (wie bei nicht gewählten Alternativen), Tooltip/aria/Legende
liefern die Begründung nach.

Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen und nicht mehr
Teil des Labels (`+ 5 % Puffer` ergibt „5 % Puffer"). Test-abgedeckt.

SPEC §1/§3/§9/§10 zuerst, dann Code (CLAUDE). Das kanonische Beispiel in §10
enthält jetzt eine `+`-Zeile und ist mit der Test-Fixture wieder deckungsgleich.
Der mitgelieferte Werkbaum-Plan markiert Drucklayout, „Was ist neu?" und die
Personenfarben als Zugaben — „Was ist neu?" war der Auslöser der Frage.

Verifiziert: 12 neue Tests (Parser setzt optional/type; Status/Größe/Tags/URL
am +-Knoten; führendes + wird verbraucht; Pfad lässt Zugabe samt Teilbaum aus;
Kosten des Elternknotens ohne Zugabe; Alternativenvergleich ohne Zugaben;
optionale Knoten bleiben sichtbar; opt-Klasse; keine Warnung bei -/+; Warnung
bei |/+; aria-label). Vitest 58/58, Snapshot zeigt `node opt` OHNE `cheap`.
Im Browser in allen drei Modi angesehen: Kreis sitzt in horizontal oben mittig,
in vertikal und kompakt links auf halber Höhe, jeweils genau auf dem Ende des
Abzweigs; SVG-Export enthält beide Kreise an denselben Punkten (gerendert
geprüft, nicht nur im Quelltext).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-27 20:58:52 +02:00
co-authored by Claude Opus 4.8
parent b7637d6393
commit aa3e087ee6
13 changed files with 336 additions and 53 deletions
+11
View File
@@ -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
+2 -1
View File
@@ -79,8 +79,9 @@
<div class="hint" id="hint">
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br>
<code>-</code>&nbsp; Teilpaket, alle erforderlich <em>(all of)</em><br>
<code>+</code>&nbsp; Zugabe, nicht erforderlich <em>(optional)</em><br>
<code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br>
Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.<br>
Zeile ohne Zeichen = Wurzelknoten. <code class="or-code">|</code> nicht mit <code>-</code>/<code>+</code> mischen.<br>
Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>:
<div class="chips">
<span class="chip st-idee">[?] Idee</span>
+49 -22
View File
@@ -174,6 +174,11 @@ function diagramToSvg(){
/* 1) Verbindungslinien (hinter den Knoten) */
const seg = (x1,y1,x2,y2,stroke,dash) =>
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="2"${dash?' stroke-dasharray="5 4"':''} stroke-linecap="round"/>`;
/* 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(
`<circle cx="${m.x.toFixed(1)}" cy="${m.y.toFixed(1)}" r="4" fill="#ffffff" stroke="#41556E" stroke-width="2"/>`));
/* 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) 访问并允许 CORSAccess-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) でアクセス可能で、CORSAccess-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) => `<span class="chip st-${key}">${code}&nbsp;${esc(t('st_'+key))}</span>`;
return `${esc(t('hint_indent'))}<br>
<code>-</code>&nbsp; ${esc(t('hint_all'))} <em>(all of)</em><br>
<code>+</code>&nbsp; ${esc(t('hint_opt'))} <em>(optional)</em><br>
<code class="or-code">|</code>&nbsp; ${esc(t('hint_any'))} <em>(any of)</em><br>
${esc(t('hint_root'))}<br>
${esc(t('hint_status'))} <code>- [~] Frontend</code>:
+7 -2
View File
@@ -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; }
+10 -3
View File
@@ -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});
});
+10 -4
View File
@@ -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 */
+24
View File
@@ -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)}
@@ -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`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="14" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li><div class="node opt st-idee" tabindex="0" data-line="9" aria-label="Dark Mode, a11yStatus, a11ySize, a11yOptional" title="st_idee · a11yOptional · jumpHint">Dark Mode<span class="size" aria-hidden="true">S</span></div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="10" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="11" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="12" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="14" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="15" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="16" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus, a11ySizeImplicit" title="st_geplant · jumpHint">WordPress<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus, a11ySizeImplicit" title="st_idee · jumpHint">Headless CMS<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf" tabindex="0" data-line="14" aria-label="Cloud, a11ySizeImplicit" title="jumpHint">Cloud<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise, a11ySizeImplicit" title="jumpHint">On-Premise<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li><div class="node opt st-idee" tabindex="0" data-line="9" aria-label="Dark Mode, a11yStatus, a11ySize, a11yOptional" title="st_idee · a11yOptional · jumpHint">Dark Mode<span class="size" aria-hidden="true">S</span></div></li><li class="has-or"><div class="node cheap st-geplant" tabindex="0" data-line="10" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="11" aria-label="WordPress, a11yStatus, a11ySizeImplicit" title="st_geplant · jumpHint">WordPress<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" tabindex="0" data-line="12" aria-label="Headless CMS, a11yStatus, a11ySizeImplicit" title="st_idee · jumpHint">Headless CMS<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" tabindex="0" data-line="14" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf" tabindex="0" data-line="15" aria-label="Cloud, a11ySizeImplicit" title="jumpHint">Cloud<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node" tabindex="0" data-line="16" aria-label="On-Premise, a11ySizeImplicit" title="jumpHint">On-Premise<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li><li><div class="node st-verworfen" tabindex="0" data-line="12" aria-label="Eigenentwicklung, a11yStatus" title="st_verworfen · jumpHint">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="14" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li><div class="node opt st-idee" tabindex="0" data-line="9" aria-label="Dark Mode, a11yStatus, a11ySize, a11yOptional" title="st_idee · a11yOptional · jumpHint">Dark Mode<span class="size" aria-hidden="true">S</span></div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="10" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="11" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="12" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li><li><div class="node st-verworfen" tabindex="0" data-line="13" aria-label="Eigenentwicklung, a11yStatus" title="st_verworfen · jumpHint">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="14" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="15" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="16" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
+93
View File
@@ -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"');
});
});
+8 -5
View File
@@ -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, '<ul class="or">')).toBe(2); // CMS-Anbindung, Hosting
expect(html).toMatch(/class="node opt st-idee"[^>]*>Dark Mode/); // `+` (SPEC §3)
expect(warnings).toEqual([]);
expect(html).toMatchSnapshot();
});
@@ -57,7 +59,7 @@ describe('renderTreeHtml — kanonisches Beispiel', () => {
it('verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen)', () => {
const {html} = renderExample({showDiscarded: true});
expect(count(html, 'class="node')).toBe(14);
expect(count(html, 'class="node')).toBe(15);
expect(html).toContain('st-verworfen');
expect(html).toMatchSnapshot();
});
@@ -118,15 +120,16 @@ describe('renderTreeHtml — data-line je Knoten (D25)', () => {
expect(lineOf(html, 'Website-Relaunch')).toBe(2); // Zeile 1 ist ein %%-Kommentar
expect(lineOf(html, 'Zielgruppenanalyse')).toBe(4);
expect(lineOf(html, 'Frontend')).toBe(7); // verlinkt -> <a>
expect(lineOf(html, 'Headless CMS')).toBe(11); // any-of-Alternative
expect(lineOf(html, 'Eigenentwicklung')).toBe(12); // verworfen, eingeblendet
expect(lineOf(html, 'Dark Mode')).toBe(9); // optionaler Knoten (+)
expect(lineOf(html, 'Headless CMS')).toBe(12); // any-of-Alternative
expect(lineOf(html, 'Eigenentwicklung')).toBe(13); // verworfen, eingeblendet
});
it('vergibt jede Zeilennummer genau einmal', () => {
const {html} = renderExample({showDiscarded: true});
const lines = [...html.matchAll(/class="node[^"]*"[^>]*?data-line="(\d+)"/g)].map(m => m[1]);
expect(lines.length).toBe(new Set(lines).size);
expect(lines.length).toBe(14); // alle Zeilen des SPEC-Beispiels außer dem Kommentar
expect(lines.length).toBe(15); // alle Zeilen des SPEC-Beispiels außer dem Kommentar
});
it('der Geister-Knoten bekommt keine Zeilennummer (er steht für keine Zeile)', () => {