|
2 | 2 | // |
3 | 3 | // Verifies: |
4 | 4 | // - debounce() collapses rapid-fire calls into one |
5 | | -// - PanelLayoutManager wires the resize listener via _onResizeDebounced |
6 | | -// - destroy() cancels the debounce timer |
| 5 | +// - PanelLayoutManager wires resize through the production helper |
| 6 | +// - destroy() cancels pending resize work |
7 | 7 |
|
8 | 8 | import { describe, it } from 'node:test'; |
9 | 9 | import assert from 'node:assert/strict'; |
10 | 10 | import { readFileSync } from 'node:fs'; |
11 | 11 | import { resolve, dirname } from 'node:path'; |
12 | 12 | import { fileURLToPath } from 'node:url'; |
| 13 | +import { |
| 14 | + addDebouncedResizeListener, |
| 15 | + removeDebouncedResizeListener, |
| 16 | +} from '../src/app/debounced-resize-listener.ts'; |
| 17 | +import { debounce } from '../src/utils/debounce.ts'; |
13 | 18 |
|
14 | 19 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
15 | 20 | const panelLayoutSrc = readFileSync( |
16 | 21 | resolve(__dirname, '../src/app/panel-layout.ts'), |
17 | 22 | 'utf-8' |
18 | 23 | ); |
19 | 24 |
|
20 | | -// ------------------------------------------------------------------ |
21 | | -// Inline debounce (mirrors src/utils/index.ts) to test behaviour |
22 | | -// without pulling in import.meta.env-dependent modules |
23 | | -// ------------------------------------------------------------------ |
24 | | -/** @returns {{ cancel: () => void }} */ |
25 | | -function debounce(fn, delay) { |
26 | | - let timeoutId; |
27 | | - const debounced = (..._args) => { |
28 | | - clearTimeout(timeoutId); |
29 | | - timeoutId = setTimeout(() => fn(), delay); |
30 | | - }; |
31 | | - debounced.cancel = () => { clearTimeout(timeoutId); }; |
32 | | - return debounced; |
33 | | -} |
34 | | - |
35 | | -describe('debounce utility (inline — mirrors src/utils/index.ts)', () => { |
| 25 | +describe('debounce utility', () => { |
36 | 26 | it('does NOT call fn before delay elapses', () => { |
37 | 27 | let called = false; |
38 | 28 | const debounced = debounce(() => { called = true; }, 100); |
@@ -69,6 +59,59 @@ describe('debounce utility (inline — mirrors src/utils/index.ts)', () => { |
69 | 59 | }); |
70 | 60 | }); |
71 | 61 |
|
| 62 | +describe('debounced resize listener helper', () => { |
| 63 | + it('collapses a real resize event burst into one delayed ensure call', async () => { |
| 64 | + const target = new EventTarget(); |
| 65 | + let callCount = 0; |
| 66 | + const listener = addDebouncedResizeListener(target, () => { callCount++; }, 40); |
| 67 | + |
| 68 | + for (let i = 0; i < 8; i++) { |
| 69 | + target.dispatchEvent(new Event('resize')); |
| 70 | + } |
| 71 | + |
| 72 | + assert.strictEqual(callCount, 0, 'resize burst must not call synchronously'); |
| 73 | + |
| 74 | + await new Promise(r => setTimeout(r, 20)); |
| 75 | + target.dispatchEvent(new Event('resize')); |
| 76 | + assert.strictEqual(callCount, 0, 'mid-burst resize must reset the timer'); |
| 77 | + |
| 78 | + await new Promise(r => setTimeout(r, 70)); |
| 79 | + assert.strictEqual(callCount, 1, 'resize burst must collapse to one delayed call'); |
| 80 | + |
| 81 | + removeDebouncedResizeListener(target, listener); |
| 82 | + }); |
| 83 | + |
| 84 | + it('re-init removal cancels the old pending listener before a new one is assigned', async () => { |
| 85 | + const target = new EventTarget(); |
| 86 | + let callCount = 0; |
| 87 | + |
| 88 | + const firstListener = addDebouncedResizeListener(target, () => { callCount++; }, 40); |
| 89 | + target.dispatchEvent(new Event('resize')); |
| 90 | + |
| 91 | + removeDebouncedResizeListener(target, firstListener); |
| 92 | + const secondListener = addDebouncedResizeListener(target, () => { callCount++; }, 40); |
| 93 | + target.dispatchEvent(new Event('resize')); |
| 94 | + |
| 95 | + await new Promise(r => setTimeout(r, 70)); |
| 96 | + assert.strictEqual(callCount, 1, 'only the new listener timer should fire'); |
| 97 | + |
| 98 | + removeDebouncedResizeListener(target, secondListener); |
| 99 | + }); |
| 100 | + |
| 101 | + it('destroy removal removes the listener and cancels pending resize work', async () => { |
| 102 | + const target = new EventTarget(); |
| 103 | + let callCount = 0; |
| 104 | + const listener = addDebouncedResizeListener(target, () => { callCount++; }, 40); |
| 105 | + |
| 106 | + target.dispatchEvent(new Event('resize')); |
| 107 | + removeDebouncedResizeListener(target, listener); |
| 108 | + target.dispatchEvent(new Event('resize')); |
| 109 | + |
| 110 | + await new Promise(r => setTimeout(r, 70)); |
| 111 | + assert.strictEqual(callCount, 0, 'destroy cleanup must cancel pending and future resize work'); |
| 112 | + }); |
| 113 | +}); |
| 114 | + |
72 | 115 | // ------------------------------------------------------------------ |
73 | 116 | // Live lifecycle tests — replaces source-text regex tests which cannot |
74 | 117 | // detect ordering bugs or re-init ghost calls. |
@@ -154,50 +197,44 @@ describe('resize debounce lifecycle (live instance)', () => { |
154 | 197 | describe('resize debounce wiring (panel-layout.ts)', () => { |
155 | 198 | it('declares _onResizeDebounced nullable field', () => { |
156 | 199 | assert.ok( |
157 | | - /private _onResizeDebounced:\s*\(\(\)\s*=>\s*void\s*\)\s*&\s*\{\s*cancel\(\):\s*void\s*\}\s*\|\s*null\s*=\s*null/.test( |
| 200 | + /private _onResizeDebounced:\s*DebouncedResizeListener\s*\|\s*null\s*=\s*null/.test( |
158 | 201 | panelLayoutSrc |
159 | 202 | ), |
160 | 203 | '_onResizeDebounced field not found with correct type signature' |
161 | 204 | ); |
162 | 205 | }); |
163 | 206 |
|
164 | | - it('init sets _onResizeDebounced = debounce(ensureCorrectZones, 100)', () => { |
| 207 | + it('init installs resize through addDebouncedResizeListener', () => { |
165 | 208 | assert.ok( |
166 | | - /this\._onResizeDebounced\s*=\s*debounce\(\(\)\s*=>\s*this\.ensureCorrectZones\(\),\s*100\)/.test( |
| 209 | + /this\._onResizeDebounced\s*=\s*addDebouncedResizeListener\(\s*window,\s*\(\)\s*=>\s*this\.ensureCorrectZones\(\)\s*\)/.test( |
167 | 210 | panelLayoutSrc |
168 | 211 | ), |
169 | | - 'debounce(ensureCorrectZones, 100) assignment not found' |
| 212 | + 'addDebouncedResizeListener(ensureCorrectZones) assignment not found' |
170 | 213 | ); |
171 | 214 | }); |
172 | 215 |
|
173 | | - it('addEventListener uses _onResizeDebounced (not bare ensureCorrectZones)', () => { |
174 | | - const addLine = panelLayoutSrc |
175 | | - .split('\n') |
176 | | - .find(l => l.includes("addEventListener") && l.includes("'resize'")); |
177 | | - assert.ok(addLine, "resize addEventListener line not found"); |
178 | | - assert.ok( |
179 | | - /_onResizeDebounced/.test(addLine), |
180 | | - `resize listener must use _onResizeDebounced. Found: ${addLine.trim()}` |
181 | | - ); |
| 216 | + it('init removes the old resize helper before installing a replacement', () => { |
182 | 217 | assert.ok( |
183 | | - !/\(\)\s*=>\s*this\.ensureCorrectZones\(\)/.test(addLine), |
184 | | - 'resize listener must not use bare arrow fn (the original bug)' |
| 218 | + /removeDebouncedResizeListener\(\s*window,\s*this\._onResizeDebounced\s*\);\s*this\._onResizeDebounced\s*=\s*addDebouncedResizeListener/s.test( |
| 219 | + panelLayoutSrc |
| 220 | + ), |
| 221 | + 'init must remove/cancel the previous resize listener before replacement' |
185 | 222 | ); |
186 | 223 | }); |
187 | 224 |
|
188 | | - it('destroy() calls _onResizeDebounced?.cancel()', () => { |
| 225 | + it('does not keep the original bare resize arrow listener', () => { |
189 | 226 | assert.ok( |
190 | | - /_onResizeDebounced\?\.cancel\(\)/.test(panelLayoutSrc), |
191 | | - 'destroy() must call _onResizeDebounced?.cancel()' |
| 227 | + !/window\.addEventListener\s*\(\s*['"]resize['"]\s*,\s*\(\)\s*=>\s*this\.ensureCorrectZones\(\)\s*\)/.test(panelLayoutSrc), |
| 228 | + 'resize listener must not use bare arrow fn (the original bug)' |
192 | 229 | ); |
193 | 230 | }); |
194 | 231 |
|
195 | | - it('destroy() removes listener via _onResizeDebounced reference', () => { |
| 232 | + it('destroy() removes the resize helper and nulls the field', () => { |
196 | 233 | assert.ok( |
197 | | - /window\.removeEventListener\s*\(\s*['"]resize['"]\s*,\s*this\._onResizeDebounced/.test( |
| 234 | + /removeDebouncedResizeListener\(\s*window,\s*this\._onResizeDebounced\s*\);\s*this\._onResizeDebounced\s*=\s*null/s.test( |
198 | 235 | panelLayoutSrc |
199 | 236 | ), |
200 | | - 'destroy() must remove resize listener via _onResizeDebounced' |
| 237 | + 'destroy() must remove/cancel the resize listener and null the field' |
201 | 238 | ); |
202 | 239 | }); |
203 | 240 | }); |
0 commit comments