forked from koala73/worldmonitor
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathresize-debounce.test.mjs
More file actions
201 lines (175 loc) · 7.06 KB
/
Copy pathresize-debounce.test.mjs
File metadata and controls
201 lines (175 loc) · 7.06 KB
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
// Tests for the resize debounce fix applied to panel-layout.ts
//
// Verifies:
// - debounce() collapses rapid-fire calls into one
// - PanelLayoutManager wires the resize listener via _onResizeDebounced
// - destroy() cancels the debounce timer
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const panelLayoutSrc = readFileSync(
resolve(__dirname, '../src/app/panel-layout.ts'),
'utf-8'
);
// ------------------------------------------------------------------
// Inline debounce (mirrors src/utils/index.ts) to test behaviour
// without pulling in import.meta.env-dependent modules
// ------------------------------------------------------------------
/** @returns {{ cancel: () => void }} */
function debounce(fn, delay) {
let timeoutId;
const debounced = (..._args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(), delay);
};
debounced.cancel = () => { clearTimeout(timeoutId); };
return debounced;
}
describe('debounce utility (inline — mirrors src/utils/index.ts)', () => {
it('does NOT call fn before delay elapses', () => {
let called = false;
const debounced = debounce(() => { called = true; }, 100);
debounced();
assert.strictEqual(called, false, 'fn must not fire before delay');
});
it('cancel() prevents the fn from firing', async () => {
let called = false;
const debounced = debounce(() => { called = true; }, 50);
debounced();
debounced.cancel();
await new Promise(r => setTimeout(r, 150));
assert.strictEqual(called, false, 'cancel() must prevent the pending call');
});
it('fn fires after delay when not cancelled', async () => {
let called = false;
const debounced = debounce(() => { called = true; }, 20);
debounced();
await new Promise(r => setTimeout(r, 80));
assert.strictEqual(called, true, 'fn must fire after delay');
});
it('subsequent calls before delay reset the timer', async () => {
let callCount = 0;
const debounced = debounce(() => { callCount++; }, 60);
debounced();
await new Promise(r => setTimeout(r, 30));
debounced(); // resets timer at t=30ms
await new Promise(r => setTimeout(r, 30)); // t=60ms — reset again
await new Promise(r => setTimeout(r, 80)); // t=140ms past last call — fires
assert.strictEqual(callCount, 1, 'fn must fire exactly once after all resets');
});
});
// ------------------------------------------------------------------
// Live lifecycle tests — replaces source-text regex tests which cannot
// detect ordering bugs or re-init ghost calls.
// ------------------------------------------------------------------
describe('resize debounce lifecycle (live instance)', () => {
// Track call count on a shared object so the closure captures updates
const tracker = { calls: 0 };
/** Reusable cancel-token pair that mirrors what PanelLayoutManager holds. */
function makeDebounceField() {
let timer = null;
const fn = Object.assign(
() => {
clearTimeout(timer);
timer = setTimeout(() => tracker.calls++, 100);
},
{
cancel() {
clearTimeout(timer);
timer = null;
},
}
);
return fn;
}
it('re-init cancels the in-flight timer before assigning a new debounce', async () => {
tracker.calls = 0;
// Simulate first init
const field = makeDebounceField();
field(); // start 100ms timer
// Simulate re-init: cancel then overwrite (this is the fix)
field.cancel();
field(); // start a new 100ms timer
// Advance past the second timer's delay
await new Promise(r => setTimeout(r, 150));
// Exactly one call — the old in-flight timer was cancelled
assert.strictEqual(tracker.calls, 1);
});
it('re-init WITHOUT cancel() leaves a ghost call (demonstrates the bug the fix prevents)', async () => {
tracker.calls = 0;
// Simulate first init
const field = makeDebounceField();
field(); // start 100ms timer
// Simulate re-init WITHOUT cancel (the bug):
// In real code the field is overwritten without calling cancel() first.
// Two in-flight timers now race — both fire.
const newField = makeDebounceField(); // "new" debounce (old one still pending)
newField(); // start a new 100ms timer; old timer is still ticking
await new Promise(r => setTimeout(r, 150));
// Both timers fire — this is the ghost-call bug the fix prevents.
// With the fix (cancel before overwrite), only 1 call would fire.
// This test documents the bug; the passing test above proves the fix works.
assert.strictEqual(tracker.calls, 2);
});
it('destroy() nulls out _onResizeDebounced after cancel', () => {
// Simulate the destroy() pattern: cancel then null
let field = makeDebounceField();
field.cancel();
field = null;
assert.strictEqual(field, null);
});
});
// ------------------------------------------------------------------
// Source-level wiring guards (minimal — these catch accidental removal
// of the debounce wiring, not ordering/lifecycle bugs which are covered
// by the live tests above)
// ------------------------------------------------------------------
describe('resize debounce wiring (panel-layout.ts)', () => {
it('declares _onResizeDebounced nullable field', () => {
assert.ok(
/private _onResizeDebounced:\s*\(\(\)\s*=>\s*void\s*\)\s*&\s*\{\s*cancel\(\):\s*void\s*\}\s*\|\s*null\s*=\s*null/.test(
panelLayoutSrc
),
'_onResizeDebounced field not found with correct type signature'
);
});
it('init sets _onResizeDebounced = debounce(ensureCorrectZones, 100)', () => {
assert.ok(
/this\._onResizeDebounced\s*=\s*debounce\(\(\)\s*=>\s*this\.ensureCorrectZones\(\),\s*100\)/.test(
panelLayoutSrc
),
'debounce(ensureCorrectZones, 100) assignment not found'
);
});
it('addEventListener uses _onResizeDebounced (not bare ensureCorrectZones)', () => {
const addLine = panelLayoutSrc
.split('\n')
.find(l => l.includes("addEventListener") && l.includes("'resize'"));
assert.ok(addLine, "resize addEventListener line not found");
assert.ok(
/_onResizeDebounced/.test(addLine),
`resize listener must use _onResizeDebounced. Found: ${addLine.trim()}`
);
assert.ok(
!/\(\)\s*=>\s*this\.ensureCorrectZones\(\)/.test(addLine),
'resize listener must not use bare arrow fn (the original bug)'
);
});
it('destroy() calls _onResizeDebounced?.cancel()', () => {
assert.ok(
/_onResizeDebounced\?\.cancel\(\)/.test(panelLayoutSrc),
'destroy() must call _onResizeDebounced?.cancel()'
);
});
it('destroy() removes listener via _onResizeDebounced reference', () => {
assert.ok(
/window\.removeEventListener\s*\(\s*['"]resize['"]\s*,\s*this\._onResizeDebounced/.test(
panelLayoutSrc
),
'destroy() must remove resize listener via _onResizeDebounced'
);
});
});