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:
co-authored by
Claude Opus 4.8
parent
b7637d6393
commit
aa3e087ee6
@@ -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
@@ -79,8 +79,9 @@
|
||||
<div class="hint" id="hint">
|
||||
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br>
|
||||
<code>-</code> Teilpaket, alle erforderlich <em>(all of)</em><br>
|
||||
<code>+</code> Zugabe, nicht erforderlich <em>(optional)</em><br>
|
||||
<code class="or-code">|</code> 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. B. <code>- [~] Frontend</code>:
|
||||
<div class="chips">
|
||||
<span class="chip st-idee">[?] Idee</span>
|
||||
|
||||
+49
-22
@@ -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) 访问并允许 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) => `<span class="chip st-${key}">${code} ${esc(t('st_'+key))}</span>`;
|
||||
return `${esc(t('hint_indent'))}<br>
|
||||
<code>-</code> ${esc(t('hint_all'))} <em>(all of)</em><br>
|
||||
<code>+</code> ${esc(t('hint_opt'))} <em>(optional)</em><br>
|
||||
<code class="or-code">|</code> ${esc(t('hint_any'))} <em>(any of)</em><br>
|
||||
${esc(t('hint_root'))}<br>
|
||||
${esc(t('hint_status'))} <code>- [~] Frontend</code>:
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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>"`;
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user