diff options
Diffstat (limited to 'web/test/stories-board.test.mjs')
| -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)', + ); + }); +}); |
