1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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');
});
});
|