Files
c6fd41a1bf
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
feat(kanban): Board als vierter Modus — Spalten aus den Boxen, Rollen aus Tags oder abgeleitet, Karten als Knoten (D98) (#6)
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
2026-09-09 07:06:36 +02:00

383 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>');
});
});