mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
128 lines
3.6 KiB
JavaScript
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);
|
|
});
|