summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:31:42 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:31:42 +0000
commitd105eca610b0e737c3313e4978d6a917b4f55d10 (patch)
tree2ad75a33abd45ebb8225dcbee01a2d0a8d7a21ed /web/test
parent8cb291ad7cdb317ff80947278ee055b1a4925b41 (diff)
feat(web): add Stories tab -- Kanban board, epic swimlanes, story-detail DAG (Phase 9a)
First UI work on top of the harness's 8 backend phases -- pure web/* frontend, no backend changes, keeping the existing embedded vanilla-JS convention (no build step, no framework). - Kanban board: columns spanning the full story lifecycle (DISCOVERY/FRAMING -> BACKLOG -> PRIORITIZED -> IN_PROGRESS -> VALIDATING/REVIEW_READY -> DONE), cards showing priority/acceptance-criteria count/eval-verdict progress. Drag-and-drop reorders priority within a column (persisted via a partial PUT /api/stories/{id}); cross-column drag is disabled outright (a dragover handler only preventDefault()s when the dragged card's own status already maps to that column) rather than allowed-then-snapped-back, since the latter would visibly misplace a card for several seconds until the next poll -- reads as a bug, not a feature. - Epic swimlanes: stories grouped client-side by epic_id (incl. an "Unassigned" lane) with a DONE/total progress meter per epic, built from data the board already fetches (avoids N+1 GET /api/epics/{id}/stories calls for a result that's a strict subset of the already-loaded list). - Story detail modal: metadata/spec/acceptance criteria, a BFS-layered plain- SVG DAG of the story's task-tree (rows = BFS depth from root_task_id over both parent_task_id and depends_on edges, solid vs. dashed strokes distinguishing the two edge kinds), an event timeline rendering eval_verdict/arbitration_decided/human_accepted/retro_captured/ epic_proposed with human-readable text, and an Accept button wired to POST /api/stories/{id}/accept when status is REVIEW_READY. - DAG node coloring reuses the app's existing --state-* CSS custom properties (the dataviz skill's "reserved status palette" pattern, already used elsewhere in the app) rather than introducing a new palette. Running the skill's own palette validator against that pre-existing 9-color set fails its CVD/lightness checks (a pre-existing condition, out of scope to fix here since those colors are used elsewhere in the app) -- mitigated per the skill's documented remedy for a failing palette: every node always carries a text label (name + role + state) and a full legend, so identity never depends on color alone; edge kind is encoded by stroke style, not color. Verified with a real running server and a real headless-browser session (Chromium via playwright-core, offline-cached), not just code review: seeded real data (an epic, 11 stories across every lifecycle status, a 6-node Builder->4-Evaluator->Arbitration task tree, 4 story events) via the live REST API plus two throwaway fixture scripts for the two write paths the API doesn't expose (task depends_on, direct event writes), then confirmed via screenshots/DOM assertions: correct column placement for every status, working swimlane grouping/progress meter, correct 6-node/8-edge DAG render, working event timeline, a real Accept-button API call flipping REVIEW_READY->DONE, real drag-and-drop priority persistence (re-fetched to confirm), and a genuine cross-column-drag no-op. Caught and fixed a real bug during this process: DAG node-label truncation now measures getComputedTextLength() and binary-searches the longest fit, instead of a fixed character-count budget that collapsed every "Eval: evaluator_X" label to "Eval:..." (only one space in the string). go build ./... clean; node --test web/test/*.mjs -- 275 tests pass (19 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'web/test')
-rw-r--r--web/test/stories-board.test.mjs197
1 files changed, 197 insertions, 0 deletions
diff --git a/web/test/stories-board.test.mjs b/web/test/stories-board.test.mjs
new file mode 100644
index 0000000..482628f
--- /dev/null
+++ b/web/test/stories-board.test.mjs
@@ -0,0 +1,197 @@
+// stories-board.test.mjs — Unit tests for the Phase 9a Stories tab's pure
+// logic: column grouping/sorting, eval-verdict counting, DAG BFS-depth
+// layout, and the new story-related event-timeline text formatting.
+//
+// Run with: node --test web/test/stories-board.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ STORY_COLUMNS,
+ columnForStatus,
+ groupStoriesByColumn,
+ storyPriorityComparator,
+ storyHasReachedValidation,
+ countEvalVerdicts,
+ computeTaskTreeDepths,
+ layoutTaskTree,
+ formatEventText,
+} from '../app.js';
+
+describe('columnForStatus', () => {
+ it('maps each documented status to a column', () => {
+ assert.equal(columnForStatus('DISCOVERY'), 'discovery');
+ assert.equal(columnForStatus('FRAMING'), 'discovery');
+ assert.equal(columnForStatus('BACKLOG'), 'backlog');
+ assert.equal(columnForStatus('PRIORITIZED'), 'prioritized');
+ assert.equal(columnForStatus('IN_PROGRESS'), 'in_progress');
+ assert.equal(columnForStatus('SHIPPABLE'), 'in_progress');
+ assert.equal(columnForStatus('DEPLOYED'), 'in_progress');
+ assert.equal(columnForStatus('VALIDATING'), 'validating');
+ assert.equal(columnForStatus('REVIEW_READY'), 'validating');
+ assert.equal(columnForStatus('DONE'), 'done');
+ assert.equal(columnForStatus('NEEDS_FIX'), 'issues');
+ assert.equal(columnForStatus('CANCELLED'), 'issues');
+ });
+
+ it('falls back to backlog for an unrecognized/empty status', () => {
+ assert.equal(columnForStatus(''), 'backlog');
+ assert.equal(columnForStatus(undefined), 'backlog');
+ assert.equal(columnForStatus('SOME_FUTURE_STATUS'), 'backlog');
+ });
+
+ it('every column key is unique and covers the full lifecycle', () => {
+ const keys = STORY_COLUMNS.map(c => c.key);
+ assert.equal(new Set(keys).size, keys.length);
+ const allStatuses = STORY_COLUMNS.flatMap(c => c.statuses);
+ for (const s of [
+ 'DISCOVERY', 'FRAMING', 'BACKLOG', 'PRIORITIZED', 'IN_PROGRESS',
+ 'SHIPPABLE', 'DEPLOYED', 'VALIDATING', 'REVIEW_READY', 'NEEDS_FIX',
+ 'DONE', 'CANCELLED',
+ ]) {
+ assert.ok(allStatuses.includes(s), `${s} missing from any column`);
+ }
+ });
+});
+
+describe('storyPriorityComparator / groupStoriesByColumn', () => {
+ it('sorts numeric priority ascending', () => {
+ const a = { id: 'a', priority: '20', created_at: '2024-01-01T00:00:00Z' };
+ const b = { id: 'b', priority: '10', created_at: '2024-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(a, b) > 0);
+ assert.ok(storyPriorityComparator(b, a) < 0);
+ });
+
+ it('sorts missing/non-numeric priority after numeric ones', () => {
+ const numeric = { id: 'n', priority: '0', created_at: '2024-01-01T00:00:00Z' };
+ const missing = { id: 'm', priority: '', created_at: '2023-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(numeric, missing) < 0);
+ });
+
+ it('breaks ties by created_at ascending', () => {
+ const older = { id: 'o', priority: '5', created_at: '2023-01-01T00:00:00Z' };
+ const newer = { id: 'n', priority: '5', created_at: '2024-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(older, newer) < 0);
+ });
+
+ it('groups and sorts stories into their column', () => {
+ const stories = [
+ { id: '1', status: 'BACKLOG', priority: '10', created_at: '2024-01-01T00:00:00Z' },
+ { id: '2', status: 'BACKLOG', priority: '0', created_at: '2024-01-01T00:00:00Z' },
+ { id: '3', status: 'DONE', priority: '0', created_at: '2024-01-01T00:00:00Z' },
+ ];
+ const groups = groupStoriesByColumn(stories);
+ assert.deepEqual(groups.backlog.map(s => s.id), ['2', '1']);
+ assert.deepEqual(groups.done.map(s => s.id), ['3']);
+ assert.equal(groups.discovery.length, 0);
+ });
+});
+
+describe('storyHasReachedValidation / countEvalVerdicts', () => {
+ it('is true only from VALIDATING onward', () => {
+ assert.equal(storyHasReachedValidation('BACKLOG'), false);
+ assert.equal(storyHasReachedValidation('IN_PROGRESS'), false);
+ assert.equal(storyHasReachedValidation('VALIDATING'), true);
+ assert.equal(storyHasReachedValidation('REVIEW_READY'), true);
+ assert.equal(storyHasReachedValidation('NEEDS_FIX'), true);
+ assert.equal(storyHasReachedValidation('DONE'), true);
+ });
+
+ it('counts only eval_verdict events', () => {
+ const events = [
+ { kind: 'eval_verdict' },
+ { kind: 'eval_verdict' },
+ { kind: 'state_change' },
+ { kind: 'arbitration_decided' },
+ ];
+ assert.equal(countEvalVerdicts(events), 2);
+ assert.equal(countEvalVerdicts([]), 0);
+ assert.equal(countEvalVerdicts(null), 0);
+ });
+});
+
+describe('computeTaskTreeDepths / layoutTaskTree', () => {
+ const nodes = [
+ { id: 'root', parent_task_id: '', depends_on: [] },
+ { id: 'child1', parent_task_id: 'root', depends_on: [] },
+ { id: 'child2', parent_task_id: 'root', depends_on: [] },
+ { id: 'dependent', parent_task_id: '', depends_on: ['child1', 'child2'] },
+ ];
+
+ it('assigns depth 0 to root and increasing depth via parent/depends_on edges', () => {
+ const depths = computeTaskTreeDepths(nodes, 'root');
+ assert.equal(depths.get('root'), 0);
+ assert.equal(depths.get('child1'), 1);
+ assert.equal(depths.get('child2'), 1);
+ assert.equal(depths.get('dependent'), 2);
+ });
+
+ it('falls back to depth 0 for everything when root is unresolvable', () => {
+ const depths = computeTaskTreeDepths(nodes, 'missing-root');
+ for (const n of nodes) assert.equal(depths.get(n.id), 0);
+ });
+
+ it('places isolated/unreachable nodes past the max depth rather than dropping them', () => {
+ const withOrphan = [...nodes, { id: 'orphan', parent_task_id: 'nonexistent', depends_on: [] }];
+ const depths = computeTaskTreeDepths(withOrphan, 'root');
+ assert.equal(depths.get('orphan'), 3); // maxDepth(2) + 1
+ });
+
+ it('lays out nodes with non-overlapping rows/columns', () => {
+ const { positions, width, height } = layoutTaskTree(nodes, 'root');
+ assert.equal(positions.get('root').y, 0);
+ assert.equal(positions.get('child1').y, positions.get('child2').y);
+ assert.notEqual(positions.get('child1').x, positions.get('child2').x);
+ assert.ok(positions.get('dependent').y > positions.get('child1').y);
+ assert.ok(width > 0);
+ assert.ok(height > 0);
+ });
+
+ it('handles a single-node tree', () => {
+ const single = [{ id: 'only', parent_task_id: '', depends_on: [] }];
+ const { positions, width, height } = layoutTaskTree(single, 'only');
+ assert.deepEqual(positions.get('only'), { x: 0, y: 0 });
+ assert.ok(width > 0 && height > 0);
+ });
+});
+
+describe('formatEventText — story/planning-layer event kinds', () => {
+ it('formats eval_verdict', () => {
+ assert.equal(
+ formatEventText({ kind: 'eval_verdict', payload: { role: 'evaluator_security', summary: 'No issues found' } }),
+ 'Eval verdict (evaluator_security): No issues found',
+ );
+ });
+
+ it('formats arbitration_decided', () => {
+ assert.equal(
+ formatEventText({ kind: 'arbitration_decided', payload: { summary: 'Ship it' } }),
+ 'Arbitration decided: Ship it',
+ );
+ });
+
+ it('formats human_accepted', () => {
+ assert.equal(
+ formatEventText({ kind: 'human_accepted', payload: { from: 'REVIEW_READY', to: 'DONE' } }),
+ 'Accepted: REVIEW_READY → DONE',
+ );
+ });
+
+ it('formats retro_captured with proposal count', () => {
+ assert.equal(
+ formatEventText({ kind: 'retro_captured', payload: { proposals: [{ role: 'builder', version: 2 }], summary: 'Went well' } }),
+ 'Retro captured (1 config proposal): Went well',
+ );
+ assert.equal(
+ formatEventText({ kind: 'retro_captured', payload: { proposals: [], summary: 'No changes needed' } }),
+ 'Retro captured (0 config proposals): No changes needed',
+ );
+ });
+
+ it('formats epic_proposed', () => {
+ assert.equal(
+ formatEventText({ kind: 'epic_proposed', payload: { name: 'Search revamp', story_ids: ['a', 'b', 'c'] } }),
+ 'Epic proposed: Search revamp (3 stories)',
+ );
+ });
+});