// 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, createTaskCard, } 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`); } assert.equal(new Set(allStates).size, allStates.length, 'a state must map to exactly one 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('sorts a task with no created_at LAST in an ascending column (queue)', () => { const tasks = [ { id: 'no-date', state: 'PENDING' }, { id: 'has-date', state: 'QUEUED', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.queue.map(t => t.id), ['has-date', 'no-date']); }); it('sorts a task with no created_at LAST in a descending column (interrupted)', () => { const tasks = [ { id: 'no-date', state: 'FAILED' }, { id: 'has-date', state: 'CANCELLED', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.interrupted.map(t => t.id), ['has-date', 'no-date']); }); it('handles null/undefined gracefully', () => { const groups = groupTasksByColumn(null); for (const col of TASK_COLUMNS) { assert.equal(groups[col.key].length, 0); } }); }); // ── createTaskCard: log-tail placeholder + elapsed timer ──────────────────── // // createTaskCard uses the real global `document` by default (existing // convention throughout app.js — e.g. renderEventTimeline(events, doc = // document)) but accepts an injectable `doc` for Node-based unit testing // without jsdom, matching the hand-rolled mock-DOM convention already used // by web/test/task-panel-summary.test.mjs and web/test/render-dedup.test.mjs. function makeMockDoc() { function makeEl(tag) { return { tag, className: '', classList: { _set: new Set(), add(...cls) { cls.forEach(c => this._set.add(c)); }, toggle(cls, on) { on ? this._set.add(cls) : this._set.delete(cls); }, contains(cls) { return this._set.has(cls); }, }, textContent: '', title: '', hidden: false, dataset: {}, children: [], _listeners: {}, appendChild(child) { this.children.push(child); return child; }, append(...nodes) { nodes.forEach(n => this.children.push(n)); }, prepend(...nodes) { this.children.unshift(...nodes); }, addEventListener(type, fn) { this._listeners[type] = fn; }, querySelector(sel) { const cls = sel.split(',')[0].trim().replace(/^\./, ''); const search = (el) => { if (el.className && el.className.split(' ').includes(cls)) return el; if (el.dataset && sel.includes('[data-started-at]') && el.className.includes('task-elapsed') && 'startedAt' in el.dataset) return el; for (const c of el.children) { const found = search(c); if (found) return found; } return null; }; return search(this); }, }; } return { createElement: (tag) => makeEl(tag) }; } describe('createTaskCard log-tail placeholder', () => { it('shows a "waiting to start" placeholder for PENDING/QUEUED tasks (no execution exists yet)', () => { const doc = makeMockDoc(); for (const state of ['PENDING', 'QUEUED']) { const card = createTaskCard({ id: 't1', name: 'Task', state }, doc); const placeholder = card.querySelector('.task-log-tail-placeholder'); const tail = card.querySelector('.task-log-tail'); assert.ok(placeholder, `expected a placeholder for ${state}`); assert.equal(tail, null, `expected no .task-log-tail element for ${state}`); } }); it('includes a .task-log-tail element for every non-Queue state', () => { const doc = makeMockDoc(); for (const state of ['RUNNING', 'BLOCKED', 'READY', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED']) { const card = createTaskCard({ id: 't1', name: 'Task', state }, doc); const tail = card.querySelector('.task-log-tail'); assert.ok(tail, `expected .task-log-tail for ${state}`); } }); }); describe('createTaskCard elapsed timer', () => { it('includes a .task-elapsed[data-started-at] element only for RUNNING tasks', () => { const doc = makeMockDoc(); const running = createTaskCard({ id: 't1', name: 'Task', state: 'RUNNING', updated_at: '2026-01-01T00:00:00Z' }, doc); assert.ok(running.querySelector('.task-elapsed[data-started-at]')); const ready = createTaskCard({ id: 't2', name: 'Task', state: 'READY' }, doc); assert.equal(ready.querySelector('.task-elapsed[data-started-at]'), null); }); });