Files
gods-eye-view/src/ui/cctvControls.test.mjs
T
Sameh Khamis eef3dec759 feat(ui): render the CCTV frame preview, source badge and summary from the store
The frame lifecycle keeps its request state in an object instead of the
image's dataset and publishes it; the panel binds the image's src, class
and data attributes and the wrapper's loading and has-frame classes from
it, so preload tokens, kept pixels on a failed refresh and clearing on a
camera change are unchanged. The source badge is the pure
presentCctvSourceBadge(), the live switch and the typed summary are
published too. The video surface still draws into the panel's canvas.
2026-10-30 21:54:42 -07:00

128 lines
3.6 KiB
JavaScript

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);
});