feat: Effektiver Status — Knotenfarbe zeigt ihn, Diskrepanz-Marke unten links (SPEC §4/§9, D39)

Jeder Status bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala
(D5); effektiv ist das Minimum über die Abhängigkeits-Hülle (Fixpunkt —
Zyklen teilen ihr Minimum, wörtlich das „wird gemeinsam fertig" aus D34).
Neutral und [-] zählen als 0, [!] als 1; bei doppelter ID gilt die erste
Vergabe. Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven
Status — das Diagramm beantwortet „wie weit ist das wirklich?". Wo der
eigene Status weiter ist, sitzt unten links die eigene Statusbox als
Marke in den eigenen §4-Farben (auch im Export/Druck); Tooltip und
a11yEffective benennen beides. XOR-„realisiert" und „Was ist neu?"
bleiben bewusst intrinsisch. i18n in allen 9 Sprachen; 12 neue Tests
(tests/effective.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 17:15:04 +02:00
co-authored by Claude Fable 5
parent cbe798d746
commit 15c3e85e26
12 changed files with 297 additions and 40 deletions
+40
View File
@@ -1834,3 +1834,43 @@ Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent
per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts und
wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im
Druck entfernt — es ist Bedienelement, keine Aussage über den Plan.
## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box
Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der
Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4).
Erwogen waren drei Darstellungen — Diskrepanz-Kennzeichnung bei intrinsischer
Farbe (die ursprüngliche Empfehlung), ein Umschalter, oder Farbe = effektiv.
**Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven Status; wo der
eigene Status weiter ist, kommt eine Diskrepanz-Marke dazu.** Das ist die
stärkere Wahl: Das Diagramm beantwortet „wie weit ist das wirklich?“ — genau
die Frage, für die man auf einen Plan schaut. Ein grünes `[x]`, das auf ein
rosé `[~]` wartet, wäre die hübschere Lüge.
**Die Rechenregel ist ein Minimum über die Abhängigkeits-Hülle.** Jeder Status
bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala (D5); effektiv ist
das Minimum des intrinsischen Rangs über den Knoten selbst und alles, was er
direkt oder mittelbar braucht. Diese Form hat zwei angenehme Folgen: **Zyklen
brauchen keine Sonderregel** — alle Knoten eines Zyklus teilen ihr Minimum,
und das ist wörtlich das „wird gemeinsam fertig“ aus D34; und die Rechnung ist
eine schlichte Fixpunkt-Iteration (Ränge sinken nur, Abbruch garantiert).
Außerhalb der Skala: neutral und `[-]` zählen als 0 — wer auf etwas
Verworfenes oder nie Begonnenes zeigt, ist effektiv am Anfang, und genau das
soll auffallen —, `[!]` als 1 (die Absicht-ohne-Investition-Gruppe aus D35).
Bei doppelter ID gilt die **erste** Vergabe — das löst das in D36 offen
gelassene „wer gewinnt bei Verweisen“ konsistent zur `duplicateId`-Warnung.
**Die Diskrepanz-Marke ist die eigene Statusbox in den eigenen Farben.**
Unten links (die letzte freie Knoten-Ecke: oben rechts Größe, unten rechts
Tags, oben links ⚠) sitzt ein kleines `[x]`-Etikett in den §4-Farben des
intrinsischen Status — die Notation kennzeichnet sich mit ihrem eigenen
Vokabular, niemand lernt ein neues Symbol, und die Farben tragen die ganze
Geschichte: Knoten rosé (effektiv in Arbeit), Etikett grün (selbst fertig).
Tooltip („effektiv … — selbst schon …, wartet auf Abhängigkeiten“) und
`a11yEffective` sagen es in Worten. Marke und Färbung gehören in Export und
Druck — sie sind eine Aussage über den Plan, nicht über den Betrachter.
**Was beim intrinsischen Status bleibt:** die XOR-Regel (§3) — „realisiert“
heißt Kosten investiert, und investiert ist investiert, auch wenn
Abhängigkeiten den Knoten zurückhalten; und „Was ist neu?“ (D28) — der gelbe
Kranz meldet das `[^]` im Text, also den Deploy des Knotens selbst. Beide
Prüfungen laufen im Parser bzw. auf dem Text und bleiben unberührt.
+32 -11
View File
@@ -190,6 +190,24 @@ Codiert als Checkbox nach dem Zeichen (Erweiterung der Markdown-Task-Syntax):
verloren, Folgezeilen bleiben unberührt. (Eine mehrzeichige Klammer wie
`[xyz]` ist keine Statusbox und bleibt Teil des Labels.)
**Intrinsischer und effektiver Status (Abhängigkeiten, §1).** Der Status in
der Box ist der **intrinsische** — der Bearbeitungsstand des Knotens selbst.
Der **effektive** Status berücksichtigt die Abhängigkeiten: Ein Knoten kann
effektiv nicht weiter sein als das, was er braucht.
- **Fortschritts-Rang** entlang der Ergebnis-Skala (D5):
`[?]` 0 · `[ ]` 1 · `[~]` 2 · `[/]` 3 · `[x]` 4 · `[^]` 5. Außerhalb der
Skala zählen neutrale Knoten und `[-]` als Rang 0 („nichts Anrechenbares“),
`[!]` als Rang 1 (Absicht ohne Investition, D35).
- **Effektiver Rang = Minimum des intrinsischen Rangs über die
Abhängigkeits-Hülle**: der Knoten selbst plus alles, was er per `:#…` direkt
oder mittelbar braucht. Zyklen brauchen keine Sonderregel — alle Knoten
eines Zyklus teilen so von selbst ihr Minimum („wird gemeinsam fertig“, §1).
Unbekannte IDs zählen nicht (sie sind schon gewarnt); bei doppelter ID gilt
die **erste** Vergabe (D36).
- Der effektive Status wird **gerechnet, nie geschrieben** (D14: der Text ist
die eine Quelle der Wahrheit). Darstellung: §9; Begründung: D39.
## 5. Aufwand (T-Shirt-Größen)
- Werte: `XS < S < M < L < XL < XXL`, notiert in Klammern, z. B. `(L)`.
@@ -241,6 +259,17 @@ Stück zwischen Elternknoten und erstem Abzweig — eine kleine **„1“-Plaket
(weißer Kreis mit grauem Rand, graue Ziffer): „genau eine“. Sie erscheint auch
im Grafikexport. Siehe D35.
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
zurückgehalten), sitzt unten links eine kleine **Status-Marke** mit der
eigenen Statusbox (z. B. `[x]`) in den §4-Farben des intrinsischen Status;
Tooltip und `aria-label` benennen beide. Ohne Diskrepanz ändert sich nichts —
und für die XOR-Regel (§3) und „Was ist neu?“ (unten) zählt weiterhin der
**intrinsische** Status: Investiert ist investiert, und `[^]` im Text bleibt
die Deploy-Aussage. Marke und Färbung erscheinen auch im Grafikexport und im
Druck. Siehe D39.
**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Zerlegung,
die Anordnung bleibt unverändert. Zwei Kennzeichen, beide auch im Grafikexport:
@@ -661,17 +690,9 @@ die Closure-Rechnung des günstigsten Pfads (alle drei unten).
### Intrinsischer und effektiver Status
- Der **intrinsische** Status ist der, der in der Statusbox steht (§4) — der
Bearbeitungsstand des Knotens selbst.
- Der **effektive** Status berücksichtigt zusätzlich die Abhängigkeiten: Ein
intrinsisch fertiger Knoten ist effektiv **nicht** fertig, solange eine
Abhängigkeit es nicht ist.
- Der effektive Status wird **gerechnet, nie geschrieben**. Im Notationstext
steht ausschließlich der intrinsische — sonst gäbe es zwei Quellen der
Wahrheit für dieselbe Aussage.
- **Offen ist die Darstellung.** Die Knotenfarbe zeigt heute den intrinsischen
Status (§4); beide zugleich brauchen entweder einen zweiten visuellen Kanal
oder einen Umschalter.
**Umgesetzt** — Rechenregel in §4 (Fortschritts-Rang, Minimum über die
Abhängigkeits-Hülle), Darstellung in §9 (Knotenfarbe = effektiver Status,
Diskrepanz-Marke unten links). Siehe D39.
### Querverbindungen im Diagramm
+6 -1
View File
@@ -83,8 +83,13 @@ entscheiden, **bevor** Code entsteht.
`deps` als ID-Strings am Knoten, Warnung `unknownDep`, Zyklen bewusst
ungeprüft; sichtbar als `→ #a, #b` im Tooltip + `a11yDeps`;
SPEC-§11-Teil nach §1 überführt; `tests/deps.test.js`.
- [ ] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung
- [x] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung
entscheiden — die Knotenfarbe zeigt heute den intrinsischen Status.
→ Entschieden und umgesetzt (D39): Fortschritts-Rang (SPEC §4), effektiv
= Minimum über die Abhängigkeits-Hülle (Fixpunkt, Zyklen teilen ihr
Minimum); **Knotenfarbe = effektiver Status**, bei Diskrepanz die eigene
Statusbox als Marke unten links in den eigenen Farben (auch im Export);
Tooltip + `a11yEffective`; `tests/effective.test.js`.
- [ ] Günstigsten Pfad auf die Dependency Closure umstellen (gemeinsame
Abhängigkeiten nur einmal zählen). Erweitert D18; die gierige Wahl je
Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und
+3 -3
View File
@@ -12,9 +12,9 @@
- [x] Node IDs (#auth) (S) %% often just the ticket number, see SPEC §11
+ [?] Free tags, &tag (M) %% only together with a consumer, see D34
- [?] A lens: highlight every node tagged &x (S)
- [~] Dependencies across the tree, (:#auth,#api) (M)
- [?] They constrain the status, not when work may start (S)
- [?] Effective status: intrinsic, plus what it depends on (S)
- [x] Dependencies across the tree, (:#auth,#api) (M)
- [x] They constrain the status, not when work may start (S)
- [x] Effective status: intrinsic, plus what it depends on (S)
- [x] Cycles are legal — they mean "finished together" (XS)
- [x] An id with no node behind it is a warning (XS)
- [x] XOR, = — exactly one alternative, not at least one (S)
+9
View File
@@ -292,6 +292,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Knoten-Referenzen; der Parser prüft nur Existenz (`unknownDep`), Zyklen
werden bewusst nicht einmal erkannt (zulässig, „gemeinsam fertig"). Keine
Diagramm-Darstellung — nur Tooltip (`→ #a, #b`) und `a11yDeps`.
- Effektiver Status (SPEC §4/§9, D39): `effectiveStatus(roots)` in model.js
liefert NUR die Diskrepanzen (Map Knoten → effektiver Status-Key; Minimum
des Fortschritts-Rangs über die Abhängigkeits-Hülle, Fixpunkt — Zyklen
teilen ihr Minimum, erste ID-Vergabe gewinnt). Der Renderer färbt dann
`st-<effKey>` statt des intrinsischen und hängt die Marke
`.chip.ownst.st-<ownKey>` (eigene Statusbox, eigene Farben) unten links an —
im Export via `drawBadge`, aus dem Label-Klon entfernen (`.ownst`). XOR-
„realisiert" (Parser) und „Was ist neu?" (`[^]` im Text) bleiben bewusst
**intrinsisch**.
- XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit
folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt
`<ul class="or xor">` aus, damit die **gesamte** any-of-Geometrie (alle drei
+1
View File
@@ -122,6 +122,7 @@
Personen mit <code>@name</code> — erscheinen unten rechts am Knoten.
Knoten-ID mit <code>#name</code> — erscheint im Tooltip des Knotens.
Abhängigkeiten mit <code>:#name,#name</code> — erscheinen im Tooltip.
Die Knotenfarbe zeigt den effektiven Status (mit Abhängigkeiten); ist der eigene weiter, steht er als Marke unten links.
Falten: <code>- &gt; [x] …</code> startet eingeklappt, <code>&lt;</code> holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).
</div>
</div>
+29 -12
View File
@@ -1,6 +1,6 @@
import './style.css';
import { parse } from './parser.js';
import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys } from './model.js';
import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js';
@@ -112,7 +112,8 @@ function render(){
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
});
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
freshSet, collapsedSet});
freshSet, collapsedSet,
effStatus: effectiveStatus(roots)});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
}
@@ -402,7 +403,7 @@ function diagramToSvg(){
§9/D38 das Bild darf keine Vollständigkeit behaupten); das offener
Knoten ist Bedienelement und fällt weg. */
const stripFold = node.classList.contains('folded') ? '' : ',.fold';
clone.querySelectorAll('.size,.tags,.ext,.risk' + stripFold).forEach(e => e.remove());
clone.querySelectorAll('.size,.tags,.ext,.risk,.ownst' + stripFold).forEach(e => e.remove());
const label = clone.textContent.replace(/\s+/g,' ').trim();
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
parts.push(`<text x="${b.cx.toFixed(1)}" y="${(b.cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(label)}</text>`);
@@ -418,6 +419,12 @@ function diagramToSvg(){
const t = getComputedStyle(tg);
drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor);
});
/* Diskrepanz-Marke des effektiven Status (D39) Aussage über den Plan,
gehört in den Export wie Größe und Tags. */
node.querySelectorAll('.ownst').forEach(c => {
const s = getComputedStyle(c);
drawBadge(c, s.backgroundColor, s.color, s.borderTopColor);
});
});
/* 3a) Optionale Knoten: hohler Kreis auf der Kante (nach den Knoten) */
@@ -1170,7 +1177,7 @@ const I18N = {
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).",
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", 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_xor:"Alternative, genau eine",
@@ -1184,6 +1191,7 @@ const I18N = {
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.",
hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.",
hint_eff:"Die Knotenfarbe zeigt den effektiven Status (mit Abhängigkeiten); ist der eigene weiter, steht er als Marke unten links.",
hint_fold:"Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).",
hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild."
},
@@ -1241,7 +1249,7 @@ const I18N = {
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).",
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", 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_xor:"alternative, exactly one",
@@ -1255,6 +1263,7 @@ const I18N = {
hint_people:"People with @name — shown at the bottom-right of the node.",
hint_id:"Node ID with #name — shown in the node's tooltip.",
hint_deps:"Dependencies with :#name,#name — shown in the tooltip.",
hint_eff:"Node colour shows the effective status (with dependencies); if its own is further along, it appears as a mark at the bottom left.",
hint_fold:"Folding: - > [x] … starts collapsed, < brings it back; ▾/▸ on a node toggles (keyboard: ←/→).",
hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view."
},
@@ -1312,7 +1321,7 @@ const I18N = {
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).",
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", 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_xor:"alternativa, exactamente una",
@@ -1326,6 +1335,7 @@ const I18N = {
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.",
hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.",
hint_eff:"El color del nodo muestra el estado efectivo (con dependencias); si el propio va más adelante, aparece como marca abajo a la izquierda.",
hint_fold:"Plegado: - > [x] … empieza plegado, < lo recupera; ▾/▸ en el nodo alterna (teclado: ←/→).",
hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista."
},
@@ -1383,7 +1393,7 @@ const I18N = {
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).",
sourceTimeoutWarn:"« {url} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", 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_xor:"alternative, exactement une",
@@ -1397,6 +1407,7 @@ const I18N = {
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
hint_id:"ID de nœud avec #nom — visible dans linfobulle du nœud.",
hint_deps:"Dépendances avec :#nom,#nom — visibles dans linfobulle.",
hint_eff:"La couleur du nœud montre le statut effectif (avec dépendances) ; si le sien est plus avancé, il apparaît en marque en bas à gauche.",
hint_fold:"Pliage : - > [x] … démarre replié, < le fait ressortir ; ▾/▸ sur le nœud bascule (clavier : ←/→).",
hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran."
},
@@ -1454,7 +1465,7 @@ const I18N = {
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).",
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_xor:"alternatywa, dokładnie jedna",
@@ -1468,6 +1479,7 @@ const I18N = {
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.",
hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.",
hint_eff:"Kolor węzła pokazuje status efektywny (z zależnościami); jeśli własny jest dalej, widnieje jako znacznik u dołu po lewej.",
hint_fold:"Zwijanie: - > [x] … zaczyna zwinięte, < przywraca; ▾/▸ na węźle przełącza (klawiatura: ←/→).",
hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie."
},
@@ -1525,7 +1537,7 @@ const I18N = {
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна",
@@ -1539,6 +1551,7 @@ const I18N = {
hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.",
hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.",
hint_eff:"Цвет узла показывает фактический статус (с учётом зависимостей); если собственный дальше, он показан меткой слева внизу.",
hint_fold:"Сворачивание: - > [x] … открывается свёрнутым, < возвращает; ▾/▸ на узле переключает (клавиши: ←/→).",
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
},
@@ -1596,7 +1609,7 @@ const I18N = {
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक",
@@ -1610,6 +1623,7 @@ const I18N = {
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।",
hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।",
hint_eff:"नोड का रंग प्रभावी स्थिति दिखाता है (निर्भरताओं सहित); यदि अपनी स्थिति आगे है, तो वह नीचे-बाएँ चिह्न के रूप में दिखती है।",
hint_fold:"फ़ोल्डिंग: - > [x] … समेटा हुआ खुलता है, < वापस लाता है; नोड पर ▾/▸ टॉगल करता है (कीबोर्ड: ←/→)।",
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
},
@@ -1667,7 +1681,7 @@ const I18N = {
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个",
@@ -1681,6 +1695,7 @@ const I18N = {
hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_id:"用 #名称 指定节点 ID——显示在节点提示中。",
hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。",
hint_eff:"节点颜色显示实际状态(含依赖);若自身状态更靠前,会以左下角标记显示。",
hint_fold:"折叠:- > [x] … 打开时即折叠,< 将其展开;节点上的 ▾/▸ 切换(键盘:←/→)。",
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
},
@@ -1738,7 +1753,7 @@ const I18N = {
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ",
@@ -1752,6 +1767,7 @@ const I18N = {
hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。",
hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。",
hint_eff:"ノードの色は実効ステータス(依存関係込み)を示します。自身が先行している場合は左下のマークで表示されます。",
hint_fold:"折りたたみ:- > [x] … は折りたたんだ状態で開き、< は呼び戻します。ノードの ▾/▸ で切替(キー:←/→)。",
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
}
@@ -1787,6 +1803,7 @@ function buildHint(){
${esc(t('hint_people'))}
${esc(t('hint_id'))}
${esc(t('hint_deps'))}
${esc(t('hint_eff'))}
${esc(t('hint_fold'))}
<code>!!!</code>&nbsp; ${esc(t('hint_focus'))}
<div class="hint-op">${esc(t('hint_jump'))}</div>`;
+45
View File
@@ -78,6 +78,51 @@ export function cheapCls(n, cheapSet){
return leaf ? 'cheap cheap-leaf' : 'cheap';
}
/* ---------- Effektiver Status (SPEC §4/§9, D39) ----------
Fortschritts-Rang entlang der Ergebnis-Skala (D5). Außerhalb der Skala:
neutrale und verworfene Knoten zählen als 0 (nichts Anrechenbares"),
High Risk als 1 (Absicht ohne Investition, D35). */
export const PROGRESS_RANK = { idee:0, geplant:1, highrisk:1, arbeit:2, durchstich:3, fertig:4, prod:5 };
const RANK_STATUS_KEY = ['idee', 'geplant', 'arbeit', 'durchstich', 'fertig', 'prod'];
export function progressRank(n){
if(!n.status) return 0;
const r = PROGRESS_RANK[n.status.key];
return r === undefined ? 0 : r; /* verworfen -> 0 */
}
/* Diskrepanzen des effektiven Status: Map Knoten -> effektiver Status-KEY,
nur für Knoten, deren eigener Status weiter ist. Effektiver Rang = Minimum
des intrinsischen Rangs über die Abhängigkeits-Hülle (Knoten selbst plus
alles per `:#…` Erreichbare), als Fixpunkt-Iteration Ränge sinken nur,
Zyklen teilen so von selbst ihr Minimum (wird gemeinsam fertig", §1).
Unbekannte IDs zählen nicht (schon gewarnt, `unknownDep`); bei doppelter
ID gilt die ERSTE Vergabe (D36/D39). */
export function effectiveStatus(roots){
const nodes = [], byId = new Map();
(function walk(ns){
for(const n of ns){
nodes.push(n);
if(n.id != null && !byId.has(n.id)) byId.set(n.id, n);
walk(n.children);
}
})(roots);
const eff = new Map(nodes.map(n => [n, progressRank(n)]));
let changed = true;
while(changed){
changed = false;
for(const n of nodes){
let v = eff.get(n);
for(const d of n.deps || []){
const target = byId.get(d);
if(target && eff.get(target) < v) v = eff.get(target);
}
if(v < eff.get(n)){ eff.set(n, v); changed = true; }
}
}
const map = new Map();
eff.forEach((r, n) => { if(r < progressRank(n)) map.set(n, RANK_STATUS_KEY[r]); });
return map;
}
/* ---------- Was ist neu? (D28) ----------
Neu" heißt hier bewusst nicht „Zeile hinzugefügt", sondern **neu in
Produktion**: ein Knoten, der jetzt `[^]` trägt und es in der zuletzt
+11 -10
View File
@@ -6,17 +6,18 @@
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {key, name}.
`name` ist der deutsche Anzeigename (Quellsprache). */
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
export const STATUS_BY_CODE = {
'?': {key:'idee', name:'Idee'},
' ': {key:'geplant', name:'geplant'},
'~': {key:'arbeit', name:'in Arbeit'},
'/': {key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {key:'fertig', name:'fertig'},
'^': {key:'prod', name:'in Produktion'},
'-': {key:'verworfen', name:'verworfen'},
'!': {key:'highrisk', name:'High Risk Aufwand unklar'}
'?': {code:'?', key:'idee', name:'Idee'},
' ': {code:' ', key:'geplant', name:'geplant'},
'~': {code:'~', key:'arbeit', name:'in Arbeit'},
'/': {code:'/', key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {code:'x', key:'fertig', name:'fertig'},
'^': {code:'^', key:'prod', name:'in Produktion'},
'-': {code:'-', key:'verworfen', name:'verworfen'},
'!': {code:'!', key:'highrisk', name:'High Risk Aufwand unklar'}
};
/* Status, die als realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind
+21 -3
View File
@@ -11,6 +11,8 @@
cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus)
freshSet, // optional: Knoten, die neu in Produktion sind (D28)
collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38)
effStatus, // optional: Map Knoten -> effektiver Status-Key, nur
// Diskrepanzen (effectiveStatus() in model.js, D39)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
@@ -52,6 +54,10 @@ function nodeAria(n, opts, fold){
Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */
if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count}));
if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)}));
/* Diskrepanz (D39): die Farbe zeigt den effektiven Status der Screenreader
bekommt beide, wie der Tooltip. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
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(', ')}));
@@ -72,8 +78,12 @@ function nodeAria(n, opts, fold){
function nodeHtml(n, extra, opts, fold){
const { t, cheapPath } = opts;
const need = needsBreakdown(n);
/* Die Knotenfarbe zeigt den EFFEKTIVEN Status (SPEC §9/D39); bei Diskrepanz
trägt die Marke unten links die eigene Statusbox in den eigenen Farben. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '',
n.status ? 'st-' + n.status.key : '']
effKey ? 'held' : '',
n.status ? 'st-' + (effKey || n.status.key) : '']
.filter(Boolean).join(' ');
/* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
@@ -81,7 +91,9 @@ function nodeHtml(n, extra, opts, fold){
const lineAttr = n.line ? ` data-line="${n.line}"` : '';
const tip = [n.id ? '#' + n.id : '',
n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '',
n.status ? t('st_' + n.status.key) : '',
effKey
? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)})
: (n.status ? t('st_' + n.status.key) : ''),
n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`;
@@ -104,12 +116,18 @@ function nodeHtml(n, extra, opts, fold){
? `<span class="fold" aria-hidden="true">${fold.collapsed ? '▸ ' + fold.count : '▾'}</span>`
: '';
const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
/* Diskrepanz-Marke: die eigene Statusbox in den eigenen §4-Farben
selbst schon [x], wartet auf Abhängigkeiten" (D39). */
const ownChip = effKey
? `<span class="chip ownst st-${n.status.key}" aria-hidden="true">[${n.status.code}]</span>`
: '';
const inner = foldHtml +
esc(n.label) +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
tagsHtml;
tagsHtml +
ownChip;
const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
const html = n.url
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${expanded}${title}>${inner}</a>`
+8
View File
@@ -644,6 +644,14 @@
position:absolute;right:-9px;bottom:-9px;
display:flex;gap:3px;
}
/* Diskrepanz-Marke des effektiven Status (SPEC §9, D39): die eigene
Statusbox in den eigenen §4-Farben (nutzt die .chip-Farbregeln der
Legende), unten links die letzte freie Knoten-Ecke. */
.node .chip.ownst{
position:absolute;left:-9px;bottom:-9px;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
padding:1px 5px;line-height:1.4;border-radius:999px;
}
.tag{
font-family:'IBM Plex Mono',monospace;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { effectiveStatus, progressRank } from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
const t = key => key;
const roots = txt => parse(txt).roots;
/* Map Label -> effektiver Status-Key (nur Diskrepanzen). */
const disc = txt => {
const m = effectiveStatus(roots(txt));
const out = {};
m.forEach((key, n) => { out[n.label] = key; });
return out;
};
const render = txt => {
const r = roots(txt);
return renderTreeHtml(r, {t, showDiscarded: false, cheapPath: false,
cheapSet: new Set(), effStatus: effectiveStatus(r)});
};
/* Effektiver Status (SPEC §4/§9, D39): Minimum des Fortschritts-Rangs über
die Abhängigkeits-Hülle; gemeldet werden nur Diskrepanzen. */
describe('effectiveStatus — Minimum über die Abhängigkeits-Hülle', () => {
it('hält einen fertigen Knoten auf dem Rang seiner Abhängigkeit', () => {
expect(disc(`[~] API #api\n[x] Deploy :#api`))
.toEqual({Deploy: 'arbeit'});
});
it('reicht das Minimum über Ketten durch', () => {
expect(disc(`[ ] C #c\n[/] B #b :#c\n[x] A :#b`))
.toEqual({A: 'geplant', B: 'geplant'});
});
it('meldet nichts, wenn die Abhängigkeit gleichauf oder weiter ist', () => {
expect(disc(`[x] API #api\n[x] Deploy :#api`)).toEqual({});
expect(disc(`[^] API #api\n[x] Deploy :#api`)).toEqual({});
});
it('lässt Zyklen ihr Minimum teilen — „wird gemeinsam fertig"', () => {
expect(disc(`[x] A #a :#b\n[x] B #b :#a`)).toEqual({});
expect(disc(`[x] A #a :#b\n[~] B #b :#a`)).toEqual({A: 'arbeit'});
expect(disc(`[x] A #a :#a`)).toEqual({}); /* Selbst-Abhängigkeit */
});
it('ignoriert unbekannte IDs (dafür gibt es die unknownDep-Warnung)', () => {
expect(disc(`[x] A :#gibtsnicht`)).toEqual({});
});
it('zählt neutrale und verworfene Ziele als Rang 0, High Risk als 1', () => {
expect(disc(`Neutral #n\n[x] A :#n`)).toEqual({A: 'idee'});
expect(disc(`[-] Weg #w\n[x] A :#w`)).toEqual({A: 'idee'});
expect(disc(`[!] Risiko #r\n[x] A :#r`)).toEqual({A: 'geplant'});
});
it('macht neutrale und verworfene Knoten selbst nie zur Diskrepanz', () => {
expect(disc(`[?] Ziel #z\nNeutral :#z\n`)).toEqual({});
expect(disc(`[?] Ziel #z\n[-] Weg :#z`)).toEqual({});
expect(progressRank(roots(`[-] Weg`)[0])).toBe(0);
});
it('löst Verweise auf die ERSTE Vergabe einer doppelten ID auf (D36/D39)', () => {
expect(disc(`[~] Erste #x\n[x] Zweite #x\n[x] A :#x`)).toEqual({A: 'arbeit'});
});
});
describe('Darstellung — Farbe effektiv, Marke intrinsisch', () => {
const TXT = `[~] API #api (M)\n[x] Deploy :#api`;
it('färbt den Knoten mit dem effektiven Status und markiert ihn als held', () => {
const {html} = render(TXT);
expect(html).toContain('class="node root-node held st-arbeit" tabindex="0" data-line="2"');
expect(html).not.toContain('st-fertig" tabindex="0" data-line="2"');
});
it('hängt die eigene Statusbox als Marke in den eigenen Farben an', () => {
const {html} = render(TXT);
expect(html).toContain('<span class="chip ownst st-fertig" aria-hidden="true">[x]</span>');
});
it('benennt beide Status in Tooltip und aria-label', () => {
const {html} = render(TXT);
expect(html).toContain('title="→ #api · heldTooltip · jumpHint"');
expect(html).toContain('aria-label="Deploy, a11yStatus, a11yEffective, a11yDeps"');
});
it('ändert Knoten ohne Diskrepanz nicht', () => {
const {html} = render(`[x] API #api\n[x] Deploy :#api`);
expect(html).not.toContain('ownst');
expect(html).not.toContain('held');
expect((html.match(/st-fertig/g) || []).length).toBe(2);
});
});