@7365admin1/layer-common 3.1.4-staging.53 → 3.1.4-staging.55
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 +650 -0
- package/components/CameraWallTile.vue +333 -0
- package/components/DashboardMain.vue +10 -33
- package/composables/useEquipmentManagementPermission.ts +1 -1
- package/composables/useEquipmentPermission.ts +1 -1
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +121 -0
- package/utils/camera-wall.ts +281 -0
|
@@ -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>
|
|
@@ -1483,6 +1483,7 @@ const props = defineProps({
|
|
|
1483
1483
|
},
|
|
1484
1484
|
title: { type: String, default: "" },
|
|
1485
1485
|
extraWidgetKeys: { type: Array as () => string[], default: () => [] },
|
|
1486
|
+
hiddenWidgetKeys: { type: Array as () => string[], default: () => [] },
|
|
1486
1487
|
showHeaderActions: { type: Boolean, default: true },
|
|
1487
1488
|
appKey: {
|
|
1488
1489
|
type: String as () =>
|
|
@@ -1530,51 +1531,26 @@ const { downloadPDF } = usePDFDownload();
|
|
|
1530
1531
|
const route = useRoute();
|
|
1531
1532
|
|
|
1532
1533
|
const propertyWidgetPermissions: Record<string, string[]> = {
|
|
1533
|
-
workOrders: [
|
|
1534
|
-
"work_orders:see-all-work-orders",
|
|
1535
|
-
"work-order:see-all-work-orders",
|
|
1536
|
-
],
|
|
1534
|
+
workOrders: ["work_orders:see-all-work-orders"],
|
|
1537
1535
|
incidents: ["incident-reports:see-incident-reports"],
|
|
1538
|
-
visitors: ["
|
|
1536
|
+
visitors: ["visitor-mgmt:see-all-visitor"],
|
|
1539
1537
|
facilityBookings: ["facility-booking-mgmt:see-all-facility-booking"],
|
|
1540
|
-
activity: [
|
|
1541
|
-
"work_orders:see-all-work-orders",
|
|
1542
|
-
"work-order:see-all-work-orders",
|
|
1543
|
-
],
|
|
1538
|
+
activity: ["work_orders:see-all-work-orders"],
|
|
1544
1539
|
upcomingEvents: ["event-mgmt:see-all-event"],
|
|
1545
1540
|
todayReminders: ["event-mgmt:see-all-event"],
|
|
1546
1541
|
todayAttentions: [
|
|
1547
1542
|
"incident-reports:see-incident-reports",
|
|
1548
1543
|
"facility-booking-mgmt:see-all-facility-booking",
|
|
1549
1544
|
],
|
|
1550
|
-
workOrderStatus: [
|
|
1551
|
-
"work_orders:see-all-work-orders",
|
|
1552
|
-
"work-order:see-all-work-orders",
|
|
1553
|
-
],
|
|
1545
|
+
workOrderStatus: ["work_orders:see-all-work-orders"],
|
|
1554
1546
|
feedbacks: ["feedbacks:see-all-feedback"],
|
|
1555
1547
|
activePatrol: ["virtual-patrol:see-all-virtual-patrol-logs"],
|
|
1556
1548
|
};
|
|
1557
1549
|
|
|
1558
1550
|
const moduleWidgetPermissions: Record<string, string[]> = {
|
|
1559
|
-
openWorkOrder: [
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
"work-order:see-all-work-orders",
|
|
1563
|
-
"work-orders:see-all-work-orders",
|
|
1564
|
-
],
|
|
1565
|
-
workOrders: [
|
|
1566
|
-
"work_orders:see-all-work-orders",
|
|
1567
|
-
"work_order:see-all-work-orders",
|
|
1568
|
-
"work-order:see-all-work-orders",
|
|
1569
|
-
"work-orders:see-all-work-orders",
|
|
1570
|
-
],
|
|
1571
|
-
activity: [
|
|
1572
|
-
"work_orders:see-all-work-orders",
|
|
1573
|
-
"work_order:see-all-work-orders",
|
|
1574
|
-
"work-order:see-all-work-orders",
|
|
1575
|
-
"work-orders:see-all-work-orders",
|
|
1576
|
-
"work_orders:see-all-work-orders",
|
|
1577
|
-
],
|
|
1551
|
+
openWorkOrder: ["work_orders:see-all-work-orders"],
|
|
1552
|
+
workOrders: ["work_orders:see-all-work-orders"],
|
|
1553
|
+
activity: ["work_orders:see-all-work-orders"],
|
|
1578
1554
|
recentActivity: [
|
|
1579
1555
|
"work_orders:see-all-work-orders",
|
|
1580
1556
|
"work_order:see-all-work-orders",
|
|
@@ -1594,7 +1570,7 @@ const moduleWidgetPermissions: Record<string, string[]> = {
|
|
|
1594
1570
|
"incident-reports:see-incident-reports",
|
|
1595
1571
|
"incident-reports:see-all-incident-reports",
|
|
1596
1572
|
],
|
|
1597
|
-
visitors: ["visitor:see-all-visitor"],
|
|
1573
|
+
visitors: ["visitor-mgmt:see-all-visitor"],
|
|
1598
1574
|
facilityBookings: ["facility-booking:see-all-facility-booking"],
|
|
1599
1575
|
patrolCompliance: [
|
|
1600
1576
|
"virtual-patrol:see-all-virtual-patrol-logs",
|
|
@@ -1610,6 +1586,7 @@ function hasSchedulePermission(permissions: string[], action: string) {
|
|
|
1610
1586
|
}
|
|
1611
1587
|
|
|
1612
1588
|
function canViewWidget(key: string) {
|
|
1589
|
+
if (props.hiddenWidgetKeys.includes(key)) return false;
|
|
1613
1590
|
const permissions = userAppRole.value?.permissions;
|
|
1614
1591
|
const modulePermissions = isPropertyMode.value
|
|
1615
1592
|
? propertyWidgetPermissions[key]
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
});
|