summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
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)',
+ );
+ });
+});