Skip to content

Commit c40381b

Browse files
committed
test(panels): exercise debounced resize listener
1 parent 7994431 commit c40381b

5 files changed

Lines changed: 120 additions & 62 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { debounce } from '@/utils/debounce';
2+
3+
export type DebouncedResizeListener = (() => void) & { cancel(): void };
4+
5+
export function addDebouncedResizeListener(
6+
target: EventTarget,
7+
onResize: () => void,
8+
delayMs = 100,
9+
): DebouncedResizeListener {
10+
const listener = debounce(() => onResize(), delayMs);
11+
target.addEventListener('resize', listener);
12+
return listener;
13+
}
14+
15+
export function removeDebouncedResizeListener(
16+
target: EventTarget,
17+
listener: DebouncedResizeListener | null,
18+
): void {
19+
if (!listener) return;
20+
target.removeEventListener('resize', listener);
21+
listener.cancel();
22+
}

src/app/panel-layout.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import type { AppContext, AppModule } from '@/app/app-context';
2+
import {
3+
addDebouncedResizeListener,
4+
removeDebouncedResizeListener,
5+
type DebouncedResizeListener,
6+
} from '@/app/debounced-resize-listener';
27
import { normalizeExclusiveChoropleths } from '@/components/resilience-choropleth-utils';
38
import { replayPendingCalls, clearAllPendingCalls } from '@/app/pending-panel-data';
49
import { getAlertsNearLocation } from '@/services/geo-convergence';
@@ -197,7 +202,7 @@ export class PanelLayoutManager implements AppModule {
197202
private unsubscribeEntitlementChange: (() => void) | null = null;
198203
private unsubscribePaymentFailureBanner: (() => void) | null = null;
199204
private scheduledLoadAllRaf: number | null = null;
200-
private _onResizeDebounced: (() => void) & { cancel(): void } | null = null;
205+
private _onResizeDebounced: DebouncedResizeListener | null = null;
201206

202207
constructor(ctx: AppContext, callbacks: PanelLayoutManagerCallbacks) {
203208
this.ctx = ctx;
@@ -405,10 +410,7 @@ export class PanelLayoutManager implements AppModule {
405410
// Reset checkout overlay so next layout init can register its callback
406411
destroyCheckoutOverlay();
407412

408-
if (this._onResizeDebounced) {
409-
window.removeEventListener('resize', this._onResizeDebounced);
410-
}
411-
this._onResizeDebounced?.cancel();
413+
removeDebouncedResizeListener(window, this._onResizeDebounced);
412414
this._onResizeDebounced = null;
413415
}
414416

@@ -1634,12 +1636,8 @@ export class PanelLayoutManager implements AppModule {
16341636
});
16351637
}
16361638

1637-
if (this._onResizeDebounced) {
1638-
window.removeEventListener('resize', this._onResizeDebounced);
1639-
this._onResizeDebounced.cancel();
1640-
}
1641-
this._onResizeDebounced = debounce(() => this.ensureCorrectZones(), 100);
1642-
window.addEventListener('resize', this._onResizeDebounced);
1639+
removeDebouncedResizeListener(window, this._onResizeDebounced);
1640+
this._onResizeDebounced = addDebouncedResizeListener(window, () => this.ensureCorrectZones());
16431641

16441642
this.ctx.map.onTimeRangeChanged((range) => {
16451643
this.ctx.currentTimeRange = range;

src/utils/debounce.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function debounce<T extends (...args: unknown[]) => void>(
2+
fn: T,
3+
delay: number,
4+
): ((...args: Parameters<T>) => void) & { cancel(): void } {
5+
let timeoutId: ReturnType<typeof setTimeout>;
6+
const debounced = (...args: Parameters<T>) => {
7+
clearTimeout(timeoutId);
8+
timeoutId = setTimeout(() => fn(...args), delay);
9+
};
10+
debounced.cancel = () => { clearTimeout(timeoutId); };
11+
return debounced;
12+
}

src/utils/index.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,7 @@ export function getHeatmapClass(change: number): string {
5050
return `${direction}-1`;
5151
}
5252

53-
export function debounce<T extends (...args: unknown[]) => void>(
54-
fn: T,
55-
delay: number
56-
): ((...args: Parameters<T>) => void) & { cancel(): void } {
57-
let timeoutId: ReturnType<typeof setTimeout>;
58-
const debounced = (...args: Parameters<T>) => {
59-
clearTimeout(timeoutId);
60-
timeoutId = setTimeout(() => fn(...args), delay);
61-
};
62-
debounced.cancel = () => { clearTimeout(timeoutId); };
63-
return debounced;
64-
}
53+
export { debounce } from './debounce';
6554

6655
export function throttle<T extends (...args: unknown[]) => void>(
6756
fn: T,

tests/resize-debounce.test.mjs

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,27 @@
22
//
33
// Verifies:
44
// - 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
77

88
import { describe, it } from 'node:test';
99
import assert from 'node:assert/strict';
1010
import { readFileSync } from 'node:fs';
1111
import { resolve, dirname } from 'node:path';
1212
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';
1318

1419
const __dirname = dirname(fileURLToPath(import.meta.url));
1520
const panelLayoutSrc = readFileSync(
1621
resolve(__dirname, '../src/app/panel-layout.ts'),
1722
'utf-8'
1823
);
1924

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', () => {
3626
it('does NOT call fn before delay elapses', () => {
3727
let called = false;
3828
const debounced = debounce(() => { called = true; }, 100);
@@ -69,6 +59,59 @@ describe('debounce utility (inline — mirrors src/utils/index.ts)', () => {
6959
});
7060
});
7161

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+
72115
// ------------------------------------------------------------------
73116
// Live lifecycle tests — replaces source-text regex tests which cannot
74117
// detect ordering bugs or re-init ghost calls.
@@ -154,50 +197,44 @@ describe('resize debounce lifecycle (live instance)', () => {
154197
describe('resize debounce wiring (panel-layout.ts)', () => {
155198
it('declares _onResizeDebounced nullable field', () => {
156199
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(
158201
panelLayoutSrc
159202
),
160203
'_onResizeDebounced field not found with correct type signature'
161204
);
162205
});
163206

164-
it('init sets _onResizeDebounced = debounce(ensureCorrectZones, 100)', () => {
207+
it('init installs resize through addDebouncedResizeListener', () => {
165208
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(
167210
panelLayoutSrc
168211
),
169-
'debounce(ensureCorrectZones, 100) assignment not found'
212+
'addDebouncedResizeListener(ensureCorrectZones) assignment not found'
170213
);
171214
});
172215

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', () => {
182217
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'
185222
);
186223
});
187224

188-
it('destroy() calls _onResizeDebounced?.cancel()', () => {
225+
it('does not keep the original bare resize arrow listener', () => {
189226
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)'
192229
);
193230
});
194231

195-
it('destroy() removes listener via _onResizeDebounced reference', () => {
232+
it('destroy() removes the resize helper and nulls the field', () => {
196233
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(
198235
panelLayoutSrc
199236
),
200-
'destroy() must remove resize listener via _onResizeDebounced'
237+
'destroy() must remove/cancel the resize listener and null the field'
201238
);
202239
});
203240
});

0 commit comments

Comments
 (0)