summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 20:46:35 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 20:46:35 +0000
commit188fe9b078e263bb6b648d7949f92dcb7b899953 (patch)
tree6df6cd38bf1925ff702fed479630ba83a7b260b4 /web
parent095db94030455bde497f18da94d0b404dcf042ea (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>
Diffstat (limited to 'web')
-rw-r--r--web/test/active-pane.test.mjs81
-rw-r--r--web/test/focus-preserve.test.mjs170
-rw-r--r--web/test/is-user-editing.test.mjs65
-rw-r--r--web/test/render-dedup.test.mjs125
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');
+ });
+});