@7365admin1/layer-common 3.1.4-staging.54 → 3.1.4-staging.56
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/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/components/CameraWall.vue +625 -0
- package/components/CameraWallTile.vue +333 -0
- package/composables/useSiteSettings.ts +28 -0
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +149 -0
- package/utils/camera-wall.ts +299 -0
- package/middleware/member.ts +0 -4
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="root"
|
|
4
|
+
class="vms-tile"
|
|
5
|
+
:class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
|
|
6
|
+
:tabindex="camera ? 0 : -1"
|
|
7
|
+
:aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
|
|
8
|
+
@click="camera && $emit('focus', camera)"
|
|
9
|
+
@dblclick="camera && toggleFullscreen()"
|
|
10
|
+
@keydown.enter="camera && toggleFullscreen()"
|
|
11
|
+
>
|
|
12
|
+
<!--
|
|
13
|
+
The picture, when there is one to show. This is an embed of the video
|
|
14
|
+
service's own player page: the stored address IS the live view, and there
|
|
15
|
+
is no other transport for live web video today.
|
|
16
|
+
-->
|
|
17
|
+
<iframe
|
|
18
|
+
v-if="camera && view.showPlayer"
|
|
19
|
+
:key="camera.host"
|
|
20
|
+
:src="camera.host"
|
|
21
|
+
class="vms-tile__frame"
|
|
22
|
+
:title="`Live view of ${camera.name || 'camera'}`"
|
|
23
|
+
allowfullscreen
|
|
24
|
+
referrerpolicy="no-referrer"
|
|
25
|
+
@load="onPlayerLoad"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<!--
|
|
29
|
+
A tile with nothing to show says so across the whole tile, not in a
|
|
30
|
+
corner. At nine tiles a supervisor is scanning, and a one-line reason
|
|
31
|
+
tucked under a black rectangle is exactly the thing that gets missed.
|
|
32
|
+
-->
|
|
33
|
+
<div v-if="view.detail" class="vms-tile__overlay">
|
|
34
|
+
<p class="vms-tile__reason">{{ view.detail }}</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Chrome sits OVER the picture, never around it. -->
|
|
38
|
+
<div v-if="camera" class="vms-tile__status">
|
|
39
|
+
<span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
|
|
40
|
+
<!--
|
|
41
|
+
At nine tiles the badge is the DOT ALONE: a status word beside a 6 px
|
|
42
|
+
dot on a small tile is unreadable at the distance a wall is scanned
|
|
43
|
+
from, and the dot is what is being read anyway. The word returns at
|
|
44
|
+
four tiles and one.
|
|
45
|
+
-->
|
|
46
|
+
<span v-if="!dense" class="vms-tile__state">{{ view.label }}</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="camera" class="vms-tile__chrome">
|
|
50
|
+
<!-- The NAME wins the fight for room: it is the tile's entire job. -->
|
|
51
|
+
<span class="vms-tile__name">{{ camera.name || camera._id }}</span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button
|
|
55
|
+
v-if="camera"
|
|
56
|
+
type="button"
|
|
57
|
+
class="vms-tile__expand"
|
|
58
|
+
title="Fill the screen with this camera"
|
|
59
|
+
aria-label="Fill the screen with this camera"
|
|
60
|
+
@click.stop="toggleFullscreen()"
|
|
61
|
+
>
|
|
62
|
+
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|
63
|
+
<path
|
|
64
|
+
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
stroke-width="2"
|
|
68
|
+
stroke-linecap="square"
|
|
69
|
+
/>
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
77
|
+
|
|
78
|
+
import {
|
|
79
|
+
LIVE_CONNECT_TIMEOUT_MS,
|
|
80
|
+
tileView,
|
|
81
|
+
type TPlayerState,
|
|
82
|
+
type TWallCamera,
|
|
83
|
+
} from "../utils/camera-wall";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ONE TILE OF THE CAMERA WALL.
|
|
87
|
+
*
|
|
88
|
+
* Everything this component decides comes from `utils/camera-wall.ts`; what is
|
|
89
|
+
* left here is the picture, the chrome over it, and the one thing only a
|
|
90
|
+
* browser can know — whether the player page ever loaded.
|
|
91
|
+
*
|
|
92
|
+
* ## The ceiling worth knowing before "improving" the live badge
|
|
93
|
+
*
|
|
94
|
+
* We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
|
|
95
|
+
* the page belongs to the video service, not to us, and learning more would
|
|
96
|
+
* mean reaching into another origin's document — which is both blocked by the
|
|
97
|
+
* browser and would break silently the day that page changes. So **"Live" means
|
|
98
|
+
* the player page is up**. If the service is up and the camera behind it is
|
|
99
|
+
* dead, the operator sees that service's own empty player, exactly as they do
|
|
100
|
+
* in the app they use today. Closing that honestly is a change on the video
|
|
101
|
+
* service's side, not here.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
const props = withDefaults(
|
|
105
|
+
defineProps<{
|
|
106
|
+
camera: TWallCamera | null;
|
|
107
|
+
/** Nine tiles: drop the status word, keep the dot. */
|
|
108
|
+
dense?: boolean;
|
|
109
|
+
/** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
|
|
110
|
+
online?: boolean;
|
|
111
|
+
focused?: boolean;
|
|
112
|
+
}>(),
|
|
113
|
+
{ dense: false, online: true, focused: false }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
|
|
117
|
+
|
|
118
|
+
const root = ref<HTMLElement | null>(null);
|
|
119
|
+
const player = ref<TPlayerState>("loading");
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
|
|
122
|
+
const view = computed(() =>
|
|
123
|
+
tileView(props.camera, { online: props.online, player: player.value })
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
function onPlayerLoad() {
|
|
127
|
+
player.value = "ready";
|
|
128
|
+
clearTimer();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clearTimer() {
|
|
132
|
+
if (timer) {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
timer = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Restart the clock whenever the address changes — a tile reused for a
|
|
140
|
+
* different camera must not inherit the previous one's verdict, in either
|
|
141
|
+
* direction.
|
|
142
|
+
*/
|
|
143
|
+
watch(
|
|
144
|
+
() => props.camera?.host,
|
|
145
|
+
(host) => {
|
|
146
|
+
clearTimer();
|
|
147
|
+
player.value = "loading";
|
|
148
|
+
if (!host) return;
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
if (player.value === "loading") player.value = "timeout";
|
|
151
|
+
}, LIVE_CONNECT_TIMEOUT_MS);
|
|
152
|
+
},
|
|
153
|
+
{ immediate: true }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
onBeforeUnmount(clearTimer);
|
|
157
|
+
|
|
158
|
+
/** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
|
|
159
|
+
async function toggleFullscreen() {
|
|
160
|
+
const el = root.value;
|
|
161
|
+
if (!el) return;
|
|
162
|
+
try {
|
|
163
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
164
|
+
else await el.requestFullscreen();
|
|
165
|
+
} catch {
|
|
166
|
+
// A browser that refuses fullscreen (permissions policy, an iframe, an old
|
|
167
|
+
// Safari) is not an error worth interrupting a supervisor over — the tile
|
|
168
|
+
// keeps working exactly as it did.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.vms-tile {
|
|
175
|
+
position: relative;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
background: var(--vms-tile, #101418);
|
|
178
|
+
border-radius: var(--vms-radius, 3px);
|
|
179
|
+
outline: none;
|
|
180
|
+
min-height: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.vms-tile--focused {
|
|
184
|
+
box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.vms-tile:focus-visible {
|
|
188
|
+
box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.vms-tile--empty {
|
|
192
|
+
background: transparent;
|
|
193
|
+
border: 1px dashed var(--vms-line, #232b36);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.vms-tile__frame {
|
|
197
|
+
width: 100%;
|
|
198
|
+
height: 100%;
|
|
199
|
+
border: none;
|
|
200
|
+
display: block;
|
|
201
|
+
background: #000;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.vms-tile__overlay {
|
|
205
|
+
position: absolute;
|
|
206
|
+
inset: 0;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
padding: 10px;
|
|
211
|
+
background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
|
|
212
|
+
pointer-events: none;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.vms-tile__reason {
|
|
216
|
+
margin: 0;
|
|
217
|
+
text-align: center;
|
|
218
|
+
color: var(--vms-text-dim, #94a0b0);
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
line-height: 16px;
|
|
221
|
+
max-width: 32ch;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.vms-tile--dense .vms-tile__reason {
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
line-height: 15px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.vms-tile__status {
|
|
230
|
+
position: absolute;
|
|
231
|
+
top: 6px;
|
|
232
|
+
right: 6px;
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
gap: 5px;
|
|
236
|
+
padding: 2px 6px;
|
|
237
|
+
border-radius: var(--vms-radius, 3px);
|
|
238
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
239
|
+
pointer-events: none;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.vms-tile__dot {
|
|
243
|
+
width: 7px;
|
|
244
|
+
height: 7px;
|
|
245
|
+
border-radius: 50%;
|
|
246
|
+
background: var(--vms-text-faint, #5c6675);
|
|
247
|
+
flex: none;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* State colours only. The chrome itself stays achromatic on purpose. */
|
|
251
|
+
.vms-tile__dot--live {
|
|
252
|
+
background: #4caf50;
|
|
253
|
+
}
|
|
254
|
+
.vms-tile__dot--connecting {
|
|
255
|
+
background: #fb8c00;
|
|
256
|
+
}
|
|
257
|
+
.vms-tile__dot--no-signal,
|
|
258
|
+
.vms-tile__dot--offline {
|
|
259
|
+
background: #e0241c;
|
|
260
|
+
}
|
|
261
|
+
.vms-tile__dot--unavailable {
|
|
262
|
+
background: var(--vms-text-faint, #5c6675);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.vms-tile__state {
|
|
266
|
+
color: var(--vms-text, #e8ecf1);
|
|
267
|
+
font-size: 11px;
|
|
268
|
+
line-height: 14px;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.vms-tile__chrome {
|
|
273
|
+
position: absolute;
|
|
274
|
+
left: 6px;
|
|
275
|
+
right: 6px;
|
|
276
|
+
bottom: 5px;
|
|
277
|
+
display: flex;
|
|
278
|
+
pointer-events: none;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.vms-tile__name {
|
|
282
|
+
color: var(--vms-text, #e8ecf1);
|
|
283
|
+
font-size: 12px;
|
|
284
|
+
line-height: 16px;
|
|
285
|
+
font-weight: 600;
|
|
286
|
+
padding: 1px 6px;
|
|
287
|
+
border-radius: var(--vms-radius, 3px);
|
|
288
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
289
|
+
overflow: hidden;
|
|
290
|
+
text-overflow: ellipsis;
|
|
291
|
+
white-space: nowrap;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.vms-tile--dense .vms-tile__name {
|
|
295
|
+
font-size: 11px;
|
|
296
|
+
line-height: 15px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.vms-tile__expand {
|
|
300
|
+
position: absolute;
|
|
301
|
+
top: 6px;
|
|
302
|
+
left: 6px;
|
|
303
|
+
display: flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
justify-content: center;
|
|
306
|
+
width: 24px;
|
|
307
|
+
height: 24px;
|
|
308
|
+
border-radius: var(--vms-radius, 3px);
|
|
309
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
310
|
+
color: var(--vms-text, #e8ecf1);
|
|
311
|
+
border: none;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
opacity: 0;
|
|
314
|
+
transition: opacity 120ms ease;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* The control is always THERE; it only stops competing with the picture. */
|
|
318
|
+
.vms-tile:hover .vms-tile__expand,
|
|
319
|
+
.vms-tile:focus-within .vms-tile__expand {
|
|
320
|
+
opacity: 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.vms-tile__expand:focus-visible {
|
|
324
|
+
opacity: 1;
|
|
325
|
+
outline: 2px solid var(--vms-accent, #5b9be0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
|
|
329
|
+
.vms-tile:fullscreen {
|
|
330
|
+
border-radius: 0;
|
|
331
|
+
background: #000;
|
|
332
|
+
}
|
|
333
|
+
</style>
|
|
@@ -99,6 +99,33 @@ export default function () {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The monitoring wall for one site.
|
|
104
|
+
*
|
|
105
|
+
* A different endpoint from `getAllSiteCameras`, and the difference is the
|
|
106
|
+
* point. That one is the Settings panel's paginated CRUD list; this one is
|
|
107
|
+
* purpose-built for a wall and is what the React Native app already uses:
|
|
108
|
+
*
|
|
109
|
+
* - it is **site-scoped and authorised server-side** — the caller must be a
|
|
110
|
+
* member of the site, of its owning organisation, or work for an
|
|
111
|
+
* organisation engaged to serve it;
|
|
112
|
+
* - it returns `type: "ip"` cameras only, so an ANPR unit can never land on a
|
|
113
|
+
* monitoring wall;
|
|
114
|
+
* - it returns each camera's **capability descriptor** and the server's own
|
|
115
|
+
* `unavailableReason`, so a tile explains itself instead of showing a black
|
|
116
|
+
* rectangle;
|
|
117
|
+
* - it never returns a credential, and it contacts no device.
|
|
118
|
+
*
|
|
119
|
+
* Unpaginated by design: a wall shows a site's cameras, and paging them was
|
|
120
|
+
* an artefact of borrowing the CRUD list.
|
|
121
|
+
*/
|
|
122
|
+
async function getSiteWall(siteId: string) {
|
|
123
|
+
return await useNuxtApp().$api<Record<string, any>>(
|
|
124
|
+
`/api/site-cameras/site/${siteId}/wall`,
|
|
125
|
+
{ method: "GET" }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
async function updateSiteInformation(
|
|
103
130
|
siteId: string,
|
|
104
131
|
payload: { bgImage: string; description: string; docs: { id: string; name: string }[] }
|
|
@@ -175,6 +202,7 @@ export default function () {
|
|
|
175
202
|
updateSite,
|
|
176
203
|
addCamera,
|
|
177
204
|
getAllSiteCameras,
|
|
205
|
+
getSiteWall,
|
|
178
206
|
setSiteGuardPosts,
|
|
179
207
|
updateSiteCamera,
|
|
180
208
|
deleteSiteCameraById,
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
capabilityView,
|
|
6
|
+
isDenseLayout,
|
|
7
|
+
tileView,
|
|
8
|
+
wallCameras,
|
|
9
|
+
wallLayout,
|
|
10
|
+
wallTiles,
|
|
11
|
+
} from "./camera-wall.ts";
|
|
12
|
+
|
|
13
|
+
const ip = (over: Record<string, unknown> = {}) => ({
|
|
14
|
+
_id: "c1",
|
|
15
|
+
name: "GYM AREA",
|
|
16
|
+
type: "ip",
|
|
17
|
+
status: "active",
|
|
18
|
+
host: "https://example.invalid/4",
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/* ---------------------------------------------------------------- rule 1 */
|
|
23
|
+
|
|
24
|
+
test("an ANPR camera can never reach the wall", () => {
|
|
25
|
+
const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
|
|
26
|
+
const kept = wallCameras(items);
|
|
27
|
+
assert.equal(kept.length, 1);
|
|
28
|
+
assert.equal(kept[0]._id, "c1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a junk response is an empty wall, not a crash", () => {
|
|
32
|
+
assert.deepEqual(wallCameras(undefined), []);
|
|
33
|
+
assert.deepEqual(wallCameras([null, 3, "x"]), []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/* ---------------------------------------------------------- layout + order */
|
|
37
|
+
|
|
38
|
+
test("an unknown layout key falls back to four tiles", () => {
|
|
39
|
+
assert.equal(wallLayout("7x7").tiles, 4);
|
|
40
|
+
assert.equal(isDenseLayout("3x3"), true);
|
|
41
|
+
assert.equal(isDenseLayout("2x2"), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no selection means the site's own order, not a blank wall", () => {
|
|
45
|
+
const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
|
|
46
|
+
const tiles = wallTiles(cams, [], "2x2");
|
|
47
|
+
assert.equal(tiles.length, 4);
|
|
48
|
+
assert.equal(tiles[0]._id, "a");
|
|
49
|
+
assert.equal(tiles[1]._id, "b");
|
|
50
|
+
assert.equal(tiles[2], null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
|
|
54
|
+
const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
|
|
55
|
+
const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
|
|
56
|
+
assert.equal(tiles.length, 1);
|
|
57
|
+
assert.equal(tiles[0]._id, "b");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/* ---------------------------------------------------------------- rule 2/3 */
|
|
61
|
+
|
|
62
|
+
test("no descriptor at all counts as NOT available", () => {
|
|
63
|
+
const v = capabilityView(ip(), "ptz");
|
|
64
|
+
assert.equal(v.available, false);
|
|
65
|
+
assert.equal(v.tag, "Not checked yet");
|
|
66
|
+
assert.match(v.detail, /has not been asked/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the server's own sentence is shown, never rewritten", () => {
|
|
70
|
+
const cam = ip({
|
|
71
|
+
capabilities: {
|
|
72
|
+
playback: {
|
|
73
|
+
state: "unsupported",
|
|
74
|
+
detail: "No direct connection to this camera's recorder is configured on this server.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const v = capabilityView(cam, "playback");
|
|
79
|
+
assert.equal(v.tag, "Not set up yet");
|
|
80
|
+
assert.equal(
|
|
81
|
+
v.detail,
|
|
82
|
+
"No direct connection to this camera's recorder is configured on this server."
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a supported capability wears no tag", () => {
|
|
87
|
+
const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
|
|
88
|
+
assert.equal(capabilityView(cam, "playback").available, true);
|
|
89
|
+
assert.equal(capabilityView(cam, "playback").tag, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/* ------------------------------------------------------------------ rule 4 */
|
|
93
|
+
|
|
94
|
+
test("a page that has not loaded is Connecting, never Live", () => {
|
|
95
|
+
assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
|
|
96
|
+
assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
|
|
97
|
+
assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the browser being offline outranks everything and says so differently", () => {
|
|
101
|
+
assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
|
|
102
|
+
assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
|
|
106
|
+
assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
|
|
107
|
+
assert.match(tileView(ip({ host: "" })).detail, /no address/);
|
|
108
|
+
assert.match(tileView(null).detail, /No camera in this position/);
|
|
109
|
+
assert.equal(tileView(ip({ host: "" })).showPlayer, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a server that says live video is unsupported is believed", () => {
|
|
113
|
+
const cam = ip({
|
|
114
|
+
capabilities: {
|
|
115
|
+
liveVideo: { state: "unsupported", detail: "This camera is not active." },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const v = tileView(cam, { player: "ready" });
|
|
119
|
+
assert.equal(v.showPlayer, false);
|
|
120
|
+
assert.equal(v.detail, "This camera is not active.");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/* ------------------------------------------- the server's reason wins */
|
|
124
|
+
|
|
125
|
+
test("the server's own refusal is shown verbatim, not replaced with a guess", () => {
|
|
126
|
+
// The one that matters: the browser cannot know this, and until the API host
|
|
127
|
+
// is configured it is the true answer for every camera in the estate.
|
|
128
|
+
const noRecorder = "No recorder is configured for this camera's relay.";
|
|
129
|
+
const view = tileView(ip({ unavailableReason: noRecorder }));
|
|
130
|
+
assert.equal(view.detail, noRecorder);
|
|
131
|
+
assert.equal(view.state, "unavailable");
|
|
132
|
+
assert.equal(view.showPlayer, false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the server's reason beats the reasons the wall could work out itself", () => {
|
|
136
|
+
const reason = "This camera has no address configured.";
|
|
137
|
+
assert.equal(tileView(ip({ status: "inactive", unavailableReason: reason })).detail, reason);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an absent or blank reason changes nothing", () => {
|
|
141
|
+
assert.equal(tileView(ip({ unavailableReason: null })).state, "connecting");
|
|
142
|
+
assert.equal(tileView(ip({ unavailableReason: " " })).state, "connecting");
|
|
143
|
+
assert.equal(tileView(ip()).state, "connecting");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("being offline still explains every tile at once, ahead of the server's reason", () => {
|
|
147
|
+
const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
|
|
148
|
+
assert.equal(view.state, "offline");
|
|
149
|
+
});
|