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