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 /web/test/render-dedup.test.mjs | |
| 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>
Diffstat (limited to 'web/test/render-dedup.test.mjs')
| -rw-r--r-- | web/test/render-dedup.test.mjs | 125 |
1 files changed, 125 insertions, 0 deletions
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'); + }); +}); |
