import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { assignRoles, boardModel, columnOf, explicitRole, taskShares, BOARD_COLUMNS } from '../src/board.js'; const roots = text => parse(text).roots; const byId = (ns, map = new Map()) => { for(const n of ns){ if(n.id) map.set(n.id, n); byId(n.children, map); } return map; }; const rolesById = (text, showDiscarded = false) => { const rs = roots(text), ids = byId(rs), roles = assignRoles(rs, showDiscarded), out = {}; ids.forEach((n, id) => { out[id] = roles.get(n); }); return out; }; const lanesOf = (text, opts) => boardModel(roots(text), opts).lanes.map(l => ({ lane: l.node.id || l.node.label, cards: l.cards.map(c => ({ id: c.node.id || c.node.label, role: c.role, col: c.column, crumb: c.crumb.map(x => x.id), tasks: c.tasks.map(tk => tk.node.id || tk.node.label), })), })); /* Der Plan aus D98, gekürzt: eine Wurzel, drei Pakete verschiedener Tiefe. */ const PLAN = `#wb: Plan (XXL) - #infra: Infrastruktur (L) - [ ] #infra.decision: Entscheidung - [x] #infra.db: Datenbank - [x] #infra.db.kc: für Keycloak - [x] #infra.db.be: für Backend - [~] #infra.be: Backend-Deployment + [~] #fe: Frontend - [~] #fe.rel: Relations (S) - [ ] #fe.accounts: Accounts (L) + #fe.more: mehr … - #kc.sso: SSO anbindbar (M)`; describe('Rollen — abgeleitet aus Schachtelung und Größe (SPEC §11/D98)', () => { it('Blätter einer Story sind Tasks, was immer sie an Größe tragen', () => { const r = rolesById(PLAN); expect(r['fe.rel']).toBe('task'); expect(r['fe.accounts']).toBe('task'); /* (L), trotzdem Task — die Story hält zusammen */ }); it('ein Blatt neben Paketen ist ab M eine Story, sonst eine Task ohne Story', () => { const r = rolesById(PLAN); expect(r['kc.sso']).toBe('story'); /* (M) neben #infra und #fe */ expect(r['infra.decision']).toBe('task');/* ohne Größe neben #infra.db */ }); it('ein Blatt als Wurzel: ab M Story, sonst Task', () => { expect(rolesById('#a: allein (L)')['a']).toBe('story'); expect(rolesById('#a: allein')['a']).toBe('task'); }); it('ein Knoten mit lauter Blatt-Kindern ist eine Story, darüber Epic', () => { const r = rolesById(PLAN); expect(r['infra.db']).toBe('story'); expect(r['fe']).toBe('story'); expect(r['infra']).toBe('epic'); expect(r['wb']).toBe('epic'); }); it('ausgeblendete verworfene Kinder zählen nicht für die Ableitung', () => { const T = `#a: Paket - #a.x: Blatt - [-] #a.y: verworfenes Paket - #a.y.z: tief`; expect(rolesById(T)['a']).toBe('story'); expect(rolesById(T, true)['a']).toBe('epic'); }); }); describe('Rollen — Schlagworte übersteuern', () => { it('liest das erste Rollen-Schlagwort der Zeile', () => { const [n] = roots('#x: Ding &task &story'); expect(explicitRole(n)).toBe('task'); expect(explicitRole(roots('#x: Ding &taiga.p')[0])).toBe(null); }); it('&story macht alle Nachkommen zu Tasks, auch tiefe', () => { const r = rolesById(`#s: Story &story - #s.a: A - #s.a.1: tief (L) - #s.b: B (XL)`); expect(r).toEqual({s: 'story', 's.a': 'task', 's.a.1': 'task', 's.b': 'task'}); }); it('&epic macht Blätter darunter zu Stories ohne Aufgaben', () => { const r = rolesById(`#e: Epic &epic - #e.a: Blatt - #e.b: Paket - #e.b.1: Blatt`); expect(r).toEqual({e: 'epic', 'e.a': 'story', 'e.b': 'story', 'e.b.1': 'task'}); }); it('&task mit Kindern: die Kinder sind Tasks und bleiben verborgen', () => { const r = rolesById(`#s: Story - #s.t: Task &task - #s.t.1: Unter-Task`); expect(r['s.t']).toBe('task'); expect(r['s.t.1']).toBe('task'); }); it('ein &epic unter einer Story wird trotzdem Task — die Story gewinnt', () => { const r = rolesById(`#s: Story &story - #s.e: Unterpaket &epic`); expect(r['s.e']).toBe('task'); }); }); describe('Spalten — die intrinsische Box', () => { it('ohne Status leer, [!] in der [ ]-Spalte, sonst der Schlüssel', () => { const rs = roots(`neutral\n[!] riskant\n[~] arbeit\n[-] weg`); expect(rs.map(columnOf)).toEqual(['', 'geplant', 'arbeit', 'verworfen']); }); it('die Spaltenliste ist die Ergebnis-Skala mit „ohne Status" links', () => { expect(BOARD_COLUMNS).toEqual(['', 'idee', 'geplant', 'arbeit', 'durchstich', 'fertig', 'prod']); expect(boardModel(roots('x'), {showDiscarded: true}).columns.at(-1)).toBe('verworfen'); expect(boardModel(roots('x'), {}).columns).not.toContain('verworfen'); }); }); describe('Lanes und Karten', () => { it('die obersten Epics werden Lanes; direkte Stories der Wurzel bekommen deren Lane zuerst', () => { const lanes = lanesOf(PLAN); expect(lanes.map(l => l.lane)).toEqual(['wb', 'infra']); /* #fe und #kc.sso hängen direkt an der Wurzel — Lane der Wurzel */ expect(lanes[0].cards.map(c => c.id)).toEqual(['fe', 'kc.sso']); expect(lanes[1].cards.map(c => c.id)).toEqual(['infra.decision', 'infra.db', 'infra.be']); }); it('eine Story-Karte listet ihre direkten Kinder als Aufgaben, mit Nachkommen-Zahl', () => { /* Enkel gibt es nur unter einer GESETZTEN Story — abgeleitet wäre #s ein Epic. */ const model = boardModel(roots(`#s: Story &story - #s.a: A - tief 1 - tief 2 - #s.b: B`)); const [card] = model.lanes[0].cards; expect(card.role).toBe('story'); expect(card.tasks.map(tk => [tk.node.id, tk.hidden])).toEqual([['s.a', 2], ['s.b', 0]]); }); it('eine Task neben Stories ist eine Karte ohne Liste', () => { const [lane] = lanesOf(PLAN).filter(l => l.lane === 'infra'); const t = lane.cards.find(c => c.id === 'infra.decision'); expect(t.role).toBe('task'); expect(t.tasks).toEqual([]); }); it('tiefere Epics stehen nur als Brotkrume auf der Karte', () => { const lanes = lanesOf(`#r: Wurzel - #e1: Epic - #e2: Unter-Epic - #s: Story - Aufgabe - #s2: Story 2 - Aufgabe`); expect(lanes.map(l => l.lane)).toEqual(['e1']); expect(lanes[0].cards.map(c => [c.id, c.crumb])).toEqual([['s', ['e2']], ['s2', []]]); }); it('eine Wurzel, die selbst Story ist, wird eine Lane mit einer Karte', () => { const lanes = lanesOf(`[~] Klein\n - a\n - b`); expect(lanes).toEqual([{lane: 'Klein', cards: [{id: 'Klein', role: 'story', col: 'arbeit', crumb: [], tasks: ['a', 'b']}]}]); }); it('die Karte steht in der Spalte ihrer eigenen Box, nicht der ihrer Aufgaben', () => { const lanes = lanesOf(PLAN); const fe = lanes[0].cards.find(c => c.id === 'fe'); expect(fe.col).toBe('arbeit'); const db = lanes[1].cards.find(c => c.id === 'infra.db'); expect(db.col).toBe('fertig'); }); it('die Status-Leiste zählt die Aufgaben je Spalte in Spaltenreihenfolge', () => { const model = boardModel(roots(PLAN)); const fe = model.lanes[0].cards.find(c => c.node.id === 'fe'); expect(taskShares(fe)).toEqual([{column: '', count: 1}, {column: 'geplant', count: 1}, {column: 'arbeit', count: 1}]); }); it('verworfene Karten und Aufgaben erscheinen nur mit dem Filter', () => { const T = `#s: Story\n - [-] #s.weg: weg\n - #s.da: da`; expect(lanesOf(T)[0].cards[0].tasks).toEqual(['s.da']); expect(lanesOf(T, {showDiscarded: true})[0].cards[0].tasks).toEqual(['s.weg', 's.da']); }); it('jeder Knoten erscheint genau einmal — als Lane, Karte oder Aufgabe', () => { const rs = roots(PLAN), model = boardModel(rs); const seen = []; for(const l of model.lanes) for(const c of l.cards){ seen.push(c.node); for(const tk of c.tasks) seen.push(tk.node); } expect(new Set(seen).size).toBe(seen.length); /* alle außer den Epics (die Lanes bzw. Brotkrume sind) */ const all = []; (function w(ns){ for(const n of ns){ all.push(n); w(n.children); } })(rs); const nonEpic = all.filter(n => model.roles.get(n) !== 'epic'); expect(seen.length).toBe(nonEpic.length); }); }); /* Das Markup: Karten, Aufgabenzeilen und Lane-Titel sind `.node` mit den Daten-Attributen des Baums — daran hängen Cursor-Zeile, Fenster und Sprung. */ import { renderBoardHtml, gateWarnings } from '../src/render.js'; const t = k => k; const html = (text, opts = {}) => { const rs = roots(text); return renderBoardHtml(boardModel(rs, opts), {t, showIds: false, cheapPath: false, cheapSet: new Set(), taskShares, ...opts}); }; const count = (h, re) => (h.match(re) || []).length; describe('renderBoardHtml', () => { it('eine Kopfzeile mit den Spalten, je Lane dieselben Spalten', () => { const h = html(PLAN); expect(h).toContain('class="board" style="--bcols:7"'); expect(count(h, /class="bcolh /g)).toBe(7); expect(count(h, /
/g)).toBe(2); expect(count(h, /class="bcol /g)).toBe(14); expect(h).toContain('boardNoStatus'); expect(h).toContain('[~] st_arbeit'); }); it('mit dem Verworfen-Filter kommt die achte Spalte', () => { expect(html(PLAN, {showDiscarded: true})).toContain('style="--bcols:8"'); }); it('Karte, Aufgabenzeile und Lane-Titel tragen data-line und das Fenster', () => { const h = html(PLAN); expect(h).toMatch(/class="node card role-story[^"]*" draggable="true" data-ceil="[^"]*" tabindex="0" data-line="8"/); /* #fe */ expect(h).toMatch(/class="node brow[^"]*" tabindex="0" data-line="9"/); /* #fe.rel */ expect(h).toMatch(/class="node lane-node" tabindex="0" data-line="2"/); /* #infra */ expect(count(h, /data-tip="/g)).toBe(count(h, /class="node /g)); }); it('die Story-Karte trägt die Status-Leiste, die Task-Karte nicht', () => { const h = html(PLAN); expect(h).toMatch(/data-line="8"[^>]*>(?:(?!<\/div>).)*class="cbar"/s); expect(h).toMatch(/class="node card role-task[^"]*"[^>]*data-line="3"/); expect(h).not.toMatch(/data-line="3"[^>]*>(?:(?!<\/div>).)*class="cbar"/s); }); it('die Brotkrume nennt die Epics zwischen Lane und Karte', () => { const h = html(`#r: Wurzel - #e1: Epic - #e2: Unter-Epic - #s: Story - Aufgabe`); expect(h).toContain(''); }); it('showIds setzt die ID vor den Titel', () => { expect(html(PLAN, {showIds: true})).toContain(''); expect(html(PLAN)).not.toContain('class="nid"'); }); it('gateWarnings meldet gemischte Gates wie der Baum', () => { expect(gateWarnings(roots('a\n - b\n | c'), {showDiscarded: false})).toEqual([{type: 'mixedGate', line: 2, label: 'a'}]); expect(gateWarnings(roots(PLAN), {showDiscarded: false})).toEqual([]); }); }); /* Filter (#ed.kanban.filter) und Zähler (#ed.kanban.counts). */ import { filterBoard, columnStats, inheritedTags } from '../src/board.js'; import { computeCheapSet } from '../src/model.js'; const TAGGED = `#wb: Plan (XXL) - #infra: Infrastruktur (L) @marc - [ ] #infra.decision: Entscheidung @mihi - [x] #infra.db: Datenbank - [x] #infra.db.kc: für Keycloak - [~] #infra.be: Backend-Deployment @timo + [~] #fe: Frontend @mihö - [~] #fe.rel: Relations (S) - [ ] #fe.accounts: Accounts (L) + #fe.more: mehr … - #kc.sso: SSO anbindbar (M)`; const idsOf = (lanes) => lanes.map(l => [l.node.id, l.cards.map(c => c.node.id + (c.tasks.length ? '[' + c.tasks.map(tk => tk.node.id).join(',') + ']' : ''))]); describe('Filter — Personen-Linse blendet aus', () => { it('erbt Tags vom nächsten getaggten Vorfahren', () => { const rs = roots(TAGGED), ids = byId(rs), tags = inheritedTags(rs); expect(tags.get(ids.get('infra.db.kc'))).toEqual(['marc']); expect(tags.get(ids.get('infra.decision'))).toEqual(['mihi']); expect(tags.get(ids.get('kc.sso'))).toEqual([]); }); it('lässt nur die Karten und Aufgaben der Person stehen, leere Lanes entfallen', () => { const rs = roots(TAGGED); const m = filterBoard(boardModel(rs), {roots: rs, lens: {tag: 'marc'}}); expect(idsOf(m.lanes)).toEqual([['infra', ['infra.db[infra.db.kc]']]]); }); it('eine Karte bleibt, wenn nur eine ihrer Aufgaben der Person gehört — mit nur dieser Aufgabe', () => { const rs = roots(`#s: Story @anna - #s.a: A @ben - #s.b: B`); const m = filterBoard(boardModel(rs), {roots: rs, lens: {tag: 'ben'}}); expect(idsOf(m.lanes)).toEqual([['s', ['s[s.a]']]]); }); it('„ohne Zuständigen" zeigt, was niemandem gehört', () => { const rs = roots(TAGGED); const m = filterBoard(boardModel(rs), {roots: rs, lens: {tag: null}}); expect(idsOf(m.lanes)).toEqual([['wb', ['kc.sso']]]); }); }); describe('Filter — nur der günstigste Pfad', () => { it('blendet Karten und Aufgaben außerhalb des Pfads aus', () => { const rs = roots(TAGGED), cheap = computeCheapSet(rs); const m = filterBoard(boardModel(rs), {roots: rs, cheapSet: cheap}); /* #fe ist eine angefangene Zugabe — seit D97 nicht auf dem Pfad */ expect(idsOf(m.lanes)).toEqual([['wb', ['kc.sso']], ['infra', ['infra.decision', 'infra.db[infra.db.kc]', 'infra.be']]]); }); it('ohne Filter kommt das Modell unverändert zurück', () => { const rs = roots(TAGGED), m = boardModel(rs); expect(filterBoard(m, {roots: rs, lens: null, cheapSet: null})).toBe(m); }); }); describe('Zähler je Spalte', () => { it('zählt Karten und nennt die ordinale Spanne, nie eine Summe', () => { /* vier Wurzeln — je eine Karte (ein Paket aus Blättern wäre EINE Story) */ const m = boardModel(roots(`#a: A (S)\n#b: B (L)\n#c: C\n[~] #d: D (M)`)); const st = columnStats(m); expect(st.get('')).toEqual({count: 3, span: 'S–L'}); expect(st.get('arbeit')).toEqual({count: 1, span: 'M'}); expect(st.get('fertig')).toEqual({count: 0, span: ''}); }); it('die Kopfzeile trägt die Zahl des gefilterten Modells', () => { const rs = roots(TAGGED); const m = filterBoard(boardModel(rs), {roots: rs, lens: {tag: 'marc'}}); const h = renderBoardHtml(m, {t, showIds: false, cheapPath: false, cheapSet: new Set(), taskShares, columnStats}); expect(h).toContain('1'); expect(h).not.toContain('2'); }); }); /* Status schreiben (#ed.kanban.drag): die Aufgaben-Schranke. */ import { taskCeiling, statusAllowed, codeRank } from '../src/board.js'; const cardOf = (text, id) => { const m = boardModel(roots(text)); for(const l of m.lanes) for(const c of l.cards) if(c.node.id === id) return c; return null; }; describe('Aufgaben-Schranke — nicht weiter als die Pflicht-Aufgaben', () => { it('die Schranke ist der kleinste Rang der Pflicht-Aufgaben', () => { const c = cardOf(`[~] #s: Story\n - [x] a\n - [~] b\n - [ ] c`, 's'); expect(taskCeiling(c)).toBe(1); expect(statusAllowed(c, '~')).toBe(false); expect(statusAllowed(c, ' ')).toBe(true); expect(statusAllowed(c, '?')).toBe(true); /* zurück geht immer */ }); it('Zugaben und Verworfenes halten nichts auf', () => { const c = cardOf(`[~] #s: Story\n - [x] a\n + [ ] extra\n - [-] weg`, 's'); expect(taskCeiling(c)).toBe(4); expect(statusAllowed(c, 'x')).toBe(true); expect(statusAllowed(c, '^')).toBe(false); }); it('ohne zählende Aufgabe gibt es keine Schranke', () => { expect(taskCeiling(cardOf(`#s: Story (M)`, 's'))).toBe(null); expect(statusAllowed(cardOf(`#s: Story (M)`, 's'), '^')).toBe(true); }); it('die Ränge folgen der Ergebnis-Skala (D39): ohne Box und [?] 0, [!] wie [ ]', () => { expect(['', '?', ' ', '!', '~', '/', 'x', '^', '-'].map(codeRank)).toEqual([0, 0, 1, 1, 2, 3, 4, 5, 0]); }); it('die Karte trägt die Schranke als data-ceil, die Boxen sind Knöpfe', () => { const h = renderBoardHtml(boardModel(roots(`[~] #s: Story\n - [x] a\n - [ ] c`)), {t, showIds: false, cheapPath: false, cheapSet: new Set(), taskShares, taskCeiling}); expect(h).toMatch(/class="node card role-story[^"]*" draggable="true" data-ceil="1" tabindex="0"/); expect(h).toContain(''); expect(h).toContain(''); }); });