import assert from 'node:assert/strict'; import { test } from 'node:test'; import { CctvControls } from './cctvControls.js'; function element() { const classes = new Set(); return { dataset: {}, src: '', removeAttribute(name) { if (name === 'src') this.src = ''; }, classList: { add(...values) { values.forEach((value) => classes.add(value)); }, remove(...values) { values.forEach((value) => classes.delete(value)); }, contains(value) { return classes.has(value); }, toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(value); }, }, }; } function fixture(t) { const prior = globalThis.Image; const requests = []; globalThis.Image = class { constructor() { requests.push(this); } }; t.after(() => { globalThis.Image = prior; }); const controls = new CctvControls({ elements: { _cctvFrame: element(), _cctvFrameWrap: element() }, cctv: {}, actions: { isEnabled: () => true }, }); t.after(() => controls.destroy()); return { controls, requests }; } test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); const stale = requests[0].onload; controls._queueCctvFrame('second.jpg', 'b', true); assert.equal(requests[0].onload, null); requests[1].onload(); stale(); assert.equal(controls._frame.src, 'second.jpg'); assert.equal(controls._frame.cameraId, 'b'); }); test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); requests[0].onload(); controls._queueCctvFrame('refresh.jpg', 'a', false); requests[1].onerror(); assert.equal(controls._frame.src, 'first.jpg'); assert.equal(controls._frame.hasFrame, true); controls._queueCctvFrame('other.jpg', 'b', true); assert.equal(controls._frame.src, ''); assert.equal(controls._frame.hasFrame, false); }); test('destroy invalidates image callbacks and releases each subscription once', (t) => { const { controls, requests } = fixture(t); let unsubscribed = 0; controls.cctv.subscribe = () => () => { unsubscribed++; }; controls.connect(); controls.connect(); assert.equal(unsubscribed, 1); controls._queueCctvFrame('first.jpg', 'a', true); const late = requests[0].onload; controls.destroy(); controls.destroy(); controls.connect(); late(); assert.equal(unsubscribed, 2); assert.equal(requests[0].onload, null); assert.equal(requests[0].onerror, null); assert.equal(controls._frame.src, ''); controls._queueCctvFrame('late.jpg', 'b', true); assert.equal(requests.length, 1); }); test('disposing during camera enable prevents the delayed focus and future clicks', async () => { const button = new EventTarget(); let resolveEnable; let enables = 0; let focuses = 0; const controls = new CctvControls({ elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, actions: { isEnabled: () => true, syncViewport() {}, toggleEnabled() { enables++; return new Promise((resolve) => { resolveEnable = resolve; }); }, runExplicitFocus() { focuses++; }, }, }); button.dispatchEvent(new Event('click')); controls.destroy(); resolveEnable(true); await new Promise((resolve) => setImmediate(resolve)); button.dispatchEvent(new Event('click')); assert.equal(enables, 1); assert.equal(focuses, 0); });