diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 20:46:35 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 20:46:35 +0000 |
| commit | 188fe9b078e263bb6b648d7949f92dcb7b899953 (patch) | |
| tree | 6df6cd38bf1925ff702fed479630ba83a7b260b4 | |
| parent | 095db94030455bde497f18da94d0b404dcf042ea (diff) | |
web/test: add active-pane, focus-preserve, is-user-editing, render-dedup tests
Unit tests for UI helper functions: active pane detection, input focus
preservation during polls, user-editing guard, and render deduplication.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | web/test/active-pane.test.mjs | 81 | ||||
| -rw-r--r-- | web/test/focus-preserve.test.mjs | 170 | ||||
| -rw-r--r-- | web/test/is-user-editing.test.mjs | 65 | ||||
| -rw-r--r-- | web/test/render-dedup.test.mjs | 125 |
4 files changed, 441 insertions, 0 deletions
diff --git a/web/test/active-pane.test.mjs b/web/test/active-pane.test.mjs new file mode 100644 index 0000000..37bb8c5 --- /dev/null +++ b/web/test/active-pane.test.mjs @@ -0,0 +1,81 @@ +// active-pane.test.mjs — Tests for Active pane partition logic. +// +// Run with: node --test web/test/active-pane.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { partitionActivePaneTasks } from '../app.js'; + +function makeTask(id, state, created_at) { + return { id, name: `task-${id}`, state, created_at: created_at ?? `2024-01-01T00:0${id}:00Z` }; +} + +const ALL_STATES = [ + 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED', + 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', +]; + +describe('partitionActivePaneTasks', () => { + it('running contains only RUNNING tasks', () => { + const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s)); + const { running } = partitionActivePaneTasks(tasks); + assert.equal(running.length, 1); + assert.equal(running[0].state, 'RUNNING'); + }); + + it('ready contains only READY tasks', () => { + const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s)); + const { ready } = partitionActivePaneTasks(tasks); + assert.equal(ready.length, 1); + assert.equal(ready[0].state, 'READY'); + }); + + it('excludes QUEUED, BLOCKED, PENDING, COMPLETED, FAILED and all other states', () => { + const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s)); + const { running, ready } = partitionActivePaneTasks(tasks); + const allReturned = [...running, ...ready]; + assert.equal(allReturned.length, 2); + assert.ok(allReturned.every(t => t.state === 'RUNNING' || t.state === 'READY')); + }); + + it('returns empty arrays for empty input', () => { + const { running, ready } = partitionActivePaneTasks([]); + assert.deepEqual(running, []); + assert.deepEqual(ready, []); + }); + + it('handles multiple RUNNING tasks sorted by created_at ascending', () => { + const tasks = [ + makeTask('b', 'RUNNING', '2024-01-01T00:02:00Z'), + makeTask('a', 'RUNNING', '2024-01-01T00:01:00Z'), + makeTask('c', 'RUNNING', '2024-01-01T00:03:00Z'), + ]; + const { running } = partitionActivePaneTasks(tasks); + assert.equal(running.length, 3); + assert.equal(running[0].id, 'a'); + assert.equal(running[1].id, 'b'); + assert.equal(running[2].id, 'c'); + }); + + it('handles multiple READY tasks sorted by created_at ascending', () => { + const tasks = [ + makeTask('y', 'READY', '2024-01-01T00:02:00Z'), + makeTask('x', 'READY', '2024-01-01T00:01:00Z'), + ]; + const { ready } = partitionActivePaneTasks(tasks); + assert.equal(ready.length, 2); + assert.equal(ready[0].id, 'x'); + assert.equal(ready[1].id, 'y'); + }); + + it('returns both sections independently when both states present', () => { + const tasks = [ + makeTask('r1', 'RUNNING', '2024-01-01T00:01:00Z'), + makeTask('d1', 'READY', '2024-01-01T00:02:00Z'), + makeTask('r2', 'RUNNING', '2024-01-01T00:03:00Z'), + ]; + const { running, ready } = partitionActivePaneTasks(tasks); + assert.equal(running.length, 2); + assert.equal(ready.length, 1); + }); +}); diff --git a/web/test/focus-preserve.test.mjs b/web/test/focus-preserve.test.mjs new file mode 100644 index 0000000..8acf73c --- /dev/null +++ b/web/test/focus-preserve.test.mjs @@ -0,0 +1,170 @@ +// focus-preserve.test.mjs — contract tests for captureFocusState / restoreFocusState +// +// These pure helpers fix the focus-stealing bug: poll() calls renderTaskList / +// renderActiveTaskList which do container.innerHTML='' on every tick, destroying +// any focused answer input (task-answer-input or question-input). +// +// captureFocusState(container, activeEl) +// Returns {taskId, className, value} if activeEl is a focusable answer input +// inside a .task-card within container. Returns null otherwise. +// +// restoreFocusState(container, state) +// Finds the equivalent input after rebuild and restores .value + .focus(). +// +// Run with: node --test web/test/focus-preserve.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Inline implementations (contract) ───────────────────────────────────────── + +function captureFocusState(container, activeEl) { + if (!activeEl || !container.contains(activeEl)) return null; + const card = activeEl.closest('.task-card'); + if (!card || !card.dataset || !card.dataset.taskId) return null; + return { + taskId: card.dataset.taskId, + className: activeEl.className, + value: activeEl.value || '', + }; +} + +function restoreFocusState(container, state) { + if (!state) return; + const card = container.querySelector(`.task-card[data-task-id="${state.taskId}"]`); + if (!card) return; + const el = card.querySelector(`.${state.className}`); + if (!el) return; + el.value = state.value; + el.focus(); +} + +// ── DOM-like mock helpers ────────────────────────────────────────────────────── + +function makeInput(className, value = '', taskId = 't1') { + const card = { + dataset: { taskId }, + _children: [], + querySelector(sel) { + const cls = sel.replace(/^\./, ''); + return this._children.find(c => c.className === cls) || null; + }, + closest(sel) { + return sel === '.task-card' ? this : null; + }, + }; + const input = { + className, + value, + _focused: false, + focus() { this._focused = true; }, + closest(sel) { return card.closest(sel); }, + }; + card._children.push(input); + return { card, input }; +} + +function makeContainer(cards = []) { + const allInputs = cards.flatMap(c => c._children); + return { + contains(el) { return allInputs.includes(el); }, + querySelector(sel) { + const m = sel.match(/\.task-card\[data-task-id="([^"]+)"\]/); + if (!m) return null; + return cards.find(c => c.dataset.taskId === m[1]) || null; + }, + }; +} + +// ── Tests: captureFocusState ─────────────────────────────────────────────────── + +describe('captureFocusState', () => { + it('returns null when activeEl is null', () => { + assert.strictEqual(captureFocusState(makeContainer([]), null), null); + }); + + it('returns null when activeEl is undefined', () => { + assert.strictEqual(captureFocusState(makeContainer([]), undefined), null); + }); + + it('returns null when activeEl is outside the container', () => { + const { input } = makeInput('task-answer-input', 'hello', 't1'); + const container = makeContainer([]); // empty — input not in it + assert.strictEqual(captureFocusState(container, input), null); + }); + + it('returns null when activeEl has no .task-card ancestor', () => { + const input = { + className: 'task-answer-input', + value: 'hi', + closest() { return null; }, + }; + const container = { contains() { return true; }, querySelector() { return null; } }; + assert.strictEqual(captureFocusState(container, input), null); + }); + + it('returns state for task-answer-input inside a task card', () => { + const { card, input } = makeInput('task-answer-input', 'partial answer', 't42'); + const state = captureFocusState(makeContainer([card]), input); + assert.deepStrictEqual(state, { + taskId: 't42', + className: 'task-answer-input', + value: 'partial answer', + }); + }); + + it('returns state for question-input inside a task card', () => { + const { card, input } = makeInput('question-input', 'my answer', 'q99'); + const state = captureFocusState(makeContainer([card]), input); + assert.deepStrictEqual(state, { + taskId: 'q99', + className: 'question-input', + value: 'my answer', + }); + }); + + it('returns empty string value when input is empty', () => { + const { card, input } = makeInput('task-answer-input', '', 't1'); + const state = captureFocusState(makeContainer([card]), input); + assert.strictEqual(state.value, ''); + }); +}); + +// ── Tests: restoreFocusState ─────────────────────────────────────────────────── + +describe('restoreFocusState', () => { + it('is a no-op when state is null', () => { + restoreFocusState(makeContainer([]), null); // must not throw + }); + + it('is a no-op when state is undefined', () => { + restoreFocusState(makeContainer([]), undefined); // must not throw + }); + + it('is a no-op when task card is no longer in container', () => { + const state = { taskId: 'gone', className: 'task-answer-input', value: 'hi' }; + restoreFocusState(makeContainer([]), state); // must not throw + }); + + it('restores value and focuses task-answer-input', () => { + const { card, input } = makeInput('task-answer-input', '', 't1'); + const state = { taskId: 't1', className: 'task-answer-input', value: 'restored text' }; + restoreFocusState(makeContainer([card]), state); + assert.strictEqual(input.value, 'restored text'); + assert.ok(input._focused, 'input should have been focused'); + }); + + it('restores value and focuses question-input', () => { + const { card, input } = makeInput('question-input', '', 'q7'); + const state = { taskId: 'q7', className: 'question-input', value: 'type answer' }; + restoreFocusState(makeContainer([card]), state); + assert.strictEqual(input.value, 'type answer'); + assert.ok(input._focused); + }); + + it('is a no-op when element className is not found in rebuilt card', () => { + const { card } = makeInput('task-answer-input', '', 't1'); + const state = { taskId: 't1', className: 'nonexistent-class', value: 'hi' }; + restoreFocusState(makeContainer([card]), state); // must not throw + }); +}); diff --git a/web/test/is-user-editing.test.mjs b/web/test/is-user-editing.test.mjs new file mode 100644 index 0000000..844d3cd --- /dev/null +++ b/web/test/is-user-editing.test.mjs @@ -0,0 +1,65 @@ +// is-user-editing.test.mjs — contract tests for isUserEditing() +// +// isUserEditing(activeEl) returns true when the browser has focus in an element +// that a poll-driven DOM refresh would destroy: INPUT, TEXTAREA, contenteditable, +// or any element inside a [role="dialog"]. +// +// Run with: node --test web/test/is-user-editing.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isUserEditing } from '../app.js'; + +// ── Mock helpers ─────────────────────────────────────────────────────────────── + +function makeEl(tagName, extras = {}) { + return { + tagName: tagName.toUpperCase(), + isContentEditable: false, + closest(sel) { return null; }, + ...extras, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('isUserEditing', () => { + it('returns false for null', () => { + assert.strictEqual(isUserEditing(null), false); + }); + + it('returns false for undefined', () => { + assert.strictEqual(isUserEditing(undefined), false); + }); + + it('returns true for INPUT element', () => { + assert.strictEqual(isUserEditing(makeEl('INPUT')), true); + }); + + it('returns true for TEXTAREA element', () => { + assert.strictEqual(isUserEditing(makeEl('TEXTAREA')), true); + }); + + it('returns true for contenteditable element', () => { + assert.strictEqual(isUserEditing(makeEl('DIV', { isContentEditable: true })), true); + }); + + it('returns true for element inside [role="dialog"]', () => { + const el = makeEl('SPAN', { + closest(sel) { return sel === '[role="dialog"]' ? {} : null; }, + }); + assert.strictEqual(isUserEditing(el), true); + }); + + it('returns false for a non-editing BUTTON', () => { + assert.strictEqual(isUserEditing(makeEl('BUTTON')), false); + }); + + it('returns false for a non-editing DIV without contenteditable', () => { + assert.strictEqual(isUserEditing(makeEl('DIV')), false); + }); + + it('returns false for a non-editing SPAN not inside a dialog', () => { + assert.strictEqual(isUserEditing(makeEl('SPAN')), false); + }); +}); diff --git a/web/test/render-dedup.test.mjs b/web/test/render-dedup.test.mjs new file mode 100644 index 0000000..f13abb2 --- /dev/null +++ b/web/test/render-dedup.test.mjs @@ -0,0 +1,125 @@ +// render-dedup.test.mjs — contract tests for renderTaskList dedup logic +// +// Verifies the invariant: renderTaskList must never leave two .task-card elements +// with the same data-task-id in the container. When a card already exists but +// has no input field, the old card must be removed before inserting the new one. +// +// This file uses inline implementations that mirror the contract, not the actual +// DOM (which requires a browser). The test defines the expected behaviour so that +// a regression in app.js would motivate a failing test. +// +// Run with: node --test web/test/render-dedup.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Inline DOM mock ──────────────────────────────────────────────────────────── + +function makeCard(taskId, hasInput = false) { + const card = { + dataset: { taskId }, + _removed: false, + _hasInput: hasInput, + remove() { this._removed = true; }, + querySelector(sel) { + if (!this._hasInput) return null; + // simulate .task-answer-input or .question-input being present + if (sel === '.task-answer-input, .question-input') { + return { className: 'task-answer-input', value: 'partial' }; + } + return null; + }, + }; + return card; +} + +// Minimal container mirroring what renderTaskList works with. +function makeContainer(existingCards = []) { + const cards = [...existingCards]; + const inserted = []; + return { + _cards: cards, + _inserted: inserted, + querySelectorAll(sel) { + if (sel === '.task-card') return [...cards]; + return []; + }, + querySelector(sel) { + const m = sel.match(/\.task-card\[data-task-id="([^"]+)"\]/); + if (!m) return null; + return cards.find(c => c.dataset.taskId === m[1] && !c._removed) || null; + }, + insertBefore(node, ref) { + inserted.push(node); + if (!cards.includes(node)) cards.push(node); + }, + get firstChild() { return cards[0] || null; }, + }; +} + +// The fixed dedup logic extracted from renderTaskList (the contract we enforce). +function selectCardForTask(task, container) { + const existing = container.querySelector(`.task-card[data-task-id="${task.id}"]`); + const hasInput = existing?.querySelector('.task-answer-input, .question-input'); + + let node; + if (existing && hasInput) { + node = existing; // reuse — preserves in-progress input + } else { + if (existing) existing.remove(); // <-- the fix: remove old before inserting new + node = makeCard(task.id, false); // simulates createTaskCard(task) + } + return node; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('renderTaskList dedup logic', () => { + it('creates a new card when no existing card in DOM', () => { + const container = makeContainer([]); + const task = { id: 't1' }; + const node = selectCardForTask(task, container); + assert.equal(node.dataset.taskId, 't1'); + assert.equal(node._removed, false); + }); + + it('removes old card and creates new when existing has no input', () => { + const old = makeCard('t2', false); + const container = makeContainer([old]); + const task = { id: 't2' }; + const node = selectCardForTask(task, container); + + // Old card must be removed to prevent duplication. + assert.equal(old._removed, true, 'old card should be marked removed'); + // New card returned is not the old card. + assert.notEqual(node, old); + assert.equal(node.dataset.taskId, 't2'); + }); + + it('reuses existing card when it has an input (preserves typing)', () => { + const existing = makeCard('t3', true); // has input + const container = makeContainer([existing]); + const task = { id: 't3' }; + const node = selectCardForTask(task, container); + + assert.equal(node, existing, 'should reuse the existing card'); + assert.equal(existing._removed, false, 'existing card should NOT be removed'); + }); + + it('never produces two cards for the same task id', () => { + // Simulate two poll cycles. + const old = makeCard('t4', false); + const container = makeContainer([old]); + const task = { id: 't4' }; + + // First "refresh" — old card has no input, so remove and insert new. + const newCard = selectCardForTask(task, container); + // Simulate insert: mark old as removed (done by remove()), add new. + container._cards.splice(container._cards.indexOf(old), 1); + if (!container._cards.includes(newCard)) container._cards.push(newCard); + + // Verify at most one card with this id exists. + const survivors = container._cards.filter(c => c.dataset.taskId === 't4' && !c._removed); + assert.equal(survivors.length, 1, 'exactly one card for t4 should remain'); + }); +}); |
