From 188fe9b078e263bb6b648d7949f92dcb7b899953 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 8 Mar 2026 20:46:35 +0000 Subject: 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 --- web/test/focus-preserve.test.mjs | 170 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 web/test/focus-preserve.test.mjs (limited to 'web/test/focus-preserve.test.mjs') 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 + }); +}); -- cgit v1.2.3