feat: XOR-Gruppen (=) — genau eine Alternative (SPEC §3, D35)

Parser erkennt `=` als disjunktives Gate (nur mit folgendem Leerraum —
`=SUMME(…)` bleibt Label); Mischungen melden mixedGate wie gehabt. Die
XOR-Regel warnt je WEITERER realisierter Alternative (xorConflict mit
Zeilennummer; realisiert = [~]/[/]/[x]/[^], siehe D35). Darstellung erbt
die komplette any-of-Geometrie (ul class="or xor"), ergänzt um eine
„1"-Plakette am Austritt der Sammelleiste — auch im Grafikexport.
Legende (+ hint_root) und Warntext in allen 9 Sprachen; SPEC §11-Eintrag
in §1/§3/§9 überführt; 13 neue Tests (tests/xor.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 16:08:37 +02:00
co-authored by Claude Fable 5
parent 2754fa40f6
commit d276c94840
14 changed files with 291 additions and 61 deletions
+36
View File
@@ -1624,3 +1624,39 @@ der Kandidaten nötig gewesen.
nachdenken will (D32). nachdenken will (D32).
- **`°`** — fehlt auf US-Layouts, und der kleine Kreis kollidierte semantisch - **`°`** — fehlt auf US-Layouts, und der kleine Kreis kollidierte semantisch
mit dem hohlen Kreis, der im Diagramm bereits *optional* bedeutet (D29). mit dem hohlen Kreis, der im Diagramm bereits *optional* bedeutet (D29).
## D35 — XOR (`=`) umgesetzt: „realisiert“ definiert, „1“-Plakette, keine neue Linienart
Das in D34 entschiedene XOR-Gate ist gebaut (SPEC §1/§3/§9); beim Bauen waren
drei Dinge zu entscheiden, die die SPEC bis dahin offen ließ:
**„Realisiert“ heißt: Kosten investiert oder mehr** — Status `[~]`, `[/]`,
`[x]`, `[^]`. Die XOR-Regel („genau eine Alternative darf realisiert werden“)
soll genau dann anschlagen, wenn der Plan tatsächlich doppelt einkauft — und
das beginnt mit `[~]`: Wer an zwei Alternativen zugleich **arbeitet**, verletzt
das „genau eine“ bereits, nicht erst beim zweiten `[x]`. `[?]`, `[ ]` und `[!]`
sind Absicht ohne Investition, `[-]` ist die Entscheidung dagegen, neutral sagt
nichts — alle fünf zählen nicht. Gemeldet wird **jede weitere** realisierte
Alternative einzeln (Warnung `xorConflict` mit ihrer Zeilennummer und ihrem
Label): Die Meldung zeigt so auf die Zeile, die man ansehen muss, statt
pauschal auf die Gruppe. Bewusst eine **Warnung, kein Fehler** — die Notation
bleibt fehlertolerant (§4), der Baum wird unverändert dargestellt.
**Kennzeichnung: „1“-Plakette am Austritt der Sammelleiste.** Der D34-Nachtrag
hielt fest, dass `=` optisch bei der konjunktiven Familie steht und das
Diagramm die Lesart korrigieren muss. Die Linien übernehmen das schon
(gestrichelt grau wie any-of); die Plakette — kleiner weißer Kreis, graue
Ziffer, auf dem Leitungsstück zwischen Elternknoten und erstem Abzweig — sagt
das „genau eine“, das der Linienstil allein nicht sagen kann. Grau statt
Petrol, weil sie zur Gate-Codierung gehört (D15: keine Signalfarbe im
Diagramm); an der Sammelleiste statt am Knoten, weil sie eine Aussage über die
**Gruppe** ist und die Knoten-Ecken belegt sind (D18). Sie erscheint auch im
Grafikexport (dort nach den Knoten gezeichnet, wie der Optional-Kreis aus D29).
**Keine neue Linienart, keine neue CSS-Familie:** Der Renderer gibt
XOR-Gruppen als `<ul class="or xor">` aus — die gesamte any-of-Geometrie
(alle drei Modi, D18-Sonderfälle, Export-Routing) gilt damit automatisch;
`.xor` ergänzt nur die Plakette. Die Alternative — ein eigener Gruppentyp mit
kopierten Regeln — hätte jede künftige Layoutänderung doppelt pflegen lassen.
Im Modell ist `'xor'` ein eigener Gate-Wert (`gateOf`), damit die
`mixedGate`-Warnung Mischungen mit `|` von selbst meldet; alle
Disjunktiv-Abfragen prüfen `!== 'and'`.
+1 -1
View File
@@ -138,7 +138,7 @@ Pfad, der die Wahrheit sagt.
- **XOR** (`=`) — neben „mindestens eine" (`|`) eine Gruppe, in der **genau - **XOR** (`=`) — neben „mindestens eine" (`|`) eine Gruppe, in der **genau
eine** Alternative realisiert werden darf. Für die Pfadrechnung ändert das eine** Alternative realisiert werden darf. Für die Pfadrechnung ändert das
nichts (die wählt bei `|` ohnehin eine); es kommt eine Regel hinzu, die nichts (die wählt bei `|` ohnehin eine); es kommt eine Regel hinzu, die
verletzt werden kann und dann gemeldet wird. verletzt werden kann und dann gemeldet wird. **Umgesetzt** (SPEC §3, D35).
- **Knotenbeschreibungen** — Erläuterungstext zum Knoten, im Diagramm als - **Knotenbeschreibungen** — Erläuterungstext zum Knoten, im Diagramm als
Tooltip oder Pop-up: kurz direkt beim Knoten, lang als Block am Dokumentende Tooltip oder Pop-up: kurz direkt beim Knoten, lang als Block am Dokumentende
über die ID zugeordnet. Die Arbeit steckt nicht im Anzeigen, sondern in der über die ID zugeordnet. Die Arbeit steckt nicht im Anzeigen, sondern in der
+27 -31
View File
@@ -14,7 +14,8 @@ Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in
dieser Reihenfolge (wichtig für Kollisionsfreiheit): dieser Reihenfolge (wichtig für Kollisionsfreiheit):
1. Kommentar entfernen: alles ab `%%` bis Zeilenende. 1. Kommentar entfernen: alles ab `%%` bis Zeilenende.
2. Einrückung, Zeichen (`-` / `+` / `|`) und Statusbox `[…]` per Zeilen-Regex. 2. Einrückung, Zeichen (`-` / `+` / `|` / `=`) und Statusbox `[…]` per
Zeilen-Regex; `=` nur mit folgendem Leerraum (§3).
3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht).
4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal.
5. Tags: alle `@name`-Vorkommen. 5. Tags: alle `@name`-Vorkommen.
@@ -41,7 +42,7 @@ hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle.
Referenz-Regex der Implementierung: Referenz-Regex der Implementierung:
``` ```
^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$
``` ```
Für die Fokusmarke (Schritt 6): Für die Fokusmarke (Schritt 6):
@@ -55,8 +56,8 @@ Für die Fokusmarke (Schritt 6):
- Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite: - Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite:
Elternknoten ist die nächste vorangehende Zeile mit **kleinerer** Elternknoten ist die nächste vorangehende Zeile mit **kleinerer**
Einrückungsbreite (Tab zählt als 2 Leerzeichen). Einrückungsbreite (Tab zählt als 2 Leerzeichen).
- Zeilen ohne Zeichen (`-`/`+`/`|`) sind Wurzelknoten. Mehrere Wurzeln = mehrere - Zeilen ohne Zeichen (`-`/`+`/`|`/`=`) sind Wurzelknoten. Mehrere Wurzeln =
Bäume nebeneinander. mehrere Bäume nebeneinander.
## 3. Zerlegungsart (Gate) ## 3. Zerlegungsart (Gate)
@@ -65,14 +66,26 @@ Für die Fokusmarke (Schritt 6):
| `-` | all of (Und-Zerlegung) | Alle Teilpakete sind erforderlich. | | `-` | all of (Und-Zerlegung) | Alle Teilpakete sind erforderlich. |
| `+` | optional (Zugabe) | Einzelnes zusätzliches Teilpaket, nicht erforderlich. | | `+` | optional (Zugabe) | Einzelnes zusätzliches Teilpaket, nicht erforderlich. |
| `\|` | any of (Oder-Zerlegung) | Mindestens eine Alternative wird gewählt. | | `\|` | any of (Oder-Zerlegung) | Mindestens eine Alternative wird gewählt. |
| `=` | exactly one (XOR-Zerlegung) | Genau eine Alternative wird realisiert. |
- `-` und `|` sind Eigenschaften der **Geschwistergruppe**; `+` ist eine - `-`, `|` und `=` sind Eigenschaften der **Geschwistergruppe**; `+` ist eine
Eigenschaft des **einzelnen Knotens** (er hängt an derselben Und-Zerlegung, Eigenschaft des **einzelnen Knotens** (er hängt an derselben Und-Zerlegung,
ist darin aber entbehrlich). ist darin aber entbehrlich).
- Daraus folgt die Mischregel: Eine Gruppe ist entweder **konjunktiv** — dann - Daraus folgt die Mischregel: Eine Gruppe ist entweder **konjunktiv** — dann
dürfen `-` und `+` frei nebeneinander stehen — oder **disjunktiv** (`|`). dürfen `-` und `+` frei nebeneinander stehen — oder **disjunktiv**
`|` mit `-`/`+` zu mischen ist ungültig: Darstellung nach dem **ersten** Kind, (einheitlich `|` oder einheitlich `=`). Jede andere Mischung ist ungültig:
plus Warnung `mixedGate` mit Zeilennummer. Darstellung nach dem **ersten** Kind, plus Warnung `mixedGate` mit
Zeilennummer.
- **Leerraum-Regel:** Als Gate wird `=` nur mit **folgendem Leerraum** erkannt —
ein Label wie `=SUMME(A1:B2)` bleibt damit ein Label. `=` ist das einzige
Gate, das diese Regel braucht; für `-`/`+`/`|` ändert sich nichts.
- **XOR-Regel:** In einer `=`-Gruppe darf genau **eine** Alternative realisiert
werden. **Realisiert** heißt: Kosten sind investiert oder mehr — Status `[~]`,
`[/]`, `[x]` oder `[^]` (§4); `[?]`, `[ ]`, `[!]`, `[-]` und neutrale Knoten
zählen nicht. Jede **weitere** realisierte Alternative ergibt eine Warnung
`xorConflict` mit ihrer Zeilennummer. Die Regel ist verletzbar, kein
Parse-Fehler: Der Baum wird unverändert dargestellt. Für den günstigsten
Pfad (§9) verhält sich `=` wie `|` — der wählt ohnehin genau eine.
- Ein `+`-Knoten zerlegt sich weiter wie jeder andere; das Gate seiner eigenen - Ein `+`-Knoten zerlegt sich weiter wie jeder andere; das Gate seiner eigenen
Kinder ist davon unabhängig. Optionalität vererbt sich nicht ausdrücklich — Kinder ist davon unabhängig. Optionalität vererbt sich nicht ausdrücklich —
wer unter einem `+`-Knoten hängt, ist mit ihm zusammen entbehrlich. wer unter einem `+`-Knoten hängt, ist mit ihm zusammen entbehrlich.
@@ -155,6 +168,12 @@ nebeneinander (schmales Diagramm rechts).
ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die
**Anordnung**, nicht die Linienfarbe. **Anordnung**, nicht die Linienfarbe.
**XOR-Gruppen (`=`, §3)** werden wie any-of gezeichnet (gestrichelt in Grau,
gleiche Anordnung). Zusätzlich sitzt am **Austritt der Sammelleiste** — auf dem
Stück zwischen Elternknoten und erstem Abzweig — eine kleine **„1“-Plakette**
(weißer Kreis mit grauem Rand, graue Ziffer): „genau eine“. Sie erscheint auch
im Grafikexport. Siehe D35.
**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Zerlegung, **Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Zerlegung,
die Anordnung bleibt unverändert. Zwei Kennzeichen, beide auch im Grafikexport: die Anordnung bleibt unverändert. Zwei Kennzeichen, beide auch im Grafikexport:
@@ -596,29 +615,6 @@ die Rechnung schwerer als heute — siehe D34.
Zerlegungszeichen — und ob eingeklappte Teilbäume in Grafikexport und Druck Zerlegungszeichen — und ob eingeklappte Teilbäume in Grafikexport und Druck
eingeklappt bleiben. eingeklappt bleiben.
### XOR — genau eine Alternative (`=`)
Neben `-` (all of), `+` (Zugabe) und `|` (any of, **mindestens** eine) soll es
eine Gruppe geben, in der **genau eine** Alternative realisiert werden darf.
Zeichen: **`=`**, an derselben Stelle wie die übrigen Zerlegungszeichen
(entschieden; Begründung und verworfene Kandidaten: D34-Nachtrag).
- Für den günstigsten Pfad ändert das nichts: Der wählt bei `|` ohnehin genau
eine (§9). XOR fügt eine **Regel** hinzu, die verletzt werden kann — zwei
realisierte Alternativen ergeben eine Warnung.
- `=` ist **disjunktiv** wie `|`; die Mischregel aus §3 gilt entsprechend:
Eine Gruppe ist einheitlich `-`/`+`, einheitlich `|` oder einheitlich `=`
jede Mischung mit `=` ist ungültig (`mixedGate`). Auch die Darstellung folgt
any-of (gestrichelt in Grau); wie die Gruppe zusätzlich als „genau eine"
gekennzeichnet wird, ist offen (z. B. eine kleine „1" an der Sammelleiste).
- **Leerraum-Regel:** Als Gate wird `=` nur mit **folgendem Leerraum**
erkannt — ein Label wie `=SUMME(A1:B2)` bleibt damit ein Label. `=` ist das
erste Gate, das diese Regel braucht; für `-`/`+`/`|` ändert sich nichts.
- Das ursprünglich vorgeschlagene `x` ist verworfen: `x [x] …` stünde als
Endzustand jeder entschiedenen Gruppe **dauerhaft** im Text (genau eine
Alternative soll ja fertig werden), und als Buchstabe kollidierte es ohne
Leerraum-Regel mit gewöhnlichen Labels (`XSS-Schutz` → Gate `X`).
### Knotenbeschreibungen ### Knotenbeschreibungen
Erläuternder Text zu einem Knoten, im Diagramm als Tooltip oder Pop-up. Erläuternder Text zu einem Knoten, im Diagramm als Tooltip oder Pop-up.
+6 -2
View File
@@ -82,8 +82,12 @@ entscheiden, **bevor** Code entsteht.
- [ ] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär); - [ ] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär);
bei ausgewähltem Knoten ein-/ausgehende hervorheben. bei ausgewähltem Knoten ein-/ausgehende hervorheben.
- [ ] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm. - [ ] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm.
- [ ] XOR-Gruppe (`=`) parsen (nur mit folgendem Leerraum, Mischung → - [x] XOR-Gruppe (`=`) parsen (nur mit folgendem Leerraum, Mischung →
`mixedGate`), zwei realisierte Alternativen melden. `mixedGate`), zwei realisierte Alternativen melden.
→ Zeichen entschieden: `=` statt `x` (D34-Nachtrag). → Zeichen entschieden: `=` statt `x` (D34-Nachtrag). Umgesetzt (D35):
„realisiert“ = `[~]`/`[/]`/`[x]`/`[^]`, Warnung `xorConflict` je weiterer
realisierter Alternative; Darstellung als any-of plus „1“-Plakette an der
Sammelleiste (auch im Grafikexport); Legende + i18n in 9 Sprachen;
`tests/xor.test.js`.
- [ ] Knotenbeschreibungen: Schreibweise für kurze und lange Form festlegen - [ ] Knotenbeschreibungen: Schreibweise für kurze und lange Form festlegen
(Einrückung ist bereits Hierarchie), dann Tooltip/Pop-up. (Einrückung ist bereits Hierarchie), dann Tooltip/Pop-up.
+1 -1
View File
@@ -17,7 +17,7 @@
- [?] Effective status: intrinsic, plus what it depends on (S) - [?] Effective status: intrinsic, plus what it depends on (S)
- [?] Cycles are legal — they mean "finished together" (XS) - [?] Cycles are legal — they mean "finished together" (XS)
- [ ] An id with no node behind it is a warning (XS) - [ ] An id with no node behind it is a warning (XS)
- [?] XOR, = — exactly one alternative, not at least one (S) - [x] XOR, = — exactly one alternative, not at least one (S)
- [?] Node descriptions, shown as a tooltip or pop-up (M) - [?] Node descriptions, shown as a tooltip or pop-up (M)
- [?] A short text right at the node (S) %% indentation already means hierarchy - [?] A short text right at the node (S) %% indentation already means hierarchy
- [?] A long block at the end, addressed by its id (S) - [?] A long block at the end, addressed by its id (S)
+8
View File
@@ -266,6 +266,14 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
`freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`, `freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
fortgeschrieben **erst beim Bestätigen** über `#freshBtn`. fortgeschrieben **erst beim Bestätigen** über `#freshBtn`.
- 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
Modi, D18-Sonderfälle, Export-Routing) automatisch gilt — `.xor` ergänzt nur
die „1"-Plakette (`ul.xor::after`, im Export `xorMarks`). Disjunktiv-Abfragen
auf `gateOf` prüfen `!== 'and'`, nie `=== 'or'`. Die `xorConflict`-Warnung
(mehr als eine realisierte Alternative: `[~]`/`[/]`/`[x]`/`[^]`) entsteht im
**Parser** (Post-Pass), nicht im Renderer.
- Optionale Knoten `+` (D29): Der Parser setzt **`optional:true` und lässt - Optionale Knoten `+` (D29): Der Parser setzt **`optional:true` und lässt
`type:'and'`** — `+` gehört zum Knoten, nicht zur Gruppe. Deshalb bleiben `type:'and'`** — `+` gehört zum Knoten, nicht zur Gruppe. Deshalb bleiben
`gateOf()` und die `mixedGate`-Warnung unverändert richtig (sie meldet nur `gateOf()` und die `mixedGate`-Warnung unverändert richtig (sie meldet nur
+2 -1
View File
@@ -103,7 +103,8 @@
<code>+</code>&nbsp; Zugabe, nicht erforderlich <em>(optional)</em><br> <code>+</code>&nbsp; Zugabe, nicht erforderlich <em>(optional)</em><br>
<code>!!!</code>&nbsp; hierhin schauen (gemeinsamer Zeigefinger)<br> <code>!!!</code>&nbsp; hierhin schauen (gemeinsamer Zeigefinger)<br>
<code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br> <code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br>
Zeile ohne Zeichen = Wurzelknoten. <code class="or-code">|</code> nicht mit <code>-</code>/<code>+</code> mischen.<br> <code class="or-code">=</code>&nbsp; Alternative, genau eine <em>(xor)</em><br>
Zeile ohne Zeichen = Wurzelknoten. <code class="or-code">|</code>, <code class="or-code">=</code> und <code>-</code>/<code>+</code> nicht mischen.<br>
Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>: Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>:
<div class="chips"> <div class="chips">
<span class="chip st-idee">[?] Idee</span> <span class="chip st-idee">[?] Idee</span>
+41 -9
View File
@@ -288,11 +288,16 @@ function diagramToSvg(){
sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb
überdecken. */ überdecken. */
const optMarks = []; const optMarks = [];
/* 1"-Plaketten der XOR-Gruppen (`=`, SPEC §9/D35) wie die Optional-Kreise
erst NACH den Knoten gezeichnet, sitzen aber auf der Leitung, nie auf
einer Knotenkante. */
const xorMarks = [];
nodes.forEach(parentEl => { nodes.forEach(parentEl => {
const li = parentEl.closest('li'); const li = parentEl.closest('li');
const childUl = li && [...li.children].find(c => c.tagName === 'UL'); const childUl = li && [...li.children].find(c => c.tagName === 'UL');
if(!childUl) return; if(!childUl) return;
const gate = childUl.classList.contains('or') ? 'or' : 'and'; const gate = childUl.classList.contains('or') ? 'or' : 'and';
const isXor = childUl.classList.contains('xor');
const stroke = gate === 'or' ? '#6B7A8C' : '#41556E'; const stroke = gate === 'or' ? '#6B7A8C' : '#41556E';
const dash = gate === 'or'; const dash = gate === 'or';
/* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder /* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder
@@ -324,6 +329,7 @@ function diagramToSvg(){
const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14; const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14;
const ys = kids.map(k=>k.cy).concat(p.cy); const ys = kids.map(k=>k.cy).concat(p.cy);
parts.push(seg(px, p.cy, busX, p.cy, stroke, dash)); parts.push(seg(px, p.cy, busX, p.cy, stroke, dash));
if(isXor) xorMarks.push({x:(px+busX)/2, y:p.cy});
parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash));
kids.forEach((k, i) => { kids.forEach((k, i) => {
const x = toRight ? k.x : k.r, o = isOpt(i); const x = toRight ? k.x : k.r, o = isOpt(i);
@@ -336,6 +342,7 @@ function diagramToSvg(){
const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14; const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14;
const xs = kids.map(k=>k.cx).concat(p.cx); const xs = kids.map(k=>k.cx).concat(p.cx);
parts.push(seg(p.cx, py, p.cx, busY, stroke, dash)); parts.push(seg(p.cx, py, p.cx, busY, stroke, dash));
if(isXor) xorMarks.push({x:p.cx, y:(py+busY)/2});
parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash));
kids.forEach((k, i) => { kids.forEach((k, i) => {
const y = toDown ? k.y : k.b, o = isOpt(i); const y = toDown ? k.y : k.b, o = isOpt(i);
@@ -393,6 +400,12 @@ function diagramToSvg(){
optMarks.forEach(m => parts.push( 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"/>`)); `<circle cx="${m.x.toFixed(1)}" cy="${m.y.toFixed(1)}" r="4" fill="#ffffff" stroke="#41556E" stroke-width="2"/>`));
/* 3a) XOR-Gruppen: „1"-Plakette am Austritt der Sammelleiste (D35) */
xorMarks.forEach(m => {
parts.push(`<circle cx="${m.x.toFixed(1)}" cy="${m.y.toFixed(1)}" r="6.5" fill="#ffffff" stroke="#6B7A8C" stroke-width="1.5"/>`);
parts.push(`<text x="${m.x.toFixed(1)}" y="${(m.y+3).toFixed(1)}" text-anchor="middle" fill="#6B7A8C" font-size="9" font-weight="600">1</text>`);
});
/* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */ /* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */
if(cheapPts.length >= 2){ if(cheapPts.length >= 2){
parts.push(cheapLine('0.2')); parts.push(cheapLine('0.2'));
@@ -1088,6 +1101,7 @@ const I18N = {
zoomAria:"Zoom (Strg/Cmd + Mausrad)", langMore:"weitere Sprachen", zoomAria:"Zoom (Strg/Cmd + Mausrad)", langMore:"weitere Sprachen",
empty:"Noch keine Struktur — einfach lostippen.", ghost:"…", empty:"Noch keine Struktur — einfach lostippen.", ghost:"…",
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.", mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
xorConflictWarn:"Zeile {line}: „{label}“ ist eine weitere realisierte Alternative — eine =-Gruppe erlaubt genau eine.",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
@@ -1096,9 +1110,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_xor:"Alternative, genau eine",
hint_opt:"Zugabe, nicht erforderlich", hint_opt:"Zugabe, nicht erforderlich",
hint_focus:"hierhin schauen (gemeinsamer Zeigefinger)", hint_focus:"hierhin schauen (gemeinsamer Zeigefinger)",
hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.", hint_root:"Zeile ohne Zeichen = Wurzelknoten. |, = und - / + nicht mischen.",
hint_status:"Status als Kästchen nach dem Zeichen, z. B.", 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_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.", hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
@@ -1152,6 +1167,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + mouse wheel)", langMore:"more languages", zoomAria:"Zoom (Ctrl/Cmd + mouse wheel)", langMore:"more languages",
empty:"No structure yet — just start typing.", ghost:"…", empty:"No structure yet — just start typing.", ghost:"…",
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.", mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
xorConflictWarn:"Line {line}: “{label}” is another realized alternative — an = group allows exactly one.",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
@@ -1160,9 +1176,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_xor:"alternative, exactly one",
hint_opt:"extra, not required", hint_opt:"extra, not required",
hint_focus:"look here (a shared pointer)", hint_focus:"look here (a shared pointer)",
hint_root:"Line without a marker = root node. Do not mix | with - / +.", hint_root:"Line without a marker = root node. Do not mix |, = and - / +.",
hint_status:"Status as a checkbox after the marker, e.g.", 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_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.", hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
@@ -1216,6 +1233,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + rueda del ratón)", langMore:"más idiomas", zoomAria:"Zoom (Ctrl/Cmd + rueda del ratón)", langMore:"más idiomas",
empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…", empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…",
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.", mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
xorConflictWarn:"Línea {line}: «{label}» es otra alternativa realizada — un grupo = permite exactamente una.",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", 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.", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
@@ -1224,9 +1242,10 @@ const I18N = {
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", 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_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_xor:"alternativa, exactamente una",
hint_opt:"extra, no obligatorio", hint_opt:"extra, no obligatorio",
hint_focus:"mirar aquí (un puntero compartido)", hint_focus:"mirar aquí (un puntero compartido)",
hint_root:"Línea sin marcador = nodo raíz. No mezcles | con - / +.", hint_root:"Línea sin marcador = nodo raíz. No mezcles |, = y - / +.",
hint_status:"Estado como casilla tras el marcador, p. ej.", 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_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.", hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
@@ -1280,6 +1299,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + molette)", langMore:"plus de langues", zoomAria:"Zoom (Ctrl/Cmd + molette)", langMore:"plus de langues",
empty:"Pas encore de structure — commencez à taper.", ghost:"…", empty:"Pas encore de structure — commencez à taper.", ghost:"…",
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.", mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
xorConflictWarn:"Ligne {line} : « {label} » est une alternative réalisée de plus — un groupe = nen autorise quune seule.",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", 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.", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
@@ -1288,9 +1308,10 @@ const I18N = {
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", 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_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_xor:"alternative, exactement une",
hint_opt:"supplément, non requis", hint_opt:"supplément, non requis",
hint_focus:"regarder ici (un pointeur partagé)", hint_focus:"regarder ici (un pointeur partagé)",
hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas | avec - / +.", hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas |, = et - / +.",
hint_status:"Statut sous forme de case après le marqueur, p. ex.", 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_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.", hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
@@ -1344,6 +1365,7 @@ const I18N = {
zoomAria:"Powiększenie (Ctrl/Cmd + kółko myszy)", langMore:"więcej języków", zoomAria:"Powiększenie (Ctrl/Cmd + kółko myszy)", langMore:"więcej języków",
empty:"Brak struktury — zacznij pisać.", ghost:"…", empty:"Brak struktury — zacznij pisać.", ghost:"…",
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.", mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
xorConflictWarn:"Wiersz {line}: „{label}” to kolejna zrealizowana alternatywa — grupa = dopuszcza dokładnie jedną.",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
@@ -1352,9 +1374,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", 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}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_xor:"alternatywa, dokładnie jedna",
hint_opt:"dodatek, niewymagany", hint_opt:"dodatek, niewymagany",
hint_focus:"spójrz tutaj (wspólny wskaźnik)", hint_focus:"spójrz tutaj (wspólny wskaźnik)",
hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj | z - / +.", hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj |, = i - / +.",
hint_status:"Status jako pole wyboru po znaczniku, np.", 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_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.", hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
@@ -1408,6 +1431,7 @@ const I18N = {
zoomAria:"Масштаб (Ctrl/Cmd + колесо мыши)", langMore:"ещё языки", zoomAria:"Масштаб (Ctrl/Cmd + колесо мыши)", langMore:"ещё языки",
empty:"Пока нет структуры — просто начните печатать.", ghost:"…", empty:"Пока нет структуры — просто начните печатать.", ghost:"…",
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.", mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
xorConflictWarn:"Строка {line}: «{label}» — ещё одна реализованная альтернатива, а группа = допускает ровно одну.",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -1416,9 +1440,10 @@ const I18N = {
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна",
hint_opt:"дополнение, не обязательно", hint_opt:"дополнение, не обязательно",
hint_focus:"смотрите здесь (общая указка)", hint_focus:"смотрите здесь (общая указка)",
hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.", hint_root:"Строка без маркера = корневой узел. Не смешивайте |, = и - / +.",
hint_status:"Статус в виде флажка после маркера, напр.", hint_status:"Статус в виде флажка после маркера, напр.",
hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:",
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
@@ -1472,6 +1497,7 @@ const I18N = {
zoomAria:"ज़ूम (Ctrl/Cmd + माउस-व्हील)", langMore:"और भाषाएँ", zoomAria:"ज़ूम (Ctrl/Cmd + माउस-व्हील)", langMore:"और भाषाएँ",
empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…", empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…",
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।", mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
xorConflictWarn:"पंक्ति {line}: „{label}“ एक और साकार विकल्प है — = समूह में केवल एक की अनुमति है।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -1480,9 +1506,10 @@ const I18N = {
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक",
hint_opt:"अतिरिक्त, आवश्यक नहीं", hint_opt:"अतिरिक्त, आवश्यक नहीं",
hint_focus:"यहाँ देखें (साझा संकेतक)", hint_focus:"यहाँ देखें (साझा संकेतक)",
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।", hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। |, = और - / + को आपस में न मिलाएँ।",
hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे", hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे",
hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:",
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
@@ -1536,6 +1563,7 @@ const I18N = {
zoomAria:"缩放(Ctrl/Cmd + 鼠标滚轮)", langMore:"更多语言", zoomAria:"缩放(Ctrl/Cmd + 鼠标滚轮)", langMore:"更多语言",
empty:"还没有结构——直接开始输入吧。", ghost:"…", empty:"还没有结构——直接开始输入吧。", ghost:"…",
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。", mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
xorConflictWarn:"第 {line} 行:「{label}」是又一个已实现的备选项——= 组只允许恰好一个。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -1544,9 +1572,10 @@ const I18N = {
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个",
hint_opt:"附加项,非必需", hint_opt:"附加项,非必需",
hint_focus:"看这里(共享的指针)", hint_focus:"看这里(共享的指针)",
hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。", hint_root:"无标记的行 = 根节点。请勿混用 |、= 与 - / +。",
hint_status:"在标记后用方框表示状态,例如", hint_status:"在标记后用方框表示状态,例如",
hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:",
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
@@ -1600,6 +1629,7 @@ const I18N = {
zoomAria:"ズーム(Ctrl/Cmd + マウスホイール)", langMore:"その他の言語", zoomAria:"ズーム(Ctrl/Cmd + マウスホイール)", langMore:"その他の言語",
empty:"まだ構造がありません — 入力を始めてください。", ghost:"…", empty:"まだ構造がありません — 入力を始めてください。", ghost:"…",
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。", mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
xorConflictWarn:"{line} 行目:「{label}」も実現済みの選択肢です — = グループで実現できるのは 1 つだけです。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
@@ -1608,9 +1638,10 @@ const I18N = {
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ",
hint_opt:"追加、必須ではない", hint_opt:"追加、必須ではない",
hint_focus:"ここを見る(共有の指さし)", hint_focus:"ここを見る(共有の指さし)",
hint_root:"マーカーのない行 = ルートノード。|- / + 混在させないでください。", hint_root:"マーカーのない行 = ルートノード。|・=・- / + 混在させないでください。",
hint_status:"マーカーの後にチェックボックスで状態、例:", hint_status:"マーカーの後にチェックボックスで状態、例:",
hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:",
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
@@ -1631,6 +1662,7 @@ function buildHint(){
<code>-</code>&nbsp; ${esc(t('hint_all'))} <em>(all of)</em><br> <code>-</code>&nbsp; ${esc(t('hint_all'))} <em>(all of)</em><br>
<code>+</code>&nbsp; ${esc(t('hint_opt'))} <em>(optional)</em><br> <code>+</code>&nbsp; ${esc(t('hint_opt'))} <em>(optional)</em><br>
<code class="or-code">|</code>&nbsp; ${esc(t('hint_any'))} <em>(any of)</em><br> <code class="or-code">|</code>&nbsp; ${esc(t('hint_any'))} <em>(any of)</em><br>
<code class="or-code">=</code>&nbsp; ${esc(t('hint_xor'))} <em>(xor)</em><br>
${esc(t('hint_root'))}<br> ${esc(t('hint_root'))}<br>
${esc(t('hint_status'))} <code>- [~] Frontend</code>: ${esc(t('hint_status'))} <code>- [~] Frontend</code>:
<div class="chips"> <div class="chips">
+10 -6
View File
@@ -5,10 +5,14 @@
import { SIZE_RANK } from './parser.js'; import { SIZE_RANK } from './parser.js';
/* Gate der Geschwistergruppe: 'or', wenn das erste Kind '|' trägt, sonst 'and' /* Gate der Geschwistergruppe nach dem ERSTEN Kind (SPEC §3): 'or' (`|`),
(SPEC §3 Darstellung nach dem ersten Kind). */ 'xor' (`=`) oder 'and' (`-`/`+`). 'xor' bleibt ein eigener Wert, damit die
mixedGate-Warnung Mischungen mit `|` meldet; wer nur konjunktiv/disjunktiv
unterscheidet, prüft `!== 'and'` (D35). */
export function gateOf(children){ export function gateOf(children){
return children.length && children[0].type === 'or' ? 'or' : 'and'; if(!children.length) return 'and';
const t = children[0].type;
return t === 'or' || t === 'xor' ? t : 'and';
} }
/* Untergliederungspflicht ab Größe M ohne Kinder (SPEC §5); verworfene nie. */ /* Untergliederungspflicht ab Größe M ohne Kinder (SPEC §5); verworfene nie. */
@@ -25,7 +29,7 @@ export function visibleChildren(n, showDiscarded){
/* ---------- Günstigster Pfad (D18) ---------- /* ---------- Günstigster Pfad (D18) ----------
Nötige Knoten für die günstigste Realisierung: all-of alle Kinder, Nötige Knoten für die günstigste Realisierung: all-of alle Kinder,
any-of nur die günstigste Alternative. Günstig" = kleinste rekursive any-of und XOR nur die günstigste Alternative. Günstig" = kleinste rekursive
Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie
mit (unabhängig vom Einblenden-Toggle). Gleichstand erste. Fehlende mit (unabhängig vom Einblenden-Toggle). Gleichstand erste. Fehlende
Größe = M. Größe = M.
@@ -43,7 +47,7 @@ export function cheapestCost(n){
const kids = pathChildren(n); const kids = pathChildren(n);
let c = ownCost(n); let c = ownCost(n);
if(kids.length){ if(kids.length){
if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost)); if(gateOf(kids) !== 'and') c += Math.min(...kids.map(cheapestCost));
else c += kids.reduce((s, k) => s + cheapestCost(k), 0); else c += kids.reduce((s, k) => s + cheapestCost(k), 0);
} }
return c; return c;
@@ -52,7 +56,7 @@ export function markCheapest(n, set){
set.add(n); set.add(n);
const kids = pathChildren(n); const kids = pathChildren(n);
if(!kids.length) return; if(!kids.length) return;
if(gateOf(kids) === 'or'){ if(gateOf(kids) !== 'and'){
let best = null, bc = Infinity; let best = null, bc = Infinity;
for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } }
if(best) markCheapest(best, set); if(best) markCheapest(best, set);
+31 -6
View File
@@ -19,14 +19,19 @@ export const STATUS_BY_CODE = {
'!': {key:'highrisk', name:'High Risk Aufwand unklar'} '!': {key:'highrisk', name:'High Risk Aufwand unklar'}
}; };
/* Status, die als realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind
investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und
neutrale Knoten zählen nicht. */
const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }. /* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or', optional, status, url, size, tags, Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size,
focus, children, line}. tags, focus, children, line}.
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3) `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
Gemischt-Warnung unverändert richtig sie schlägt nur an, wenn `|` mit Gemischt-Warnung unverändert richtig sie schlägt an, wenn `|` oder `=`
`-`/`+` gemischt wird. mit `-`/`+` (oder untereinander) gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen); -> Tags -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */ Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
@@ -41,9 +46,11 @@ export function parse(text){
/* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der /* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
(fehlertolerant: die Zeile geht nicht verloren). */ (fehlertolerant: die Zeile geht nicht verloren). */
const m = raw.match(/^([ \t]*)([-|+])?\s*(?:\[([^\]])\]\s*)?(.*)$/); /* `=` (XOR, SPEC §3) nur mit folgendem Leerraum die Leerraum-Regel hält
Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher. */
const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([^\]])\]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length; const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and'; const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and';
const optional = m[2] === '+'; const optional = m[2] === '+';
const boxChar = m[3]; // undefined, wenn keine Statusbox const boxChar = m[3]; // undefined, wenn keine Statusbox
@@ -74,5 +81,23 @@ export function parse(text){
stack.push({node, width}); stack.push({node, width});
}); });
/* XOR-Regel (SPEC §3): In einer `=`-Gruppe darf genau EINE Alternative
realisiert sein. Jede weitere wird einzeln gemeldet die Warnung zeigt so
auf die Zeile, die man ansehen muss, statt pauschal auf die Gruppe (D35).
Gruppen-Gate nach dem ersten Kind, wie in der Darstellung (§3). */
(function checkXor(node){
const kids = node.children;
if(kids.length && kids[0].type === 'xor'){
let realized = 0;
for(const k of kids){
if(k.status && REALIZED.has(k.status.key)){
realized++;
if(realized > 1) warnings.push({type:'xorConflict', line:k.line, label:k.label});
}
}
}
kids.forEach(checkXor);
})(virtualRoot);
return {roots: virtualRoot.children, warnings}; return {roots: virtualRoot.children, warnings};
} }
+8 -4
View File
@@ -28,7 +28,7 @@ function extraCls(n, opts){
Abzweigs). Leere Liste gar kein Attribut. */ Abzweigs). Leere Liste gar kein Attribut. */
function liClass(visibleKids, opts, optional){ function liClass(visibleKids, opts, optional){
const cls = [ const cls = [
visibleKids.length ? (gateOf(visibleKids) === 'or' ? 'has-or' : 'has-and') : '', visibleKids.length ? (gateOf(visibleKids) !== 'and' ? 'has-or' : 'has-and') : '',
optional ? 'opt' : '' optional ? 'opt' : ''
].filter(Boolean); ].filter(Boolean);
return cls.length ? ` class="${cls.join(' ')}"` : ''; return cls.length ? ` class="${cls.join(' ')}"` : '';
@@ -103,14 +103,18 @@ function renderChildren(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded); const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return ''; if(!kids.length) return '';
/* Gemischte Gates (SPEC §3): Da `+` nur `optional` setzt und `type:'and'` /* Gemischte Gates (SPEC §3): Da `+` nur `optional` setzt und `type:'and'`
behält, schlägt das hier weiterhin genau dann an, wenn `|` mit `-`/`+` behält, schlägt das hier genau dann an, wenn `|` oder `=` mit `-`/`+`
gemischt wird `-` neben `+` ist erlaubt und still. */ (oder untereinander) gemischt wird `-` neben `+` ist erlaubt und still. */
const types = new Set(kids.map(k => k.type)); const types = new Set(kids.map(k => k.type));
if(types.size > 1){ if(types.size > 1){
/* strukturierte Warnung (Typ + Zeile); Formatierung in warnings.js */ /* strukturierte Warnung (Typ + Zeile); Formatierung in warnings.js */
warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label}); warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label});
} }
const gate = gateOf(kids); const gate = gateOf(kids);
/* XOR-Gruppen (`=`, SPEC §3/§9) erben die komplette any-of-Geometrie über
die Klasse `or` (alle Modi, Export-Routing); `xor` ergänzt nur die
1"-Plakette an der Sammelleiste (D35). */
const ulCls = gate === 'xor' ? 'or xor' : gate;
const items = kids.map(k => { const items = kids.map(k => {
const vk = visibleChildren(k, opts.showDiscarded); const vk = visibleChildren(k, opts.showDiscarded);
/* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird /* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
@@ -121,7 +125,7 @@ function renderChildren(node, warnings, opts){
renderChildren(k, warnings, opts) + renderChildren(k, warnings, opts) +
`</li>`; `</li>`;
}).join(''); }).join('');
return `<ul class="${gate}">${items}</ul>`; return `<ul class="${ulCls}">${items}</ul>`;
} }
/* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und /* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und
+14
View File
@@ -722,6 +722,20 @@
ul.or::before{ ul.or::before{
content:'';position:absolute;top:0;left:9px;height:14px;border-left:2px dashed var(--muted); content:'';position:absolute;top:0;left:9px;height:14px;border-left:2px dashed var(--muted);
} }
/* XOR (`=`, SPEC §3/§9, D35): Geometrie kommt komplett von `ul.or` (der
Renderer gibt `class="or xor"` aus); `.xor` ergänzt nur die 1"-Plakette
am Austritt der Sammelleiste genau eine". Grau wie die any-of-Führung
(Gate-Codierung, keine Signalfarbe, D15); sitzt mittig auf dem
14-px-Einlaufstück (left:9px = Leisten-x in allen Modi). */
ul.xor::after{
content:'1';
position:absolute;left:9px;top:7px;transform:translate(-50%,-50%);
width:13px;height:13px;border-radius:50%;
background:var(--card);border:1.5px solid var(--muted);
box-sizing:border-box;
color:var(--muted);font-size:9px;font-weight:600;line-height:10px;
text-align:center;
}
ul.or>li{position:relative;align-items:flex-start;padding:5px 0 5px 30px} ul.or>li{position:relative;align-items:flex-start;padding:5px 0 5px 30px}
/* Abzweig auf Höhe der Knoten-Mitte (5px li-Padding + halbe Knotenhöhe), nicht Mitte des Teilbaums */ /* Abzweig auf Höhe der Knoten-Mitte (5px li-Padding + halbe Knotenhöhe), nicht Mitte des Teilbaums */
ul.or>li::before{ ul.or>li::before{
+5
View File
@@ -9,6 +9,9 @@
Bekannte Typen: Bekannte Typen:
- mixedGate { line, label } Geschwister mit gemischtem Gate (SPEC §3) - mixedGate { line, label } Geschwister mit gemischtem Gate (SPEC §3)
- unknownStatus { line, code } unbekanntes Statuszeichen (Phase 2) - unknownStatus { line, code } unbekanntes Statuszeichen (Phase 2)
- xorConflict { line, label } weitere realisierte Alternative in einer
`=`-Gruppe (SPEC §3/D35); je Zeile eine
Warnung, damit sie dorthin zeigt
- sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne - sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne
Zeilennummer, erscheint dadurch zuoberst Zeilennummer, erscheint dadurch zuoberst
- padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht - padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht
@@ -29,6 +32,8 @@ export function formatWarning(w, t){
return t('mixedWarn', {line: w.line, label: esc(w.label)}); return t('mixedWarn', {line: w.line, label: esc(w.label)});
case 'unknownStatus': case 'unknownStatus':
return t('unknownStatusWarn', {line: w.line, code: esc(w.code)}); return t('unknownStatusWarn', {line: w.line, code: esc(w.code)});
case 'xorConflict':
return t('xorConflictWarn', {line: w.line, label: esc(w.label)});
case 'sourceLoad': case 'sourceLoad':
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit': case 'padRateLimit':
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { gateOf, computeCheapSet } 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();
/* XOR-Gruppen: `=` (SPEC §3, D34/D35). Disjunktiv wie `|`, aber genau EINE
Alternative darf realisiert werden. */
describe('Parser — `=` als XOR-Gate mit Leerraum-Regel', () => {
it('erkennt `=` mit folgendem Leerraum als Gate', () => {
const [wurzel] = roots(`[ ] Wahl\n = [ ] A\n = [ ] B`);
expect(wurzel.children.map(k => [k.label, k.type]))
.toEqual([['A', 'xor'], ['B', 'xor']]);
});
it('lässt `=` ohne folgenden Leerraum im Label (Leerraum-Regel)', () => {
const [wurzel] = roots(`[ ] Wurzel\n - =SUMME(A1:B2)`);
expect(wurzel.children.map(k => [k.label, k.type]))
.toEqual([['=SUMME(A1:B2)', 'and']]);
});
it('parst Status, Größe, Tags und URL am `=`-Knoten wie sonst auch', () => {
const [wurzel] = roots(`[ ] Wahl\n = [~] A (M) https://example.org/a @ana`);
const k = wurzel.children[0];
expect([k.label, k.type, k.status.key, k.size, k.tags, k.url])
.toEqual(['A', 'xor', 'arbeit', 'M', ['ana'], 'https://example.org/a']);
});
it('meldet gateOf für eine XOR-Gruppe als eigenen Wert', () => {
const [wurzel] = roots(`[ ] Wahl\n = [ ] A\n = [ ] B`);
expect(gateOf(wurzel.children)).toBe('xor');
});
});
describe('XOR-Regel — genau eine Alternative darf realisiert sein', () => {
it('warnt nicht bei null oder einer realisierten Alternative', () => {
expect(parse(`[ ] Wahl\n = [ ] A\n = [ ] B`).warnings).toEqual([]);
expect(parse(`[ ] Wahl\n = [x] A\n = [ ] B`).warnings).toEqual([]);
});
it('meldet jede WEITERE realisierte Alternative mit ihrer Zeile', () => {
const {warnings} = parse(`[ ] Wahl\n = [x] A\n = [~] B\n = [/] C`);
expect(warnings).toEqual([
{type: 'xorConflict', line: 3, label: 'B'},
{type: 'xorConflict', line: 4, label: 'C'}
]);
});
it('zählt schon `[~]` als realisiert — Kosten sind investiert', () => {
const {warnings} = parse(`[ ] Wahl\n = [~] A\n = [~] B`);
expect(warnings).toEqual([{type: 'xorConflict', line: 3, label: 'B'}]);
});
it('zählt Absicht, Ablehnung und neutrale Knoten nicht als realisiert', () => {
/* [?], [ ], [!], [-] und ohne Statusbox — keine davon ist realisiert. */
const {warnings} = parse(
`[ ] Wahl\n = [^] A\n = [?] B\n = [ ] C\n = [!] D\n = [-] E\n = F`);
expect(warnings).toEqual([]);
});
it('prüft verschachtelte XOR-Gruppen unabhängig voneinander', () => {
const {warnings} = parse(`[ ] Wurzel
- [ ] Teil
= [x] A
= [x] B
- [ ] Anderes
= [ ] C
= [x] D`);
expect(warnings).toEqual([{type: 'xorConflict', line: 4, label: 'B'}]);
});
});
describe('Mischregel — `=` ist disjunktiv, jede Mischung warnt', () => {
it('warnt, wenn `=` mit `|` gemischt wird', () => {
const {warnings} = render(`[ ] Wahl\n = [ ] A\n | [ ] B`);
expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Wahl'}]);
});
it('warnt, wenn `=` mit `-` oder `+` gemischt wird', () => {
const {warnings} = render(`[ ] Wahl\n = [ ] A\n + [ ] B`);
expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Wahl'}]);
});
});
describe('Darstellung und günstigster Pfad', () => {
it('rendert die XOR-Gruppe als `ul.or.xor` (any-of-Geometrie + Plakette)', () => {
const {html} = render(`[ ] Wahl\n = [ ] A\n = [ ] B`);
expect(html).toContain('<ul class="or xor">');
expect(html).toContain('<li class="has-or">');
});
it('wählt im günstigsten Pfad die günstigste XOR-Alternative', () => {
expect(cheapLabels(`[ ] Wahl (XS)\n = [ ] A (L)\n = [ ] B (S)`))
.toEqual(['B', 'Wahl']);
});
});