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._cctvFrame.src, 'second.jpg'); assert.equal(controls._cctvFrame.dataset.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._cctvFrame.src, 'first.jpg'); assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true); controls._queueCctvFrame('other.jpg', 'b', true); assert.equal(controls._cctvFrame.src, ''); assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), 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._cctvFrame.src, ''); controls._queueCctvFrame('late.jpg', 'b', true); assert.equal(requests.length, 1); }); function calibrationFixture(t) { const { controls } = fixture(t); const prior = globalThis.document; const inputs = []; globalThis.document = { createElement() { const input = new EventTarget(); Object.assign(input, { focus() {}, select() {}, setAttribute(name, value) { this[name] = value; }, remove() { this.parent.input = null; }, }); inputs.push(input); return input; }, }; t.after(() => { globalThis.document = prior; }); const chip = { dataset: { calField: 'heading' }, textContent: '', input: null, appendChild(input) { this.input = input; input.parent = this; }, querySelector() { return this.input; }, }; const patches = []; controls.actions.setParams = (params) => patches.push(params); controls.actions.setPanelCollapsed = () => {}; controls._cctvCalReadout = { querySelectorAll: () => [chip] }; controls._cctvState = { enabled: true, activeCameraId: 'a', activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, }; const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name }); return { controls, chip, inputs, patches, key }; } test('calibration commits against the captured camera base and releases its editor', (t) => { const { controls, chip, inputs, patches, key } = calibrationFixture(t); controls._beginCctvCalValueEdit(chip); inputs[0].value = '100'; controls._cctvState.activeCamera.basePose.headingDeg = 60; inputs[0].dispatchEvent(key('Enter')); inputs[0].dispatchEvent(new Event('blur')); assert.deepEqual(patches, [ { selectedCameraId: 'a', calibration: { cameraId: 'a', patch: { headingDeg: 80 } }, }, ]); assert.equal(controls._calibrationEdit, null); }); test('a camera switch cancels calibration before old blur can change the new camera', (t) => { const { controls, chip, inputs, patches } = calibrationFixture(t); controls._beginCctvCalValueEdit(chip); inputs[0].value = '100'; controls._renderCctvState({ enabled: true, activeCameraId: 'b', activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } }, }); inputs[0].dispatchEvent(new Event('blur')); assert.deepEqual(patches, []); assert.equal(chip.input, null); assert.equal(chip.textContent, 'HDG 200.0°'); }); test('Escape claims calibration cancellation and disposal prevents late commits', (t) => { const { controls, chip, inputs, patches, key } = calibrationFixture(t); controls._beginCctvCalValueEdit(chip); inputs[0].value = '100'; const escape = key('Escape'); inputs[0].dispatchEvent(escape); inputs[0].dispatchEvent(new Event('blur')); assert.equal(escape.defaultPrevented, true); controls._beginCctvCalValueEdit(chip); inputs[1].value = '120'; controls.destroy(); inputs[1].dispatchEvent(new Event('blur')); assert.deepEqual(patches, []); }); 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); });