@7365admin1/layer-common 3.2.1-staging.64 → 3.2.1-staging.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CameraWall.vue +326 -16
- package/components/CameraWallTile.vue +478 -29
- package/composables/useSiteSettings.ts +22 -0
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +266 -10
- package/utils/camera-wall.ts +554 -47
|
@@ -2,12 +2,23 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
agoLabel,
|
|
5
6
|
capabilityView,
|
|
7
|
+
clampPage,
|
|
8
|
+
clampPan,
|
|
9
|
+
clampZoom,
|
|
10
|
+
healthView,
|
|
6
11
|
isDenseLayout,
|
|
12
|
+
playerOrigin,
|
|
13
|
+
playerSignal,
|
|
7
14
|
tileView,
|
|
8
15
|
wallCameras,
|
|
9
16
|
wallLayout,
|
|
17
|
+
wallPageCount,
|
|
18
|
+
wallRows,
|
|
10
19
|
wallTiles,
|
|
20
|
+
WALL_CAPABILITY_CONTROLS,
|
|
21
|
+
zoomStep,
|
|
11
22
|
} from "./camera-wall.ts";
|
|
12
23
|
|
|
13
24
|
const ip = (over: Record<string, unknown> = {}) => ({
|
|
@@ -44,10 +55,11 @@ test("an unknown layout key falls back to four tiles", () => {
|
|
|
44
55
|
test("no selection means the site's own order, not a blank wall", () => {
|
|
45
56
|
const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
|
|
46
57
|
const tiles = wallTiles(cams, [], "2x2");
|
|
47
|
-
|
|
58
|
+
// Two cameras on a four-tile wall are TWO tiles, not two tiles and two
|
|
59
|
+
// dashed boxes. The grid packs; see wallRows.
|
|
60
|
+
assert.equal(tiles.length, 2);
|
|
48
61
|
assert.equal(tiles[0]._id, "a");
|
|
49
62
|
assert.equal(tiles[1]._id, "b");
|
|
50
|
-
assert.equal(tiles[2], null);
|
|
51
63
|
});
|
|
52
64
|
|
|
53
65
|
test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
|
|
@@ -83,23 +95,132 @@ test("the server's own sentence is shown, never rewritten", () => {
|
|
|
83
95
|
);
|
|
84
96
|
});
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
/*
|
|
99
|
+
CHANGED DELIBERATELY. This asserted `tag === null` for a working capability,
|
|
100
|
+
and that was fine while every capability on this screen was switched off. It
|
|
101
|
+
stopped being fine the moment one was on: an untagged panel beside four
|
|
102
|
+
tagged ones reads as a panel whose tag failed to render, not as good news.
|
|
103
|
+
It also hid a real defect, which the next test pins.
|
|
104
|
+
*/
|
|
105
|
+
test("a working capability says so, and does not fall back to the not-probed sentence", () => {
|
|
87
106
|
const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
|
|
88
|
-
|
|
89
|
-
assert.equal(
|
|
107
|
+
const v = capabilityView(cam, "playback");
|
|
108
|
+
assert.equal(v.available, true);
|
|
109
|
+
assert.equal(v.tag, "Ready");
|
|
110
|
+
// The defect: `detail` used to fall back to "has not been asked what it can
|
|
111
|
+
// do yet" whenever the server sent none — and the server sends none for a
|
|
112
|
+
// capability that WORKS. A working control claiming it had never been checked
|
|
113
|
+
// is a contradiction on screen, and it would have shipped the day zoom landed.
|
|
114
|
+
assert.equal(v.detail, null);
|
|
115
|
+
assert.equal(v.nextStep, null);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------------- rule 5 */
|
|
119
|
+
|
|
120
|
+
test("a switched-off control says what would switch it on and who to ask", () => {
|
|
121
|
+
const cam = ip({
|
|
122
|
+
capabilities: {
|
|
123
|
+
playback: {
|
|
124
|
+
state: "unsupported",
|
|
125
|
+
reason: "device-http-not-configured",
|
|
126
|
+
detail: "No direct connection to this camera's recorder is configured on this server.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const v = capabilityView(cam, "playback");
|
|
131
|
+
// The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
|
|
132
|
+
assert.match(v.detail, /^No direct connection/);
|
|
133
|
+
assert.match(v.nextStep, /Seven365 administrator/);
|
|
134
|
+
assert.match(v.nextStep, /not something to switch on from this page/i);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a reason code this build has never seen still names somebody to ask", () => {
|
|
138
|
+
const cam = ip({
|
|
139
|
+
capabilities: { ptz: { state: "unsupported", reason: "invented-later", detail: "Nope." } },
|
|
140
|
+
});
|
|
141
|
+
assert.match(capabilityView(cam, "ptz").nextStep, /Seven365 administrator/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("every control the wall draws has a sentence for when it works", () => {
|
|
145
|
+
// Otherwise a capability flipping on shows a panel with a "Ready" tag and no
|
|
146
|
+
// explanation of what to do with it.
|
|
147
|
+
for (const control of WALL_CAPABILITY_CONTROLS) {
|
|
148
|
+
assert.ok(control.ready.length > 0, `${control.key} has no ready sentence`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("zoom is drawn as a capability, and it is the one that works today", () => {
|
|
153
|
+
const keys = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
|
|
154
|
+
assert.ok(keys.includes("digitalZoom"));
|
|
155
|
+
// Digital zoom is cropping, not movement. If these two ever read the same on
|
|
156
|
+
// screen, somebody will believe they moved a camera that never moved.
|
|
157
|
+
assert.ok(keys.includes("ptz"));
|
|
158
|
+
assert.notEqual(
|
|
159
|
+
WALL_CAPABILITY_CONTROLS.find((c) => c.key === "digitalZoom").title,
|
|
160
|
+
WALL_CAPABILITY_CONTROLS.find((c) => c.key === "ptz").title
|
|
161
|
+
);
|
|
90
162
|
});
|
|
91
163
|
|
|
92
164
|
/* ------------------------------------------------------------------ rule 4 */
|
|
93
165
|
|
|
94
166
|
test("a page that has not loaded is Connecting, never Live", () => {
|
|
95
167
|
assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
|
|
96
|
-
assert.equal(tileView(ip(), { player: "
|
|
97
|
-
|
|
168
|
+
assert.equal(tileView(ip(), { player: "failed" }).state, "no-signal");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
173
|
+
*
|
|
174
|
+
* On the live wall two cameras rendered a blank rectangle and both carried a
|
|
175
|
+
* green "Live". The page had loaded; no picture was arriving. A loaded page is
|
|
176
|
+
* now worth exactly one thing, and it is not the word "Live".
|
|
177
|
+
*/
|
|
178
|
+
test("a loaded player page is NOT Live - it is Not verified", () => {
|
|
179
|
+
const v = tileView(ip(), { player: "page-up" });
|
|
180
|
+
assert.equal(v.state, "unverified");
|
|
181
|
+
assert.equal(v.label, "Not verified");
|
|
182
|
+
// No scrim: there may be a perfectly good picture under that badge, and
|
|
183
|
+
// covering a working camera would be a worse lie than the one being fixed.
|
|
184
|
+
assert.equal(v.detail, null);
|
|
185
|
+
assert.equal(v.showPlayer, true);
|
|
186
|
+
assert.match(v.note, /cannot confirm a picture is arriving/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("only a reported frame earns the word Live", () => {
|
|
190
|
+
assert.equal(tileView(ip(), { player: "playing" }).label, "Live");
|
|
191
|
+
assert.equal(tileView(ip(), { player: "playing" }).state, "live");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a picture that arrived and stopped stops being Live", () => {
|
|
195
|
+
// The mobile app's "Live over a nine-second-old still", refused here.
|
|
196
|
+
const v = tileView(ip(), { player: "stalled" });
|
|
197
|
+
assert.equal(v.state, "no-signal");
|
|
198
|
+
assert.match(v.detail, /stopped arriving/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("only signals the video service could actually send are believed", () => {
|
|
202
|
+
assert.equal(playerSignal({ event: "frame" }), "playing");
|
|
203
|
+
assert.equal(playerSignal("videoDecode"), "playing");
|
|
204
|
+
assert.equal(playerSignal({ type: "stalled" }), "stalled");
|
|
205
|
+
assert.equal(playerSignal({ event: "error" }), "failed");
|
|
206
|
+
// Anything else leaves the tile exactly where it was. A message we do not
|
|
207
|
+
// understand must never be able to turn a badge green.
|
|
208
|
+
assert.equal(playerSignal({ event: "live" }), null);
|
|
209
|
+
assert.equal(playerSignal(null), null);
|
|
210
|
+
assert.equal(playerSignal(42), null);
|
|
211
|
+
assert.equal(playerSignal({}), null);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a message is only believed from the camera's own origin", () => {
|
|
215
|
+
assert.equal(playerOrigin("https://relay.example.test/7"), "https://relay.example.test");
|
|
216
|
+
assert.equal(playerOrigin("not a url"), "");
|
|
217
|
+
assert.equal(playerOrigin(""), "");
|
|
218
|
+
assert.equal(playerOrigin(null), "");
|
|
98
219
|
});
|
|
99
220
|
|
|
100
221
|
test("the browser being offline outranks everything and says so differently", () => {
|
|
101
|
-
assert.equal(tileView(ip(), { online: false, player: "
|
|
102
|
-
assert.equal(tileView(ip(), { player: "
|
|
222
|
+
assert.equal(tileView(ip(), { online: false, player: "playing" }).label, "No connection");
|
|
223
|
+
assert.equal(tileView(ip(), { player: "failed" }).label, "No signal");
|
|
103
224
|
});
|
|
104
225
|
|
|
105
226
|
test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
|
|
@@ -115,7 +236,7 @@ test("a server that says live video is unsupported is believed", () => {
|
|
|
115
236
|
liveVideo: { state: "unsupported", detail: "This camera is not active." },
|
|
116
237
|
},
|
|
117
238
|
});
|
|
118
|
-
const v = tileView(cam, { player: "
|
|
239
|
+
const v = tileView(cam, { player: "playing" });
|
|
119
240
|
assert.equal(v.showPlayer, false);
|
|
120
241
|
assert.equal(v.detail, "This camera is not active.");
|
|
121
242
|
});
|
|
@@ -147,3 +268,138 @@ test("being offline still explains every tile at once, ahead of the server's rea
|
|
|
147
268
|
const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
|
|
148
269
|
assert.equal(view.state, "offline");
|
|
149
270
|
});
|
|
271
|
+
|
|
272
|
+
/* ------------------------------------------------------------------ paging */
|
|
273
|
+
|
|
274
|
+
test("the fifth camera on a four-tile wall is reachable, not dropped", () => {
|
|
275
|
+
// The defect this fixes, exactly: a site with five cameras showed four, said
|
|
276
|
+
// "Cameras 5" in the toolbar, and had nowhere to put the fifth.
|
|
277
|
+
const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
|
|
278
|
+
assert.equal(wallPageCount(cams, [], "2x2"), 2);
|
|
279
|
+
|
|
280
|
+
const second = wallTiles(cams, [], "2x2", 1);
|
|
281
|
+
assert.equal(second[0]._id, "e");
|
|
282
|
+
// A short last page is short, not padded - and it must never wrap back
|
|
283
|
+
// round to the first camera, which would show "a" twice on one wall.
|
|
284
|
+
assert.equal(second.length, 1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("the grid packs to the cameras on it instead of drawing holes", () => {
|
|
288
|
+
// Five cameras on a nine-tile wall: five pictures over two rows, not five
|
|
289
|
+
// pictures and four dead grey boxes over three.
|
|
290
|
+
const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
|
|
291
|
+
const tiles = wallTiles(cams, [], "3x3");
|
|
292
|
+
assert.equal(tiles.length, 5);
|
|
293
|
+
assert.equal(wallRows(tiles.length, "3x3"), 2);
|
|
294
|
+
|
|
295
|
+
assert.equal(wallRows(1, "3x3"), 1);
|
|
296
|
+
assert.equal(wallRows(9, "3x3"), 3);
|
|
297
|
+
// Never taller than the layout asked for, and never zero rows.
|
|
298
|
+
assert.equal(wallRows(99, "3x3"), 3);
|
|
299
|
+
assert.equal(wallRows(0, "2x2"), 1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("an exactly-full wall is one page, and an empty one is still one", () => {
|
|
303
|
+
const four = ["a", "b", "c", "d"].map((id) => ip({ _id: id }));
|
|
304
|
+
assert.equal(wallPageCount(four, [], "2x2"), 1);
|
|
305
|
+
assert.equal(wallPageCount([], [], "2x2"), 1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a page number is clamped rather than left pointing past the end", () => {
|
|
309
|
+
const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
|
|
310
|
+
// Going from 2x2 page 2 to a 3x3 wall: page 1 no longer exists.
|
|
311
|
+
assert.equal(clampPage(1, wallPageCount(cams, [], "3x3")), 0);
|
|
312
|
+
assert.equal(clampPage(-4, 3), 0);
|
|
313
|
+
assert.equal(clampPage(99, 3), 2);
|
|
314
|
+
assert.equal(clampPage(NaN, 3), 0);
|
|
315
|
+
// Asking for a page past the end returns the last one's tiles, never nothing.
|
|
316
|
+
assert.equal(wallTiles(cams, [], "2x2", 9)[0]._id, "e");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("paging respects a selection instead of quietly paging the whole site", () => {
|
|
320
|
+
const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
|
|
321
|
+
assert.equal(wallPageCount(cams, ["a", "b"], "1x1"), 2);
|
|
322
|
+
assert.equal(wallTiles(cams, ["a", "b"], "1x1", 1)[0]._id, "b");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/* ------------------------------------------------------------------ health */
|
|
326
|
+
|
|
327
|
+
test("the recorder's state and the player page's state are separate answers", () => {
|
|
328
|
+
const camera = ip();
|
|
329
|
+
// The tile badge says the page is up; the recorder says it is not. Both are
|
|
330
|
+
// true at once when a relay outlives the device behind it, and a wall that
|
|
331
|
+
// merged them would tell a supervisor only the reassuring half.
|
|
332
|
+
assert.equal(tileView(camera, { player: "playing" }).label, "Live");
|
|
333
|
+
assert.equal(healthView({ health: "unreachable", reachable: false }).tone, "down");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("health that has never been asked for does not read as healthy", () => {
|
|
337
|
+
assert.equal(healthView(null).tone, "unknown");
|
|
338
|
+
assert.equal(healthView(undefined).lastFrame, null);
|
|
339
|
+
assert.match(healthView(null).label, /not checked/i);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("a drifted clock is a warning, not a failure", () => {
|
|
343
|
+
assert.equal(healthView({ health: "drifted", reachable: true }).tone, "warn");
|
|
344
|
+
assert.equal(healthView({ health: "ok", reachable: true }).tone, "ok");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("reachability alone is enough when the server sends no summary word", () => {
|
|
348
|
+
assert.equal(healthView({ reachable: true }).tone, "ok");
|
|
349
|
+
assert.equal(healthView({ reachable: false }).tone, "down");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("the server's health sentences are carried through, not rewritten", () => {
|
|
353
|
+
const v = healthView({
|
|
354
|
+
health: "unreachable",
|
|
355
|
+
reachable: false,
|
|
356
|
+
reason: "The recorder did not respond.",
|
|
357
|
+
detailUnavailableReason:
|
|
358
|
+
"Firmware and device clock are not available: this recorder is reachable over video only.",
|
|
359
|
+
});
|
|
360
|
+
assert.equal(v.detail, "The recorder did not respond.");
|
|
361
|
+
assert.match(v.deviceDetail, /^Firmware and device clock/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("a picture's age is coarse above a minute, and never a fake time", () => {
|
|
365
|
+
const now = Date.parse("2026-08-11T10:00:00.000Z");
|
|
366
|
+
const at = (ms) => new Date(now - ms).toISOString();
|
|
367
|
+
assert.equal(agoLabel(at(1000), now), "just now");
|
|
368
|
+
assert.equal(agoLabel(at(40_000), now), "40s ago");
|
|
369
|
+
assert.equal(agoLabel(at(6 * 60_000), now), "6 min ago");
|
|
370
|
+
assert.equal(agoLabel(at(3 * 3_600_000), now), "3 h ago");
|
|
371
|
+
// Never invent one: no timestamp and a broken timestamp both mean "no answer".
|
|
372
|
+
assert.equal(agoLabel(null, now), null);
|
|
373
|
+
assert.equal(agoLabel("not a date", now), null);
|
|
374
|
+
// A clock ahead of ours must not read as a picture from the future.
|
|
375
|
+
assert.equal(agoLabel(at(-5000), now), "just now");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/* -------------------------------------------------------------------- zoom */
|
|
379
|
+
|
|
380
|
+
test("zoom stops at 4x and never goes below the whole picture", () => {
|
|
381
|
+
assert.equal(clampZoom(0.2), 1);
|
|
382
|
+
assert.equal(clampZoom(99), 4);
|
|
383
|
+
assert.equal(clampZoom(NaN), 1);
|
|
384
|
+
assert.equal(clampZoom(2.5), 2.5);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("a wheel notch zooms by direction, not by however much the device reports", () => {
|
|
388
|
+
assert.ok(zoomStep(1, -120) > 1);
|
|
389
|
+
assert.ok(zoomStep(2, 3) < 2);
|
|
390
|
+
// A trackpad's tiny delta and a mouse's huge one take the same step.
|
|
391
|
+
assert.equal(zoomStep(1, -1), zoomStep(1, -4000));
|
|
392
|
+
assert.equal(zoomStep(1, 120), 1, "cannot zoom out past the whole picture");
|
|
393
|
+
assert.equal(zoomStep(4, -120), 4, "cannot zoom in past the ceiling");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("panning cannot walk the picture off the tile", () => {
|
|
397
|
+
const frame = { width: 400, height: 200 };
|
|
398
|
+
// At 2x the picture is twice the frame, so half the frame each way and no more.
|
|
399
|
+
assert.deepEqual(clampPan({ x: 9999, y: 9999 }, 2, frame), { x: 200, y: 100 });
|
|
400
|
+
assert.deepEqual(clampPan({ x: -9999, y: -9999 }, 2, frame), { x: -200, y: -100 });
|
|
401
|
+
// At 1x there is nothing to pan, so every drag resolves to centred.
|
|
402
|
+
assert.deepEqual(clampPan({ x: 50, y: 50 }, 1, frame), { x: 0, y: 0 });
|
|
403
|
+
// A tile that has not been measured yet cannot be dragged into the void.
|
|
404
|
+
assert.deepEqual(clampPan({ x: 50, y: 50 }, 3, { width: 0, height: 0 }), { x: 0, y: 0 });
|
|
405
|
+
});
|