// 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)', ); }); });