summaryrefslogtreecommitdiff
path: root/web/test/render-dedup.test.mjs
blob: f13abb2a4326a94496476f4afa3d62ec355c9fb4 (plain)
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');
  });
});