diff options
| -rw-r--r-- | web/app.js | 54 | ||||
| -rw-r--r-- | web/test/tab-persistence.test.mjs | 8 | ||||
| -rw-r--r-- | web/test/tasks-board.test.mjs | 129 |
3 files changed, 187 insertions, 4 deletions
@@ -3350,6 +3350,60 @@ export function groupStoriesByColumn(stories) { return groups; } +// --------------------------------------------------------------------------- +// Tasks board — column model +// --------------------------------------------------------------------------- + +export const TASK_COLUMNS = [ + { key: 'queue', label: 'Queue', states: ['PENDING', 'QUEUED'] }, + { key: 'running', label: 'Running', states: ['RUNNING', 'BLOCKED'] }, + { key: 'ready', label: 'Ready', states: ['READY'] }, + { key: 'interrupted', label: 'Interrupted', states: ['FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED'] }, + { key: 'done', label: 'Done', states: ['COMPLETED'] }, +]; + +// columnForTaskState returns the column key for a given task state, falling +// back to 'queue' for an empty/unrecognized state so a task is never dropped +// off the board entirely. +export function columnForTaskState(state) { + const col = TASK_COLUMNS.find(c => c.states.includes(state)); + return col ? col.key : 'queue'; +} + +// Sort directions are per-column, matching the semantics the old (removed) +// panels used: +// queue — oldest-first (FIFO) +// ready — oldest-first (longest-waiting surfaces first) +// interrupted — newest-first (most recent failure is most urgent) +// done — newest-first (most recently completed is most relevant) +// running — newest-first (new-but-inconsequential default) +const TASK_COLUMN_SORT = { + queue: (a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0), + running: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0), + ready: (a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0), + interrupted: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0), + done: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0), +}; + +// groupTasksByColumn returns { [columnKey]: task[] }, each sub-array sorted +// per the per-column sort direction above. +export function groupTasksByColumn(tasks) { + const groups = {}; + for (const col of TASK_COLUMNS) groups[col.key] = []; + for (const t of tasks || []) { + const key = columnForTaskState(t.state); + if (!groups[key]) groups[key] = []; + groups[key].push(t); + } + for (const col of TASK_COLUMNS) { + const cmp = TASK_COLUMN_SORT[col.key]; + if (cmp) groups[col.key].sort(cmp); + } + return groups; +} + +// --------------------------------------------------------------------------- + // A story "has reached validation" once evaluator verdicts could plausibly // exist for it — VALIDATING or later in the lifecycle (see StoryOrchestrator // stage 2 in CLAUDE.md). Used to gate the eval-verdict indicator so we don't diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs index 9311453..972723c 100644 --- a/web/test/tab-persistence.test.mjs +++ b/web/test/tab-persistence.test.mjs @@ -20,8 +20,8 @@ import { getActiveMainTab, setActiveMainTab } from '../app.js'; describe('getActiveMainTab', () => { beforeEach(() => store.clear()); - it('returns "queue" when localStorage has no stored value', () => { - assert.equal(getActiveMainTab(), 'queue'); + it('returns "stories" when localStorage has no stored value', () => { + assert.equal(getActiveMainTab(), 'stories'); }); it('returns the tab name stored by setActiveMainTab', () => { @@ -29,10 +29,10 @@ describe('getActiveMainTab', () => { assert.equal(getActiveMainTab(), 'settings'); }); - it('returns "queue" after localStorage value is removed', () => { + it('returns "stories" after localStorage value is removed', () => { setActiveMainTab('stats'); localStorage.removeItem('activeMainTab'); - assert.equal(getActiveMainTab(), 'queue'); + assert.equal(getActiveMainTab(), 'stories'); }); it('reflects the most recent setActiveMainTab call', () => { diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs new file mode 100644 index 0000000..85bb04a --- /dev/null +++ b/web/test/tasks-board.test.mjs @@ -0,0 +1,129 @@ +// tasks-board.test.mjs — Unit tests for the Tasks tab's pure column logic: +// column-mapping completeness, fallback-state behaviour, and per-column +// grouping/sorting correctness. +// +// Run with: node --test web/test/tasks-board.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + TASK_COLUMNS, + columnForTaskState, + groupTasksByColumn, +} from '../app.js'; + +describe('columnForTaskState', () => { + it('maps every documented task state to a column', () => { + assert.equal(columnForTaskState('PENDING'), 'queue'); + assert.equal(columnForTaskState('QUEUED'), 'queue'); + assert.equal(columnForTaskState('RUNNING'), 'running'); + assert.equal(columnForTaskState('BLOCKED'), 'running'); + assert.equal(columnForTaskState('READY'), 'ready'); + assert.equal(columnForTaskState('FAILED'), 'interrupted'); + assert.equal(columnForTaskState('TIMED_OUT'), 'interrupted'); + assert.equal(columnForTaskState('CANCELLED'), 'interrupted'); + assert.equal(columnForTaskState('BUDGET_EXCEEDED'), 'interrupted'); + assert.equal(columnForTaskState('COMPLETED'), 'done'); + }); + + it('falls back to queue for an unrecognized/empty state', () => { + assert.equal(columnForTaskState(''), 'queue'); + assert.equal(columnForTaskState(undefined), 'queue'); + assert.equal(columnForTaskState('SOME_FUTURE_STATE'), 'queue'); + }); + + it('every column key is unique and covers all 10 task states', () => { + const keys = TASK_COLUMNS.map(c => c.key); + assert.equal(new Set(keys).size, keys.length, 'column keys must be unique'); + const allStates = TASK_COLUMNS.flatMap(c => c.states); + for (const s of [ + 'PENDING', 'QUEUED', 'RUNNING', 'BLOCKED', 'READY', + 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED', + ]) { + assert.ok(allStates.includes(s), `${s} missing from any column`); + } + }); +}); + +describe('groupTasksByColumn', () => { + it('returns an object with a key per column, all initially empty arrays', () => { + const groups = groupTasksByColumn([]); + for (const col of TASK_COLUMNS) { + assert.ok(Array.isArray(groups[col.key]), `groups.${col.key} should be an array`); + assert.equal(groups[col.key].length, 0); + } + }); + + it('places tasks in the correct column', () => { + const tasks = [ + { id: '1', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' }, + { id: '2', state: 'RUNNING', created_at: '2024-01-02T00:00:00Z' }, + { id: '3', state: 'READY', created_at: '2024-01-03T00:00:00Z' }, + { id: '4', state: 'FAILED', created_at: '2024-01-04T00:00:00Z' }, + { id: '5', state: 'COMPLETED', created_at: '2024-01-05T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.equal(groups.queue.length, 1); + assert.equal(groups.queue[0].id, '1'); + assert.equal(groups.running.length, 1); + assert.equal(groups.running[0].id, '2'); + assert.equal(groups.ready.length, 1); + assert.equal(groups.ready[0].id, '3'); + assert.equal(groups.interrupted.length, 1); + assert.equal(groups.interrupted[0].id, '4'); + assert.equal(groups.done.length, 1); + assert.equal(groups.done[0].id, '5'); + }); + + it('sorts queue oldest-first (ascending created_at)', () => { + const tasks = [ + { id: 'newer', state: 'QUEUED', created_at: '2024-02-01T00:00:00Z' }, + { id: 'older', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.deepEqual(groups.queue.map(t => t.id), ['older', 'newer']); + }); + + it('sorts ready oldest-first (ascending created_at)', () => { + const tasks = [ + { id: 'newer', state: 'READY', created_at: '2024-02-01T00:00:00Z' }, + { id: 'older', state: 'READY', created_at: '2024-01-01T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.deepEqual(groups.ready.map(t => t.id), ['older', 'newer']); + }); + + it('sorts interrupted newest-first (descending created_at)', () => { + const tasks = [ + { id: 'older', state: 'FAILED', created_at: '2024-01-01T00:00:00Z' }, + { id: 'newer', state: 'CANCELLED', created_at: '2024-02-01T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.deepEqual(groups.interrupted.map(t => t.id), ['newer', 'older']); + }); + + it('sorts done newest-first (descending created_at)', () => { + const tasks = [ + { id: 'older', state: 'COMPLETED', created_at: '2024-01-01T00:00:00Z' }, + { id: 'newer', state: 'COMPLETED', created_at: '2024-02-01T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.deepEqual(groups.done.map(t => t.id), ['newer', 'older']); + }); + + it('sorts running newest-first (descending created_at)', () => { + const tasks = [ + { id: 'older', state: 'RUNNING', created_at: '2024-01-01T00:00:00Z' }, + { id: 'newer', state: 'BLOCKED', created_at: '2024-02-01T00:00:00Z' }, + ]; + const groups = groupTasksByColumn(tasks); + assert.deepEqual(groups.running.map(t => t.id), ['newer', 'older']); + }); + + it('handles null/undefined gracefully', () => { + const groups = groupTasksByColumn(null); + for (const col of TASK_COLUMNS) { + assert.equal(groups[col.key].length, 0); + } + }); +}); |
