summaryrefslogtreecommitdiff
path: root/web/test/focus-preserve.test.mjs
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 21:03:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 21:03:50 +0000
commit632ea5a44731af94b6238f330a3b5440906c8ae7 (patch)
treed8c780412598d66b89ef390b5729e379fdfd9d5b /web/test/focus-preserve.test.mjs
parent406247b14985ab57902e8e42898dc8cb8960290d (diff)
parent93a4c852bf726b00e8014d385165f847763fa214 (diff)
merge: pull latest from master and resolve conflicts
- Resolve conflicts in API server, CLI, and executor. - Maintain Gemini classification and assignment logic. - Update UI to use generic agent config and project_dir. - Fix ProjectDir/WorkingDir inconsistencies in Gemini runner. - All tests passing after merge.
Diffstat (limited to 'web/test/focus-preserve.test.mjs')
-rw-r--r--web/test/focus-preserve.test.mjs170
1 files changed, 170 insertions, 0 deletions
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
+ });
+});