board.js rechnet headless Rollen (&epic/&story/&task, sonst aus Schachtelung und Größe), Lanes aus den obersten Epics, Karten mit Aufgabenzeilen und Status-Leiste; renderBoardHtml zeichnet, Karten und Zeilen sind .node mit den Attributen des Baums. Nur Anzeige; Filter, Zähler, Export und Drag&Drop folgen als eigene Teilpakete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #6
383 lines
16 KiB
JavaScript
383 lines
16 KiB
JavaScript
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, /<section class="lane">/g)).toBe(2);
|
||
expect(count(h, /class="bcol /g)).toBe(14);
|
||
expect(h).toContain('boardNoStatus');
|
||
expect(h).toContain('[~]</span> 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('<span class="crumb" aria-hidden="true">#e2</span>');
|
||
});
|
||
|
||
it('showIds setzt die ID vor den Titel', () => {
|
||
expect(html(PLAN, {showIds: true})).toContain('<span class="nid" aria-hidden="true">#fe</span>');
|
||
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('<span class="bcnt">1</span>');
|
||
expect(h).not.toContain('<span class="bcnt">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('<button type="button" class="cbox" data-code="~" aria-label="boardStatusPick">[~]</button>');
|
||
expect(h).toContain('<button type="button" class="bbox" data-code="x" aria-label="boardStatusPick">[x]</button>');
|
||
});
|
||
});
|