feat: der günstigste Pfad zeigt die offene Front (D46)
Der Pfad rechnete rein aus T-Shirt-Größen — `ownCost()` war `SIZE_RANK[size] + 1`, der Status kam in der Kostenrechnung überhaupt nicht vor (nur `[-]` flog heraus). Folge: Längst Erledigtes wurde voll eingepreist und lag weiter hell auf dem Pfad; im mitgelieferten Werkbaum-Plan zeichnete die Linie damit überwiegend fertige Arbeit nach. Und in einer Alternativgruppe hatte eine bereits realisierte Alternative keinerlei Kostenvorteil — der Pfad empfahl die nominell billigere, obwohl die Wahl längst getroffen und bezahlt ist. Ab jetzt kostet Erledigtes 0. Der Pfad beantwortet damit „was ist als Nächstes am günstigsten?" statt „was hätte der Plan von vorn gekostet?". - Schwelle bei `[x]` fertig: die Beförderung auf `[^]` ist keine Kostenfrage (das tut per D30 ein eigener Commit). - Angefangenes (`[~]`, `[/]`) zählt weiter voll — Bruchteile ordinaler Größen wären erfunden, und `[~]` heißt laut §4 gerade „Risiko hoch". - Maßgeblich ist der intrinsische Status: investiert ist investiert, auch wenn Abhängigkeiten den Knoten effektiv zurückhalten (D39). Verhindert nebenbei doppeltes Zählen — die Abhängigkeit steht mit eigenen Kosten selbst da. - Abgezogen werden nur die eigenen Kosten, nicht der Teilbaum. Darstellung: Erledigte bleiben `cheap` und behalten ihre volle Statusfarbe (grün/blau sagt schon „nichts mehr zu tun"), verlieren aber Stationspunkt und implizites M-Badge — Letzteres macht eine Kostenannahme sichtbar, und an einem erledigten Knoten wird keine getroffen. Station ist der tiefste noch OFFENE Knoten eines Zweigs (`hidesOpenCheap()` fragt den Teilbaum statt nur die direkten Kinder): Sind alle Kinder fertig, wird der offene Elternknoten selbst die Station. Der eingeklappte Knoten (D38) erbt dieselbe Verschärfung. Kein neuer Umschalter — der vorhandene ändert seine Bedeutung. Nachgemessen am Werkbaum-Plan: 110 Knoten auf dem Pfad (unverändert), Stationen 69 -> 24, und die 24 sind exakt die offene Front (Ticket-Referenzen, Öffnen/Speichern, Backend-Gerüst samt REST und Persistenz, Websocket-Transport, Text-CRDT, Mermaid-Layout, IDEA-Plugin). Linie wird weiter gezogen; Export und Druck folgen ohne Zusatzcode, sie lesen dieselbe `.node.cheap-leaf`. 236 Tests grün, davon 14 neue (tests/frontier.test.js). Der Snapshot des kanonischen Beispiels ändert sich um genau zwei Knoten. SPEC §9, llms.md und ROADMAP/TASKS nachgezogen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9069955895
commit
c793bf0038
@@ -74,6 +74,13 @@ One node per line. Everything except the label is optional.
|
||||
- No box = neutral node. `x` may also be uppercase `X`.
|
||||
- Unknown codes are tolerated: the node renders neutral, with a warning —
|
||||
the line is never lost.
|
||||
- **`[x]` and `[^]` cost nothing any more.** The cheapest path prices the
|
||||
work that is *left*, so a done node adds zero regardless of its size and
|
||||
carries no station on the path line; started work (`[~]`, `[/]`) still
|
||||
counts in full. A realized alternative therefore wins its `|`/`=` group
|
||||
even when a cheaper unstarted one sits next to it. The *intrinsic* status
|
||||
decides — dependencies may hold a node back effectively, but the work on
|
||||
it is paid for.
|
||||
|
||||
### Size (effort)
|
||||
|
||||
|
||||
+40
-13
@@ -49,8 +49,19 @@ export function pathChildren(n){
|
||||
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; }
|
||||
/* ---------- Erledigt: was nichts mehr kostet (SPEC §9, D46) ----------
|
||||
`[x]` fertig und `[^]` in Produktion. Die Beförderung auf Prod ist keine
|
||||
Kostenfrage (D30: das tut der Deploy), fertig ist also die Schwelle.
|
||||
Angefangenes (`[~]`, `[/]`) zählt bewusst voll: Die Arbeit ist noch offen,
|
||||
und Bruchteile ordinaler T-Shirt-Größen wären erfunden.
|
||||
Maßgeblich ist der INTRINSISCHE Status — investiert ist investiert, auch
|
||||
wenn Abhängigkeiten den Knoten effektiv zurückhalten (D39); deren eigene
|
||||
Kosten stehen ohnehin an ihnen selbst. */
|
||||
export function isDone(n){
|
||||
return !!n.status && (n.status.key === 'fertig' || n.status.key === 'prod');
|
||||
}
|
||||
/* fehlende Größe wird als M interpretiert; Erledigtes kostet nichts mehr */
|
||||
export function ownCost(n){ return isDone(n) ? 0 : SIZE_RANK[n.size || 'M'] + 1; }
|
||||
export function cheapestCost(n){
|
||||
const kids = pathChildren(n);
|
||||
let c = ownCost(n);
|
||||
@@ -185,19 +196,35 @@ export function hidesCheap(n, cheapSet){
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/* Dasselbe, aber nur für noch OFFENE Pfadarbeit (D46): Erledigtes zählt nicht
|
||||
mehr, es ist keine Station und hält auch keine unter sich. */
|
||||
export function hidesOpenCheap(n, cheapSet){
|
||||
for(const k of n.children || []){
|
||||
if((cheapSet.has(k) && !isDone(k)) || hidesOpenCheap(k, cheapSet)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cheapCls(n, cheapSet, collapsed){
|
||||
/* Eingeklappt steht der Knoten stellvertretend für seinen ganzen Teilbaum
|
||||
(SPEC §9/D38): Liegt darin etwas auf dem Pfad, ist er dessen tiefste noch
|
||||
SICHTBARE Station — sonst überspränge die Linie den Zweig, als wäre dort
|
||||
nichts zu tun. Das gilt auch, wenn er selbst nicht gebraucht wird, sein
|
||||
Teilbaum aber schon (eine per `:#…` gezogene Alternative, D42): Er ist
|
||||
dann der einzige sichtbare Griff auf nötige Arbeit und darf deshalb auch
|
||||
nicht von der Inversion ausgeblasst werden. */
|
||||
if(collapsed) return (cheapSet.has(n) || hidesCheap(n, cheapSet)) ? 'cheap cheap-leaf' : '';
|
||||
if(!cheapSet.has(n)) return '';
|
||||
const leaf = !pathChildren(n).some(k => cheapSet.has(k));
|
||||
return leaf ? 'cheap cheap-leaf' : 'cheap';
|
||||
/* Stationen sind die tiefsten noch OFFENEN Knoten des Pfads (D46). Ein
|
||||
erledigter Knoten bleibt `cheap` — er gehört zum Pfad und behält seine
|
||||
volle Statusfarbe —, trägt aber keinen Punkt und wird von der Linie
|
||||
übergangen; die zeigt den günstigsten noch zu gehenden Rest.
|
||||
Eingeklappt steht der Knoten stellvertretend für seinen ganzen Teilbaum
|
||||
(SPEC §9/D38): Liegt darin noch offene Pfadarbeit, ist er deren tiefste
|
||||
noch SICHTBARE Station — sonst überspränge die Linie den Zweig, als wäre
|
||||
dort nichts zu tun. Das gilt auch, wenn er selbst nicht gebraucht wird,
|
||||
sein Teilbaum aber schon (eine per `:#…` gezogene Alternative, D42): Er
|
||||
ist dann der einzige sichtbare Griff auf nötige Arbeit und darf deshalb
|
||||
auch nicht von der Inversion ausgeblasst werden. */
|
||||
const onPath = cheapSet.has(n);
|
||||
if(collapsed){
|
||||
if((onPath && !isDone(n)) || hidesOpenCheap(n, cheapSet)) return 'cheap cheap-leaf';
|
||||
return (onPath || hidesCheap(n, cheapSet)) ? 'cheap' : '';
|
||||
}
|
||||
if(!onPath) return '';
|
||||
if(isDone(n)) return 'cheap';
|
||||
return hidesOpenCheap(n, cheapSet) ? 'cheap' : 'cheap cheap-leaf';
|
||||
}
|
||||
|
||||
/* ---------- Effektiver Status (SPEC §4/§9, D39) ----------
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// Diskrepanzen (effectiveStatus() in model.js, D39)
|
||||
} */
|
||||
|
||||
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
|
||||
import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone } from './model.js';
|
||||
|
||||
/* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion"
|
||||
gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und
|
||||
@@ -68,7 +68,7 @@ function nodeAria(n, opts, fold){
|
||||
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'));
|
||||
else if(cheapPath && !isDone(n)) parts.push(t('a11ySizeImplicit'));
|
||||
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
|
||||
/* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung
|
||||
im Diagramm — sichtbar nur im Tooltip und hier. */
|
||||
@@ -131,10 +131,13 @@ function nodeHtml(n, extra, opts, fold){
|
||||
const tagsHtml = n.tags && n.tags.length
|
||||
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
/* Das implizite M-Badge macht eine KOSTENANNAHME sichtbar (D18). An einem
|
||||
erledigten Knoten wird keine getroffen — er kostet nichts mehr (D46) —,
|
||||
dort bleibt es deshalb weg. */
|
||||
const implicitTip = attr(t('implicitSizeTooltip'));
|
||||
const sizeBadge = n.size
|
||||
? `<span class="size" aria-hidden="true">${n.size}</span>`
|
||||
: (cheapPath ? `<span class="size implicit" aria-hidden="true" title="${implicitTip}">M</span>` : '');
|
||||
: (cheapPath && !isDone(n) ? `<span class="size implicit" aria-hidden="true" title="${implicitTip}">M</span>` : '');
|
||||
/* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken
|
||||
Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */
|
||||
const riskMark = n.status && n.status.key === 'highrisk'
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_fertig · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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="opt"><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" aria-expanded="true" title="st_geplant · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_idee · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_fertig · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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="opt"><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" aria-expanded="true" title="st_geplant · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_idee · jumpHint"><span class="fold" aria-hidden="true">▾</span>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 > 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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_fertig · jumpHint"><span class="fold" aria-hidden="true">▾</span>Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap 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 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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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="opt"><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" aria-expanded="true" title="st_geplant · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_idee · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_fertig · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_arbeit · jumpHint"><span class="fold" aria-hidden="true">▾</span>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="opt"><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" aria-expanded="true" title="st_geplant · jumpHint"><span class="fold" aria-hidden="true">▾</span>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" aria-expanded="true" title="st_idee · jumpHint"><span class="fold" aria-hidden="true">▾</span>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,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse } from '../src/parser.js';
|
||||
import {
|
||||
isDone, ownCost, cheapestCost, cheapCls, computeCheapSet,
|
||||
} from '../src/model.js';
|
||||
|
||||
/* Status-bewusster günstigster Pfad (SPEC §9, D46): Erledigtes kostet nichts
|
||||
mehr und ist keine Station; hervorgehoben wird der günstigste noch OFFENE
|
||||
Rest — die aktuelle Front. */
|
||||
|
||||
const roots = txt => parse(txt).roots;
|
||||
const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort();
|
||||
/* Stationen = Knoten mit 'cheap-leaf' (die Pfadlinie fädelt durch sie). Der
|
||||
Baum wird EINMAL geparst und beides daraus abgeleitet — `Set.has()` prüft
|
||||
auf Objektidentität, ein zweiter Parse-Durchlauf träfe nie (Falle aus D28). */
|
||||
function plan(txt){
|
||||
const rs = roots(txt);
|
||||
const set = computeCheapSet(rs);
|
||||
const st = [];
|
||||
(function walk(ns){
|
||||
for(const n of ns){
|
||||
if(cheapCls(n, set, false).includes('cheap-leaf')) st.push(n.label);
|
||||
walk(n.children);
|
||||
}
|
||||
})(rs);
|
||||
return { rs, set, stationen: st.sort(), aufPfad: [...set].map(n => n.label).sort() };
|
||||
}
|
||||
|
||||
describe('isDone — die Schwelle liegt bei „fertig"', () => {
|
||||
it('erkennt [x] und [^], sonst nichts', () => {
|
||||
const rs = roots(`[?] a
|
||||
[ ] b
|
||||
[!] c
|
||||
[~] d
|
||||
[/] e
|
||||
[x] f
|
||||
[^] g
|
||||
[-] h
|
||||
i`);
|
||||
expect(rs.map(isDone)).toEqual([false, false, false, false, false, true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kosten — Erledigtes zählt nicht mehr', () => {
|
||||
it('kostet 0, unabhängig von der Größe', () => {
|
||||
const [a, b, c] = roots(`[x] Fertig (XXL)
|
||||
[^] Prod (XL)
|
||||
[~] Arbeit (XS)`);
|
||||
expect(ownCost(a)).toBe(0);
|
||||
expect(ownCost(b)).toBe(0);
|
||||
expect(ownCost(c)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Angefangenes ([~], [/]) zählt weiterhin voll', () => {
|
||||
const [a, b] = roots(`[~] A (L)
|
||||
[ ] B (L)`);
|
||||
expect(ownCost(a)).toBe(ownCost(b));
|
||||
});
|
||||
|
||||
it('zählt den intrinsischen Status — Abhängigkeiten halten ihn nicht auf', () => {
|
||||
/* Effektiv ist A erst „geplant" (D39), investiert ist trotzdem investiert. */
|
||||
const [a] = roots(`[x] A (XL) :#b
|
||||
[ ] B #b (XS)`);
|
||||
expect(ownCost(a)).toBe(0);
|
||||
});
|
||||
|
||||
it('zieht nur die eigenen Kosten ab, nicht den Teilbaum', () => {
|
||||
const [n] = roots(`[x] Eltern (L)
|
||||
- [ ] Kind (S)`);
|
||||
expect(cheapestCost(n)).toBe(cheapestCost(roots('[ ] Kind (S)')[0]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auswahl — die getroffene Wahl gewinnt', () => {
|
||||
it('eine realisierte Alternative schlägt die nominell billigere', () => {
|
||||
/* Ohne Status-Bewusstsein gewönne „Billig" (XS gegen L). */
|
||||
expect(cheapLabels(`[ ] Wahl (XS)
|
||||
| [x] Gemacht (L)
|
||||
| [ ] Billig (XS)`)).toEqual(['Gemacht', 'Wahl']);
|
||||
});
|
||||
|
||||
it('eine erst angefangene Alternative gewinnt dadurch NICHT', () => {
|
||||
expect(cheapLabels(`[ ] Wahl (XS)
|
||||
| [~] Angefangen (L)
|
||||
| [ ] Billig (XS)`)).toEqual(['Billig', 'Wahl']);
|
||||
});
|
||||
|
||||
it('gilt auch in einer XOR-Gruppe', () => {
|
||||
expect(cheapLabels(`[ ] Wahl (XS)
|
||||
= [ ] Billig (XS)
|
||||
= [^] Live (XL)`)).toEqual(['Live', 'Wahl']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stationen — die Linie zeigt den offenen Rest', () => {
|
||||
it('erledigte Blätter tragen keinen Punkt', () => {
|
||||
const p = plan(`[ ] W (XS)
|
||||
- [x] Fertig (S)
|
||||
- [ ] Offen (S)`);
|
||||
expect(p.aufPfad).toEqual(['Fertig', 'Offen', 'W']); /* beide bleiben auf dem Pfad */
|
||||
expect(p.stationen).toEqual(['Offen']);
|
||||
});
|
||||
|
||||
it('ein offener Knoten mit lauter erledigten Kindern wird selbst zur Station', () => {
|
||||
/* Die Restarbeit ist dann die des Elternknotens — sonst hätte der Zweig
|
||||
gar keine Station, obwohl dort noch etwas offen ist. */
|
||||
const p = plan(`[~] Eltern (M)
|
||||
- [x] A (S)
|
||||
- [^] B (S)`);
|
||||
expect(p.stationen).toEqual(['Eltern']);
|
||||
});
|
||||
|
||||
it('ein erledigter Knoten mit offenem Kind bleibt Durchgang', () => {
|
||||
const p = plan(`[x] Eltern (M)
|
||||
- [ ] Kind (S)`);
|
||||
expect(p.stationen).toEqual(['Kind']);
|
||||
});
|
||||
|
||||
it('ein durchweg erledigter Baum hat keine Station mehr', () => {
|
||||
const p = plan(`[^] W (L)
|
||||
- [x] A (S)
|
||||
- [x] B (S)`);
|
||||
expect(p.stationen).toEqual([]);
|
||||
expect(p.aufPfad).toEqual(['A', 'B', 'W']); /* der Pfad selbst bleibt */
|
||||
});
|
||||
});
|
||||
|
||||
describe('Eingeklappt — der Knoten vertritt offene Arbeit, nicht erledigte', () => {
|
||||
it('vertritt einen Teilbaum mit offener Pfadarbeit als Station (D38)', () => {
|
||||
const rs = roots(`[ ] W (XS)
|
||||
- [ ] Zweig (M)
|
||||
- [ ] Tief (S)`);
|
||||
const set = computeCheapSet(rs);
|
||||
const zweig = rs[0].children[0];
|
||||
expect(cheapCls(zweig, set, true)).toBe('cheap cheap-leaf');
|
||||
});
|
||||
|
||||
it('bleibt ohne Punkt, wenn darunter alles erledigt ist', () => {
|
||||
const rs = roots(`[ ] W (XS)
|
||||
- [x] Zweig (M)
|
||||
- [x] Tief (S)`);
|
||||
const set = computeCheapSet(rs);
|
||||
const zweig = rs[0].children[0];
|
||||
expect(cheapCls(zweig, set, true)).toBe('cheap'); /* auf dem Pfad, aber nichts zu tun */
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user