diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 05:31:42 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 05:31:42 +0000 |
| commit | d105eca610b0e737c3313e4978d6a917b4f55d10 (patch) | |
| tree | 2ad75a33abd45ebb8225dcbee01a2d0a8d7a21ed /web/test | |
| parent | 8cb291ad7cdb317ff80947278ee055b1a4925b41 (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.mjs | 197 |
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)', + ); + }); +}); |
