Die Notation kannte nur „erforderlich" (-) und „wählbar" (|). Ein einzelnes zusätzliches Feature — weder nötig noch Alternative zu etwas anderem — musste als normales -Kind notiert werden und log damit. Feature-Modelle (FODA) unterscheiden seit den 90ern mandatory/optional/alternative; `+` ergänzt die fehlende zweite Beziehung. Mnemonik: `-` Teilpaket, `+` Zugabe, `|` Alternative. Anlass ist nicht die Optik, sondern der günstigste Pfad (D18): markCheapest() lief bei all-of über ALLE Kinder, jede Zugabe steckte also im errechneten Minimum — systematisch zu groß, und zwar umso mehr, je ehrlicher ein Plan auch die Kür notiert. Sichtbar wird es beim Alternativenvergleich: eine Alternative mit teurer Zugabe verlor gegen eine schlichtere, obwohl die Zugabe gar nicht dazugehört. - Parser setzt `optional:true` und lässt `type:'and'` stehen — `+` gehört zum Knoten, nicht zur Gruppe. Dadurch bleiben gateOf() und die mixedGate-Warnung unverändert richtig: sie meldet weiter genau dann, wenn | mit -/+ gemischt wird. `-` neben `+` ist erlaubt und still — „diese drei sind nötig, das hier wäre schön" ist der Normalfall, nicht der Fehlerfall. - Aus dem Pfad fallen optionale Knoten über pathChildren() heraus, die eine Stelle, die cheapestCost() und markCheapest() gemeinsam nutzen — deshalb wirkt es samt Teilbaum. - Darstellung: hohler Kreis mittig auf der Knotenkante, wo der Abzweig auftrifft (FODA-Konvention). Bewusst KEIN dritter Linienstil: im kompakten Modus codiert allein der Stil das Gate (D15), gepunktet müsste sich dort gegen gestrichelt-grau behaupten. Der Kreis ist orthogonal dazu. CSS-Grundfall ist gestapelt (links/50 %), Ausnahme der horizontale Fächer (oben/50 %), Rück-Ausnahme der gestapelte all-of-Teilbaum unter any-of (D18) — andersherum wären es vier Ausnahmen statt zwei. - SVG-Export zeichnet den Kreis NACH den Knoten (optMarks, Schritt 3a): er liegt halb außerhalb der Box und würde sonst vom Knoten-Rechteck überdeckt. - Legende, Knoten-Tooltip und aria-label in allen neun Sprachen; hint_root formuliert die neue Mischregel. Bekannte Schwäche, bewusst in Kauf genommen: Bei aktivem Pfad-Umschalter wird der optionale Knoten ausgeblasst (opacity:.32) — und mit ihm sein Kreis, der die Erklärung dafür wäre. `opacity` am Elternteil schlägt auf Pseudoelemente durch, das lässt sich nicht zurücknehmen. Das Zurücktreten ist hier die Hauptaussage (wie bei nicht gewählten Alternativen), Tooltip/aria/Legende liefern die Begründung nach. Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen und nicht mehr Teil des Labels (`+ 5 % Puffer` ergibt „5 % Puffer"). Test-abgedeckt. SPEC §1/§3/§9/§10 zuerst, dann Code (CLAUDE). Das kanonische Beispiel in §10 enthält jetzt eine `+`-Zeile und ist mit der Test-Fixture wieder deckungsgleich. Der mitgelieferte Werkbaum-Plan markiert Drucklayout, „Was ist neu?" und die Personenfarben als Zugaben — „Was ist neu?" war der Auslöser der Frage. Verifiziert: 12 neue Tests (Parser setzt optional/type; Status/Größe/Tags/URL am +-Knoten; führendes + wird verbraucht; Pfad lässt Zugabe samt Teilbaum aus; Kosten des Elternknotens ohne Zugabe; Alternativenvergleich ohne Zugaben; optionale Knoten bleiben sichtbar; opt-Klasse; keine Warnung bei -/+; Warnung bei |/+; aria-label). Vitest 58/58, Snapshot zeigt `node opt` OHNE `cheap`. Im Browser in allen drei Modi angesehen: Kreis sitzt in horizontal oben mittig, in vertikal und kompakt links auf halber Höhe, jeweils genau auf dem Ende des Abzweigs; SVG-Export enthält beide Kreise an denselben Punkten (gerendert geprüft, nicht nur im Quelltext). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
3.7 KiB
JavaScript
94 lines
3.7 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { parse } from '../src/parser.js';
|
|
import { computeCheapSet, cheapestCost, visibleChildren } from '../src/model.js';
|
|
import { renderTreeHtml } from '../src/render.js';
|
|
|
|
const t = key => key;
|
|
const roots = txt => parse(txt).roots;
|
|
const render = txt => renderTreeHtml(roots(txt),
|
|
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
|
|
const cheapLabels = txt => [...computeCheapSet(roots(txt))].map(n => n.label).sort();
|
|
|
|
/* Optionale Knoten: `+` (SPEC §3, D29). Eigenschaft des einzelnen Knotens,
|
|
nicht der Gruppe — deshalb bleibt `type` 'and'. */
|
|
describe('Parser — `+` setzt optional, nicht das Gate', () => {
|
|
it('erkennt `+` als optionalen Knoten innerhalb einer Und-Gruppe', () => {
|
|
const [wurzel] = roots(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
|
|
expect(wurzel.children.map(k => [k.label, k.type, k.optional]))
|
|
.toEqual([['Pflicht', 'and', false], ['Zugabe', 'and', true]]);
|
|
});
|
|
|
|
it('lässt `-` und Zeilen ohne Zeichen nicht-optional', () => {
|
|
const [wurzel] = roots(`Wurzel\n - Kind`);
|
|
expect(wurzel.optional).toBe(false);
|
|
expect(wurzel.children[0].optional).toBe(false);
|
|
});
|
|
|
|
it('parst Status, Größe, Tags und URL am `+`-Knoten wie sonst auch', () => {
|
|
const [wurzel] = roots(`[ ] Wurzel\n + [^] Zugabe (S) https://example.org/x @ana`);
|
|
const k = wurzel.children[0];
|
|
expect([k.label, k.status.key, k.size, k.tags, k.url])
|
|
.toEqual(['Zugabe', 'prod', 'S', ['ana'], 'https://example.org/x']);
|
|
});
|
|
|
|
/* Bewusste Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen.
|
|
Vorher wäre es Teil des Labels gewesen. */
|
|
it('verbraucht ein führendes `+` als Zeichen, nicht als Label-Text', () => {
|
|
expect(roots(`+ 5 % Puffer`)[0].label).toBe('5 % Puffer');
|
|
});
|
|
});
|
|
|
|
describe('Günstigster Pfad — optionale Knoten sind nie nötig', () => {
|
|
const BAUM = `[ ] Wurzel (S)
|
|
- [ ] Pflicht (S)
|
|
+ [ ] Zugabe (XXL)
|
|
- [ ] Unterpunkt (S)`;
|
|
|
|
it('lässt den optionalen Knoten samt Teilbaum aus dem Pfad', () => {
|
|
expect(cheapLabels(BAUM)).toEqual(['Pflicht', 'Wurzel']);
|
|
});
|
|
|
|
it('rechnet die Zugabe nicht in die Kosten des Elternknotens', () => {
|
|
/* Wurzel (S=2) + Pflicht (S=2) = 4; mit der XXL-Zugabe wären es 10. */
|
|
expect(cheapestCost(roots(BAUM)[0])).toBe(4);
|
|
});
|
|
|
|
it('vergleicht Alternativen ohne deren Zugaben', () => {
|
|
/* Ohne die Ausnahme kostete A 2+6=8 und B (3) gewänne. */
|
|
const BAUM = `[ ] Wahl (XS)
|
|
| [ ] A (S)
|
|
+ [ ] Extra (XXL)
|
|
| [ ] B (M)`;
|
|
expect(cheapLabels(BAUM)).toEqual(['A', 'Wahl']);
|
|
});
|
|
|
|
it('blendet optionale Knoten aber nicht aus dem Diagramm aus', () => {
|
|
const [wurzel] = roots(BAUM);
|
|
expect(visibleChildren(wurzel, false).map(k => k.label))
|
|
.toEqual(['Pflicht', 'Zugabe']);
|
|
});
|
|
});
|
|
|
|
describe('Renderer — Kennzeichnung und Gemischt-Warnung', () => {
|
|
it('gibt dem optionalen Knoten die Klasse `opt`', () => {
|
|
const {html} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
|
|
expect((html.match(/class="node opt[ "]/g) || []).length).toBe(1);
|
|
expect(html).toContain('>Zugabe<');
|
|
});
|
|
|
|
it('warnt NICHT, wenn `-` und `+` nebeneinander stehen', () => {
|
|
const {warnings} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`);
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it('warnt weiterhin, wenn `|` mit `+` gemischt wird', () => {
|
|
const {warnings} = render(`[ ] Wurzel\n | [ ] Alternative\n + [ ] Zugabe`);
|
|
expect(warnings).toEqual([{type: 'mixedGate', line: 2, label: 'Wurzel'}]);
|
|
});
|
|
|
|
it('nennt „optional" im aria-label des Knotens', () => {
|
|
const {html} = render(`[ ] Wurzel\n + [ ] Zugabe`);
|
|
expect(html).toContain('aria-label="Zugabe, a11yStatus, a11yOptional"');
|
|
});
|
|
});
|