import { describe, it, expect } from 'vitest'; import { parse, setFoldMark } from '../src/parser.js'; import { initialCollapsed, computeCheapSet, atMostM } from '../src/model.js'; import { renderTreeHtml } from '../src/render.js'; const t = key => key; const roots = txt => parse(txt).roots; const render = (txt, collapsedSet) => renderTreeHtml(roots(txt), {t, showDiscarded: false, cheapPath: false, cheapSet: new Set(), collapsedSet: collapsedSet || new Set()}); /* Rendert mit dem Anfangszustand aus den Textmarken (wie app.js, ohne Overrides). */ const renderFolded = txt => { const r = roots(txt); return renderTreeHtml(r, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set(), collapsedSet: initialCollapsed(r, true)}); }; const collapsedLabels = (txt, rescueFocus = true) => [...initialCollapsed(roots(txt), rescueFocus)].map(n => n.label).sort(); /* Faltmarken `>`/`<` (SPEC §1/§9, D38): Anfangszustand der Faltung im Text. */ describe('Parser — Faltmarke unmittelbar vor dem Label', () => { it('erkennt `>` und `<` hinter der Statusbox und nimmt sie aus dem Label', () => { const [wurzel] = roots(`[ ] Wurzel\n - [x] > Zu (M)\n - [ ] < Auf`); expect(wurzel.children.map(k => [k.label, k.fold, k.status?.key])) .toEqual([['Zu', '>', 'fertig'], ['Auf', '<', 'geplant']]); expect(wurzel.fold).toBe(null); }); it('liest die alte Stellung (vor der Box) weiter — D34-Nachtrag 2', () => { const [wurzel] = roots(`[ ] Wurzel\n - > [x] Zu (M)\n - < [ ] Auf`); expect(wurzel.children.map(k => [k.label, k.fold, k.status?.key])) .toEqual([['Zu', '>', 'fertig'], ['Auf', '<', 'geplant']]); }); it('erkennt die Marke ohne Statusbox direkt hinter dem Zeichen', () => { const [wurzel] = roots(`> [~] Kapitel\n - > Kind ohne Box`); expect([wurzel.fold, wurzel.label]).toEqual(['>', 'Kapitel']); expect(wurzel.children.map(k => [k.label, k.fold])) .toEqual([['Kind ohne Box', '>']]); }); it('verlangt folgenden Leerraum — `[x] >Achtung` bleibt ein Label', () => { const [wurzel] = roots(`[ ] Wurzel\n - >Achtung\n - [x] >Achtung`); expect(wurzel.children.map(k => [k.label, k.fold])) .toEqual([['>Achtung', null], ['>Achtung', null]]); }); it('lässt ein `>` mitten im Label unberührt', () => { const [wurzel] = roots(`[ ] a > b`); expect([wurzel.label, wurzel.fold]).toEqual(['a > b', null]); }); }); /* Umkehrung der Marken-Extraktion fürs Zurückschreiben aus dem Diagramm (SPEC §1, D38-Nachtrag 2): Angefasst wird nur die Marke samt Leerraum. */ describe('setFoldMark — Faltmarke setzen und entfernen', () => { it('setzt `>` hinter die Statusbox, unmittelbar vor das Label', () => { expect(setFoldMark(' - [x] Concept (M)', '>')).toBe(' - [x] > Concept (M)'); }); it('entfernt eine vorhandene Marke', () => { expect(setFoldMark(' - [x] > Concept (M)', null)).toBe(' - [x] Concept (M)'); }); it('ersetzt `<` durch `>`', () => { expect(setFoldMark(' - [ ] < B1', '>')).toBe(' - [ ] > B1'); }); it('löst die alte Stellung (vor der Box) in die neue auf', () => { /* Gelesen wird sie weiter (D34-Nachtrag 2), geschrieben nie. */ expect(setFoldMark(' - > [x] Concept (M)', '>')).toBe(' - [x] > Concept (M)'); expect(setFoldMark(' - < [x] Concept (M)', '>')).toBe(' - [x] > Concept (M)'); expect(setFoldMark(' - > [x] Concept (M)', null)).toBe(' - [x] Concept (M)'); }); it('setzt die Marke ohne Statusbox direkt hinter das Zeichen', () => { expect(setFoldMark(' - Konzeption', '>')).toBe(' - > Konzeption'); expect(setFoldMark('[~] Wurzel', '>')).toBe('[~] > Wurzel'); expect(setFoldMark('[~] > Wurzel', null)).toBe('[~] Wurzel'); expect(setFoldMark('Wurzel ohne Box', '>')).toBe('> Wurzel ohne Box'); }); it('lässt ungewöhnliche Spaltung stehen', () => { expect(setFoldMark(' - [x] X', '>')).toBe(' - [x] > X'); expect(setFoldMark('\t= [ ] Cloud', '>')).toBe('\t= [ ] > Cloud'); }); it('fasst ein `>` im Label nicht an (Leerraum-Regel)', () => { /* `[x] >Achtung` ist ein Label, keine Marke — die neue Marke kommt davor. */ expect(setFoldMark(' - [x] >Achtung', '>')).toBe(' - [x] > >Achtung'); expect(setFoldMark(' - [x] >Achtung', null)).toBe(' - [x] >Achtung'); }); it('hält die Statusbox-Spalte über die Ebenen hinweg bündig', () => { /* Der Grund für die Stellung (D34-Nachtrag 2): Vor der Box geschrieben, rückte die Box der gefalteten Zeile um genau eine Einrückungsstufe ein und stand dann in der Spalte der Boxen ihrer eigenen Kinder. */ const eltern = setFoldMark(' - [ ] erster Schritt', '>'); const kind = ' - [ ] Schritt 1a'; expect(eltern.indexOf('[')).toBe(4); expect(kind.indexOf('[')).toBe(6); }); it('ist verlustfrei umkehrbar', () => { for(const l of ['[ ] W', ' - [x] A (M) @anna', ' | [?] B', ' + [-] C']){ expect(setFoldMark(setFoldMark(l, '>'), null)).toBe(l); } }); }); describe('initialCollapsed — `>` klappt ein, `<` wandert die Faltung hinunter', () => { it('klappt `>`-Knoten ein', () => { expect(collapsedLabels(`[ ] W\n - > [ ] A\n - [ ] A1\n - [ ] B`)) .toEqual(['A']); }); it('holt einen `<`-Teilbaum hervor: Vorfahr öffnet, Geschwister klappen ein', () => { const txt = `> [ ] W - [ ] A - [ ] A1 - [ ] B - < [ ] B1 - [ ] B1a - [ ] C`; /* W öffnet sich (Pfad zu B1), A klappt stattdessen ein; B öffnet den Weg, B1 samt Teilbaum ist sichtbar. C ist Blatt und braucht keine Faltung. */ expect(collapsedLabels(txt)).toEqual(['A']); }); it('respektiert ein `>` innerhalb des hervorgeholten Teilbaums', () => { const txt = `> [ ] W - < [ ] B - > [ ] B1 - [ ] B1a`; expect(collapsedLabels(txt)).toEqual(['B1']); }); it('holt auch einen `!!!`-markierten Knoten hervor', () => { const txt = `> [ ] W\n - [ ] A\n - [ ] A1\n - [ ] B !!!\n - [ ] B1`; expect(collapsedLabels(txt)).toEqual(['A']); /* … aber nur mit rescueFocus — headless-Aufrufer können es abschalten. */ expect(collapsedLabels(txt, false)).toEqual(['W']); }); it('lässt Bäume ohne Marken vollständig offen', () => { expect(collapsedLabels(`[ ] W\n - [ ] A\n - [ ] B`)).toEqual([]); }); }); describe('Renderer — eingeklappte Teilbäume', () => { const TXT = `[ ] W\n - > [~] A (M)\n - [ ] A1\n - [ ] A2\n - [ ] A2a\n - [ ] B`; it('lässt die Kinder eines eingeklappten Knotens weg', () => { const {html} = renderFolded(TXT); expect(html).not.toContain('A1'); expect(html).toContain('>B<'); }); it('kennzeichnet mit Klasse `folded` und „▸ n" (n = alle verborgenen Knoten)', () => { const {html} = renderFolded(TXT); expect(html).toContain('folded'); expect(html).toContain(''); expect(html).toContain('aria-expanded="false"'); expect(html).toContain('a11yFolded'); }); it('gibt offenen Eltern das ▾, Blättern gar kein Falt-Zeichen', () => { const {html} = render(`[ ] W\n - [ ] Blatt`); expect((html.match(/class="fold"/g) || []).length).toBe(1); expect(html).toContain('>▾'); expect(html).toContain('aria-expanded="true"'); }); it('macht das eingeklappte