@7365admin1/layer-common 3.2.1 → 3.2.2-staging.77

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.
Files changed (55) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -0,0 +1,571 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ agoLabel,
6
+ capabilityView,
7
+ CAPABILITY_NEXT_STEPS,
8
+ clampPage,
9
+ clampPan,
10
+ clampZoom,
11
+ focusedCamera,
12
+ healthView,
13
+ isDenseLayout,
14
+ playerOrigin,
15
+ playerSignal,
16
+ sharedCapabilityNote,
17
+ tileView,
18
+ wallCameras,
19
+ wallLayout,
20
+ wallPageCount,
21
+ wallRows,
22
+ wallTiles,
23
+ WALL_CAPABILITY_CONTROLS,
24
+ zoomStep,
25
+ } from "./camera-wall.ts";
26
+
27
+ const ip = (over: Record<string, unknown> = {}) => ({
28
+ _id: "c1",
29
+ name: "GYM AREA",
30
+ type: "ip",
31
+ status: "active",
32
+ host: "https://example.invalid/4",
33
+ ...over,
34
+ });
35
+
36
+ /* ---------------------------------------------------------------- rule 1 */
37
+
38
+ test("an ANPR camera can never reach the wall", () => {
39
+ const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
40
+ const kept = wallCameras(items);
41
+ assert.equal(kept.length, 1);
42
+ assert.equal(kept[0]._id, "c1");
43
+ });
44
+
45
+ test("a junk response is an empty wall, not a crash", () => {
46
+ assert.deepEqual(wallCameras(undefined), []);
47
+ assert.deepEqual(wallCameras([null, 3, "x"]), []);
48
+ });
49
+
50
+ /* ---------------------------------------------------------- layout + order */
51
+
52
+ test("an unknown layout key falls back to four tiles", () => {
53
+ assert.equal(wallLayout("7x7").tiles, 4);
54
+ assert.equal(isDenseLayout("3x3"), true);
55
+ assert.equal(isDenseLayout("2x2"), false);
56
+ });
57
+
58
+ test("no selection means the site's own order, not a blank wall", () => {
59
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
60
+ const tiles = wallTiles(cams, [], "2x2");
61
+ // Two cameras on a four-tile wall are TWO tiles, not two tiles and two
62
+ // dashed boxes. The grid packs; see wallRows.
63
+ assert.equal(tiles.length, 2);
64
+ assert.equal(tiles[0]._id, "a");
65
+ assert.equal(tiles[1]._id, "b");
66
+ });
67
+
68
+ test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
69
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
70
+ const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
71
+ assert.equal(tiles.length, 1);
72
+ assert.equal(tiles[0]._id, "b");
73
+ });
74
+
75
+ /* ------------------------------------------- which tile the panel is about */
76
+
77
+ /*
78
+ THE PANEL STOPPED PRINTING THE CAMERA'S NAME, AND THESE ARE THE TWO FACTS
79
+ THAT LET IT.
80
+
81
+ The name was there so the reader could tell which of up to nine tiles the
82
+ panel described. It is now the selected tile's accent ring that answers that —
83
+ which is only safe while the panel can never be about a tile that is not
84
+ drawn. Five cameras across two pages is exactly the case the owner asked
85
+ about, so it is exactly the case pinned here.
86
+ */
87
+ const FIVE = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id, name: id.toUpperCase() }));
88
+
89
+ test("nothing selected yet: the panel is about the first tile ON THIS PAGE", () => {
90
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "")?._id, "a");
91
+ // Page 2 of the same wall holds one camera, and the panel is about that one —
92
+ // not still about "a", which is no longer on screen.
93
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 1), "")?._id, "e");
94
+ });
95
+
96
+ test("paging away from your selection never leaves the panel describing an off-page camera", () => {
97
+ // Selected "a" on page 1, then paged to page 2. "a" is not drawn there.
98
+ const pageTwo = wallTiles(FIVE, [], "2x2", 1);
99
+ const shown = focusedCamera(pageTwo, "a");
100
+ assert.equal(shown?._id, "e");
101
+ assert.ok(
102
+ pageTwo.some((c) => c._id === shown?._id),
103
+ "the panel must always describe a tile on the current page"
104
+ );
105
+ });
106
+
107
+ test("paging back restores the selection, because the raw id is kept", () => {
108
+ assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "c")?._id, "c");
109
+ });
110
+
111
+ test("an empty wall has nothing to describe", () => {
112
+ assert.equal(focusedCamera([], "a"), null);
113
+ });
114
+
115
+ /* ---------------------------------------------------------------- rule 2/3 */
116
+
117
+ test("no descriptor at all counts as NOT available", () => {
118
+ const v = capabilityView(ip(), "ptz");
119
+ assert.equal(v.available, false);
120
+ assert.equal(v.tag, "Not checked yet");
121
+ assert.match(v.detail, /has not been asked/);
122
+ });
123
+
124
+ test("the server's own sentence is shown, never rewritten", () => {
125
+ const cam = ip({
126
+ capabilities: {
127
+ playback: {
128
+ state: "unsupported",
129
+ detail: "No direct connection to this camera's recorder is configured on this server.",
130
+ },
131
+ },
132
+ });
133
+ const v = capabilityView(cam, "playback");
134
+ assert.equal(v.tag, "Not set up yet");
135
+ assert.equal(
136
+ v.detail,
137
+ "No direct connection to this camera's recorder is configured on this server."
138
+ );
139
+ });
140
+
141
+ /*
142
+ CHANGED DELIBERATELY. This asserted `tag === null` for a working capability,
143
+ and that was fine while every capability on this screen was switched off. It
144
+ stopped being fine the moment one was on: an untagged panel beside four
145
+ tagged ones reads as a panel whose tag failed to render, not as good news.
146
+ It also hid a real defect, which the next test pins.
147
+ */
148
+ test("a working capability says so, and does not fall back to the not-probed sentence", () => {
149
+ const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
150
+ const v = capabilityView(cam, "playback");
151
+ assert.equal(v.available, true);
152
+ assert.equal(v.tag, "Ready");
153
+ // The defect: `detail` used to fall back to "has not been asked what it can
154
+ // do yet" whenever the server sent none — and the server sends none for a
155
+ // capability that WORKS. A working control claiming it had never been checked
156
+ // is a contradiction on screen, and it would have shipped the day zoom landed.
157
+ assert.equal(v.detail, null);
158
+ assert.equal(v.nextStep, null);
159
+ });
160
+
161
+ /* ---------------------------------------------------------------- rule 5 */
162
+
163
+ test("a switched-off control says what would switch it on and who to ask", () => {
164
+ const cam = ip({
165
+ capabilities: {
166
+ playback: {
167
+ state: "unsupported",
168
+ reason: "device-http-not-configured",
169
+ detail: "No direct connection to this camera's recorder is configured on this server.",
170
+ },
171
+ },
172
+ });
173
+ const v = capabilityView(cam, "playback");
174
+ // The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
175
+ assert.match(v.detail, /^No direct connection/);
176
+ assert.match(v.nextStep, /Seven365 administrator/);
177
+ assert.match(v.nextStep, /not switchable from this page/i);
178
+ });
179
+
180
+ /*
181
+ THE ADVICE HAS TO NAME THE TWO CHANGES, AND IT HAS TO STAY OUT OF THE
182
+ DEPLOYMENT MANUAL.
183
+
184
+ "Ask your Seven365 administrator" was the whole of the old advice and it was a
185
+ dead end — worse than useless for the owner, who IS the administrator. What
186
+ makes the new line actionable is that it names the two real changes: the
187
+ network has to let the server reach the recorder, and the recorder then has to
188
+ be configured on the server. Both halves are pinned here, because dropping
189
+ either one silently returns the sentence to a shrug.
190
+
191
+ And the other direction is pinned too: no environment variable name may appear
192
+ in copy a property manager reads. The names are correct, they are in
193
+ `iservice365-core/docs/camera-integration-config.md`, and they are unreadable
194
+ to the person this screen is written for.
195
+ */
196
+ test("the setup advice names the network change AND the server change", () => {
197
+ const advice = CAPABILITY_NEXT_STEPS["device-http-not-configured"];
198
+ assert.match(advice, /network/i);
199
+ assert.match(advice, /recorder/i);
200
+ assert.match(advice, /server's configuration/i);
201
+ });
202
+
203
+ test("no environment variable name is ever shown to a reader", () => {
204
+ // Anything SCREAMING_SNAKE would be a deployment detail leaking into product
205
+ // copy. `CAMERA_DEVICE_HTTP` and friends belong in the deployment document.
206
+ for (const [reason, advice] of Object.entries(CAPABILITY_NEXT_STEPS)) {
207
+ assert.doesNotMatch(advice, /[A-Z][A-Z0-9]*_[A-Z0-9_]+/, `${reason} names a variable`);
208
+ }
209
+ });
210
+
211
+ /* ---------------------------------------------------------------- rule 7 */
212
+
213
+ const OFF_FOR_THE_SAME_REASON = {
214
+ state: "unsupported",
215
+ reason: "device-http-not-configured",
216
+ detail: "No direct connection to this camera's recorder is configured on this server.",
217
+ };
218
+ const CAP_KEYS = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
219
+
220
+ test("four controls off for one reason state that reason once", () => {
221
+ // What the owner actually saw: the same sentence and the same next step,
222
+ // four times in a row, under four different titles.
223
+ const cam = ip({
224
+ capabilities: {
225
+ // Exactly the live wall: zoom works, the other four are off together.
226
+ digitalZoom: { state: "supported" },
227
+ playback: OFF_FOR_THE_SAME_REASON,
228
+ events: OFF_FOR_THE_SAME_REASON,
229
+ ptz: OFF_FOR_THE_SAME_REASON,
230
+ presets: OFF_FOR_THE_SAME_REASON,
231
+ },
232
+ });
233
+ const note = sharedCapabilityNote(cam, CAP_KEYS);
234
+ assert.match(note.detail, /^No direct connection/);
235
+ assert.match(note.nextStep, /Seven365 administrator/);
236
+ });
237
+
238
+ test("controls off for DIFFERENT reasons are never merged into one", () => {
239
+ // "The recorder is unreachable" and "this camera cannot move" are two
240
+ // problems with two different people to ask. Flattening them would be a lie
241
+ // of convenience, so the group note is refused and each card keeps its own.
242
+ const cam = ip({
243
+ capabilities: {
244
+ playback: OFF_FOR_THE_SAME_REASON,
245
+ ptz: { state: "unsupported", reason: "device-no-ptz", detail: "This camera cannot move." },
246
+ },
247
+ });
248
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
249
+ });
250
+
251
+ test("one control off on its own does not get a group note", () => {
252
+ const cam = ip({
253
+ capabilities: {
254
+ playback: OFF_FOR_THE_SAME_REASON,
255
+ events: { state: "supported" },
256
+ ptz: { state: "supported" },
257
+ presets: { state: "supported" },
258
+ digitalZoom: { state: "supported" },
259
+ },
260
+ });
261
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
262
+ });
263
+
264
+ test("a camera with no descriptor at all still groups its unknowns", () => {
265
+ // Every control reads "Not checked yet" with the same not-probed sentence —
266
+ // which is four identical cards, the same noise, and the same fix.
267
+ const note = sharedCapabilityNote(ip(), CAP_KEYS);
268
+ assert.match(note.detail, /has not been asked/);
269
+ });
270
+
271
+ test("a reason code this build has never seen still names somebody to ask", () => {
272
+ const cam = ip({
273
+ capabilities: { ptz: { state: "unsupported", reason: "invented-later", detail: "Nope." } },
274
+ });
275
+ assert.match(capabilityView(cam, "ptz").nextStep, /Seven365 administrator/);
276
+ });
277
+
278
+ test("every control the wall draws has a sentence for when it works", () => {
279
+ // Otherwise a capability flipping on shows a panel with a "Ready" tag and no
280
+ // explanation of what to do with it.
281
+ for (const control of WALL_CAPABILITY_CONTROLS) {
282
+ assert.ok(control.ready.length > 0, `${control.key} has no ready sentence`);
283
+ }
284
+ });
285
+
286
+ test("zoom is drawn as a capability, and it is the one that works today", () => {
287
+ const keys = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
288
+ assert.ok(keys.includes("digitalZoom"));
289
+ // Digital zoom is cropping, not movement. If these two ever read the same on
290
+ // screen, somebody will believe they moved a camera that never moved.
291
+ assert.ok(keys.includes("ptz"));
292
+ assert.notEqual(
293
+ WALL_CAPABILITY_CONTROLS.find((c) => c.key === "digitalZoom").title,
294
+ WALL_CAPABILITY_CONTROLS.find((c) => c.key === "ptz").title
295
+ );
296
+ });
297
+
298
+ /* ------------------------------------------------------------------ rule 4 */
299
+
300
+ test("a page that has not loaded is Connecting, never Live", () => {
301
+ assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
302
+ assert.equal(tileView(ip(), { player: "failed" }).state, "no-signal");
303
+ });
304
+
305
+ /**
306
+ * THE DEFECT THIS FILE EXISTS FOR.
307
+ *
308
+ * On the live wall two cameras rendered a blank rectangle and both carried a
309
+ * green "Live". The page had loaded; no picture was arriving. A loaded page is
310
+ * now worth exactly one thing, and it is not the word "Live".
311
+ */
312
+ test("a loaded player page is NOT Live - it is Not confirmed", () => {
313
+ const v = tileView(ip(), { player: "page-up" });
314
+ assert.equal(v.state, "unverified");
315
+ assert.equal(v.label, "Not confirmed");
316
+ // No scrim: there may be a perfectly good picture under that badge, and
317
+ // covering a working camera would be a worse lie than the one being fixed.
318
+ assert.equal(v.detail, null);
319
+ assert.equal(v.showPlayer, true);
320
+ assert.match(v.note, /cannot confirm the picture is arriving/);
321
+ // Plain English, and an instruction rather than an explanation: the reader is
322
+ // a guard, not a browser engineer. Nothing about origins, players or frames.
323
+ assert.match(v.note, /check the tile/i);
324
+ assert.doesNotMatch(v.note, /browser|origin|player|iframe|service/i);
325
+ });
326
+
327
+ test("only a reported frame earns the word Live", () => {
328
+ assert.equal(tileView(ip(), { player: "playing" }).label, "Live");
329
+ assert.equal(tileView(ip(), { player: "playing" }).state, "live");
330
+ });
331
+
332
+ test("a picture that arrived and stopped stops being Live", () => {
333
+ // The mobile app's "Live over a nine-second-old still", refused here.
334
+ const v = tileView(ip(), { player: "stalled" });
335
+ assert.equal(v.state, "no-signal");
336
+ assert.match(v.detail, /stopped arriving/);
337
+ });
338
+
339
+ test("only signals the video service could actually send are believed", () => {
340
+ assert.equal(playerSignal({ event: "frame" }), "playing");
341
+ assert.equal(playerSignal("videoDecode"), "playing");
342
+ assert.equal(playerSignal({ type: "stalled" }), "stalled");
343
+ assert.equal(playerSignal({ event: "error" }), "failed");
344
+ // Anything else leaves the tile exactly where it was. A message we do not
345
+ // understand must never be able to turn a badge green.
346
+ assert.equal(playerSignal({ event: "live" }), null);
347
+ assert.equal(playerSignal(null), null);
348
+ assert.equal(playerSignal(42), null);
349
+ assert.equal(playerSignal({}), null);
350
+ });
351
+
352
+ test("a message is only believed from the camera's own origin", () => {
353
+ assert.equal(playerOrigin("https://relay.example.test/7"), "https://relay.example.test");
354
+ assert.equal(playerOrigin("not a url"), "");
355
+ assert.equal(playerOrigin(""), "");
356
+ assert.equal(playerOrigin(null), "");
357
+ });
358
+
359
+ test("the browser being offline outranks everything and says so differently", () => {
360
+ assert.equal(tileView(ip(), { online: false, player: "playing" }).label, "No connection");
361
+ assert.equal(tileView(ip(), { player: "failed" }).label, "No signal");
362
+ });
363
+
364
+ test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
365
+ assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
366
+ assert.match(tileView(ip({ host: "" })).detail, /no address/);
367
+ assert.match(tileView(null).detail, /No camera in this position/);
368
+ assert.equal(tileView(ip({ host: "" })).showPlayer, false);
369
+ });
370
+
371
+ test("a server that says live video is unsupported is believed", () => {
372
+ const cam = ip({
373
+ capabilities: {
374
+ liveVideo: { state: "unsupported", detail: "This camera is not active." },
375
+ },
376
+ });
377
+ const v = tileView(cam, { player: "playing" });
378
+ assert.equal(v.showPlayer, false);
379
+ assert.equal(v.detail, "This camera is not active.");
380
+ });
381
+
382
+ /* ------------------------------------------- the server's reason wins */
383
+
384
+ test("the server's own refusal is shown verbatim, not replaced with a guess", () => {
385
+ // The one that matters: the browser cannot know this, and until the API host
386
+ // is configured it is the true answer for every camera in the estate.
387
+ const noRecorder = "No recorder is configured for this camera's relay.";
388
+ const view = tileView(ip({ unavailableReason: noRecorder }));
389
+ assert.equal(view.detail, noRecorder);
390
+ assert.equal(view.state, "unavailable");
391
+ assert.equal(view.showPlayer, false);
392
+ });
393
+
394
+ test("the server's reason beats the reasons the wall could work out itself", () => {
395
+ const reason = "This camera has no address configured.";
396
+ assert.equal(tileView(ip({ status: "inactive", unavailableReason: reason })).detail, reason);
397
+ });
398
+
399
+ test("an absent or blank reason changes nothing", () => {
400
+ assert.equal(tileView(ip({ unavailableReason: null })).state, "connecting");
401
+ assert.equal(tileView(ip({ unavailableReason: " " })).state, "connecting");
402
+ assert.equal(tileView(ip()).state, "connecting");
403
+ });
404
+
405
+ test("being offline still explains every tile at once, ahead of the server's reason", () => {
406
+ const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
407
+ assert.equal(view.state, "offline");
408
+ });
409
+
410
+ /* ------------------------------------------------------------------ paging */
411
+
412
+ test("the fifth camera on a four-tile wall is reachable, not dropped", () => {
413
+ // The defect this fixes, exactly: a site with five cameras showed four, said
414
+ // "Cameras 5" in the toolbar, and had nowhere to put the fifth.
415
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
416
+ assert.equal(wallPageCount(cams, [], "2x2"), 2);
417
+
418
+ const second = wallTiles(cams, [], "2x2", 1);
419
+ assert.equal(second[0]._id, "e");
420
+ // A short last page is short, not padded - and it must never wrap back
421
+ // round to the first camera, which would show "a" twice on one wall.
422
+ assert.equal(second.length, 1);
423
+ });
424
+
425
+ test("the grid packs to the cameras on it instead of drawing holes", () => {
426
+ // Five cameras on a nine-tile wall: five pictures over two rows, not five
427
+ // pictures and four dead grey boxes over three.
428
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
429
+ const tiles = wallTiles(cams, [], "3x3");
430
+ assert.equal(tiles.length, 5);
431
+ assert.equal(wallRows(tiles.length, "3x3"), 2);
432
+
433
+ assert.equal(wallRows(1, "3x3"), 1);
434
+ assert.equal(wallRows(9, "3x3"), 3);
435
+ // Never taller than the layout asked for, and never zero rows.
436
+ assert.equal(wallRows(99, "3x3"), 3);
437
+ assert.equal(wallRows(0, "2x2"), 1);
438
+ });
439
+
440
+ test("an exactly-full wall is one page, and an empty one is still one", () => {
441
+ const four = ["a", "b", "c", "d"].map((id) => ip({ _id: id }));
442
+ assert.equal(wallPageCount(four, [], "2x2"), 1);
443
+ assert.equal(wallPageCount([], [], "2x2"), 1);
444
+ });
445
+
446
+ test("a page number is clamped rather than left pointing past the end", () => {
447
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
448
+ // Going from 2x2 page 2 to a 3x3 wall: page 1 no longer exists.
449
+ assert.equal(clampPage(1, wallPageCount(cams, [], "3x3")), 0);
450
+ assert.equal(clampPage(-4, 3), 0);
451
+ assert.equal(clampPage(99, 3), 2);
452
+ assert.equal(clampPage(NaN, 3), 0);
453
+ // Asking for a page past the end returns the last one's tiles, never nothing.
454
+ assert.equal(wallTiles(cams, [], "2x2", 9)[0]._id, "e");
455
+ });
456
+
457
+ test("paging respects a selection instead of quietly paging the whole site", () => {
458
+ const cams = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id }));
459
+ assert.equal(wallPageCount(cams, ["a", "b"], "1x1"), 2);
460
+ assert.equal(wallTiles(cams, ["a", "b"], "1x1", 1)[0]._id, "b");
461
+ });
462
+
463
+ /* ------------------------------------------------------------------ health */
464
+
465
+ test("the recorder's state and the player page's state are separate answers", () => {
466
+ const camera = ip();
467
+ // The tile badge says the page is up; the recorder says it is not. Both are
468
+ // true at once when a relay outlives the device behind it, and a wall that
469
+ // merged them would tell a supervisor only the reassuring half.
470
+ assert.equal(tileView(camera, { player: "playing" }).label, "Live");
471
+ assert.equal(healthView({ health: "unreachable", reachable: false }).tone, "down");
472
+ });
473
+
474
+ test("health that has never been asked for does not read as healthy", () => {
475
+ assert.equal(healthView(null).tone, "unknown");
476
+ assert.equal(healthView(undefined).lastFrame, null);
477
+ assert.match(healthView(null).label, /not checked/i);
478
+ });
479
+
480
+ test("a drifted clock is a warning, not a failure", () => {
481
+ assert.equal(healthView({ health: "drifted", reachable: true }).tone, "warn");
482
+ assert.equal(healthView({ health: "ok", reachable: true }).tone, "ok");
483
+ });
484
+
485
+ test("reachability alone is enough when the server sends no summary word", () => {
486
+ assert.equal(healthView({ reachable: true }).tone, "ok");
487
+ assert.equal(healthView({ reachable: false }).tone, "down");
488
+ });
489
+
490
+ test("the server's health sentence is carried through, not rewritten", () => {
491
+ const v = healthView({
492
+ health: "unreachable",
493
+ reachable: false,
494
+ reason: "The recorder did not respond.",
495
+ });
496
+ assert.equal(v.detail, "The recorder did not respond.");
497
+ });
498
+
499
+ /**
500
+ * The firmware-and-clock sentence is the server explaining why two values it
501
+ * never showed are blank. It was drawn on the panel and appended to the tile's
502
+ * tooltip; a guard can do nothing with a firmware version, so it is not carried
503
+ * at all now. Pinned, because "soften it" was the tempting answer and the wrong
504
+ * one.
505
+ */
506
+ test("the firmware and device-clock sentence is not carried to the screen", () => {
507
+ const v = healthView({
508
+ health: "ok",
509
+ reachable: true,
510
+ detailUnavailableReason:
511
+ "Firmware and device clock are not available: this recorder is reachable over video only.",
512
+ });
513
+ assert.equal(v.detail, null);
514
+ assert.equal("deviceDetail" in v, false);
515
+ });
516
+
517
+ /**
518
+ * The complaint that started this: a green "Recorder responding" beside "Last
519
+ * picture this server received: none yet" reads as a contradiction. It is not
520
+ * one — but the panel now simply says nothing when there has never been a
521
+ * picture, which is the only reading a guard can act on.
522
+ */
523
+ test("a recorder that is up with no picture yet reports no picture line at all", () => {
524
+ const v = healthView({ health: "ok", reachable: true, lastFrameAt: null });
525
+ assert.equal(v.tone, "ok");
526
+ assert.equal(v.label, "Recorder responding");
527
+ assert.equal(v.lastFrame, null);
528
+ });
529
+
530
+ test("a picture's age is coarse above a minute, and never a fake time", () => {
531
+ const now = Date.parse("2026-08-11T10:00:00.000Z");
532
+ const at = (ms) => new Date(now - ms).toISOString();
533
+ assert.equal(agoLabel(at(1000), now), "just now");
534
+ assert.equal(agoLabel(at(40_000), now), "40s ago");
535
+ assert.equal(agoLabel(at(6 * 60_000), now), "6 min ago");
536
+ assert.equal(agoLabel(at(3 * 3_600_000), now), "3 h ago");
537
+ // Never invent one: no timestamp and a broken timestamp both mean "no answer".
538
+ assert.equal(agoLabel(null, now), null);
539
+ assert.equal(agoLabel("not a date", now), null);
540
+ // A clock ahead of ours must not read as a picture from the future.
541
+ assert.equal(agoLabel(at(-5000), now), "just now");
542
+ });
543
+
544
+ /* -------------------------------------------------------------------- zoom */
545
+
546
+ test("zoom stops at 4x and never goes below the whole picture", () => {
547
+ assert.equal(clampZoom(0.2), 1);
548
+ assert.equal(clampZoom(99), 4);
549
+ assert.equal(clampZoom(NaN), 1);
550
+ assert.equal(clampZoom(2.5), 2.5);
551
+ });
552
+
553
+ test("a wheel notch zooms by direction, not by however much the device reports", () => {
554
+ assert.ok(zoomStep(1, -120) > 1);
555
+ assert.ok(zoomStep(2, 3) < 2);
556
+ // A trackpad's tiny delta and a mouse's huge one take the same step.
557
+ assert.equal(zoomStep(1, -1), zoomStep(1, -4000));
558
+ assert.equal(zoomStep(1, 120), 1, "cannot zoom out past the whole picture");
559
+ assert.equal(zoomStep(4, -120), 4, "cannot zoom in past the ceiling");
560
+ });
561
+
562
+ test("panning cannot walk the picture off the tile", () => {
563
+ const frame = { width: 400, height: 200 };
564
+ // At 2x the picture is twice the frame, so half the frame each way and no more.
565
+ assert.deepEqual(clampPan({ x: 9999, y: 9999 }, 2, frame), { x: 200, y: 100 });
566
+ assert.deepEqual(clampPan({ x: -9999, y: -9999 }, 2, frame), { x: -200, y: -100 });
567
+ // At 1x there is nothing to pan, so every drag resolves to centred.
568
+ assert.deepEqual(clampPan({ x: 50, y: 50 }, 1, frame), { x: 0, y: 0 });
569
+ // A tile that has not been measured yet cannot be dragged into the void.
570
+ assert.deepEqual(clampPan({ x: 50, y: 50 }, 3, { width: 0, height: 0 }), { x: 0, y: 0 });
571
+ });