// 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 }); });