@7365admin1/core 3.82.0 → 3.84.0
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/CHANGELOG.md +53 -0
- package/dist/index.d.ts +462 -1
- package/dist/index.js +476 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +451 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/building-map.test.mjs +305 -0
- package/test/robot-adapter.test.mjs +179 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.84.0",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test": "tsup src/config.ts src/utils/robot-cloud.util.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.ts src/utils/lift-contract.util.ts src/utils/anpr-revoke.util.ts src/utils/anpr-plate.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/utils/camera-playback.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/occurrence-book-create.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts src/utils/people-create-gate.util.ts src/utils/service-app-type.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.ts src/utils/lift-contract.util.ts src/utils/anpr-revoke.util.ts src/utils/anpr-plate.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/utils/camera-playback.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/occurrence-book-create.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts src/utils/people-create-gate.util.ts src/utils/service-app-type.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SCALE_M_PER_PX,
|
|
6
|
+
WALL_HEIGHT_M,
|
|
7
|
+
WALL_THICKNESS_M,
|
|
8
|
+
buildingMapFromRobotMaps,
|
|
9
|
+
buildingSolids,
|
|
10
|
+
levelPixelToMetres,
|
|
11
|
+
levelScale,
|
|
12
|
+
levelSolids,
|
|
13
|
+
scaleFromMeasurement,
|
|
14
|
+
wallsFromOccupancyGrid,
|
|
15
|
+
} from "./.build/utils/building-map.util.mjs";
|
|
16
|
+
|
|
17
|
+
/* --------------------------------------------------------------------------
|
|
18
|
+
* A floor plan is pixels; everything downstream is metres. Get the scale wrong
|
|
19
|
+
* and a building is silently the wrong size - which is far harder to notice
|
|
20
|
+
* than an error, because every screen still renders.
|
|
21
|
+
*
|
|
22
|
+
* The shape and the defaults are Open-RMF's, taken from a real
|
|
23
|
+
* office.building.yaml in open-rmf/rmf_demos and traffic_editor's documented
|
|
24
|
+
* behaviour, so a map made here can be exported to RMF and one made there read
|
|
25
|
+
* here.
|
|
26
|
+
* ----------------------------------------------------------------------- */
|
|
27
|
+
|
|
28
|
+
const level = (over = {}) => ({
|
|
29
|
+
name: "L1",
|
|
30
|
+
elevationM: 0,
|
|
31
|
+
scaleMPerPx: 0.05,
|
|
32
|
+
originXM: 0,
|
|
33
|
+
originYM: 0,
|
|
34
|
+
vertices: [],
|
|
35
|
+
walls: [],
|
|
36
|
+
floors: [],
|
|
37
|
+
doors: [],
|
|
38
|
+
measurements: [],
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/* -------------------------------------------------------- RMF's defaults */
|
|
43
|
+
|
|
44
|
+
test("the wall defaults are traffic_editor's, not ours", () => {
|
|
45
|
+
// "By default, the walls are of thickness of 10cm and height 2.5m."
|
|
46
|
+
assert.equal(WALL_HEIGHT_M, 2.5);
|
|
47
|
+
assert.equal(WALL_THICKNESS_M, 0.1);
|
|
48
|
+
// traffic_editor's default before a measurement is taken.
|
|
49
|
+
assert.equal(DEFAULT_SCALE_M_PER_PX, 0.05);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/* -------------------------------------------------------------- the scale */
|
|
53
|
+
|
|
54
|
+
test("a measurement sets metres per pixel", () => {
|
|
55
|
+
// 200px across something known to be 10m.
|
|
56
|
+
assert.equal(scaleFromMeasurement(200, 10), 0.05);
|
|
57
|
+
assert.equal(scaleFromMeasurement(100, 10), 0.1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a nonsense measurement is refused, not silently accepted", () => {
|
|
61
|
+
// A zero or negative scale makes every distance in the building wrong, and
|
|
62
|
+
// wrong-by-a-factor is much harder to spot than a thrown error.
|
|
63
|
+
for (const px of [0, -1, NaN, Infinity]) {
|
|
64
|
+
assert.throws(() => scaleFromMeasurement(px, 10), /pixel distance/);
|
|
65
|
+
}
|
|
66
|
+
for (const m of [0, -5, NaN]) {
|
|
67
|
+
assert.throws(() => scaleFromMeasurement(200, m), /real distance/);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a level's measurement beats its declared scale", () => {
|
|
72
|
+
const l = level({
|
|
73
|
+
scaleMPerPx: 0.5,
|
|
74
|
+
vertices: [{ x: 0, y: 0 }, { x: 200, y: 0 }],
|
|
75
|
+
measurements: [{ start: 0, end: 1, distanceM: 10 }],
|
|
76
|
+
});
|
|
77
|
+
assert.equal(levelScale(l), 0.05);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("with no usable measurement it falls back, and never to zero", () => {
|
|
81
|
+
assert.equal(levelScale(level({ scaleMPerPx: 0.03 })), 0.03);
|
|
82
|
+
assert.equal(levelScale(level({ scaleMPerPx: 0 })), DEFAULT_SCALE_M_PER_PX);
|
|
83
|
+
|
|
84
|
+
// A measurement between two vertices at the same point is not usable.
|
|
85
|
+
const degenerate = level({
|
|
86
|
+
scaleMPerPx: 0.02,
|
|
87
|
+
vertices: [{ x: 5, y: 5 }, { x: 5, y: 5 }],
|
|
88
|
+
measurements: [{ start: 0, end: 1, distanceM: 10 }],
|
|
89
|
+
});
|
|
90
|
+
assert.equal(levelScale(degenerate), 0.02);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/* --------------------------------------------------------- the transform */
|
|
94
|
+
|
|
95
|
+
test("pixels become site metres, with the vertical flip", () => {
|
|
96
|
+
// Image origin is top-left, map origin bottom-left. Without the flip a
|
|
97
|
+
// building comes out mirrored, which reads as "the robot is in the wrong
|
|
98
|
+
// room" rather than as a transform bug.
|
|
99
|
+
const l = level({ originXM: -49.713, originYM: -35.846, scaleMPerPx: 0.05 });
|
|
100
|
+
const p = levelPixelToMetres(l, 100, 200, 800);
|
|
101
|
+
|
|
102
|
+
assert.equal(Number(p.x.toFixed(4)), Number((-49.713 + 100 * 0.05).toFixed(4)));
|
|
103
|
+
assert.equal(Number(p.y.toFixed(4)), Number((-35.846 + (800 - 200) * 0.05).toFixed(4)));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/* ------------------------------------------------------- 3D generation */
|
|
107
|
+
|
|
108
|
+
test("a wall becomes a box standing ON the floor", () => {
|
|
109
|
+
const l = level({
|
|
110
|
+
vertices: [{ x: 0, y: 0 }, { x: 200, y: 0 }],
|
|
111
|
+
walls: [{ start: 0, end: 1 }],
|
|
112
|
+
});
|
|
113
|
+
const { walls } = levelSolids(l, 100);
|
|
114
|
+
|
|
115
|
+
assert.equal(walls.length, 1);
|
|
116
|
+
const w = walls[0];
|
|
117
|
+
assert.equal(w.lengthM, 10); // 200px * 0.05
|
|
118
|
+
assert.equal(w.heightM, WALL_HEIGHT_M);
|
|
119
|
+
assert.equal(w.thicknessM, WALL_THICKNESS_M);
|
|
120
|
+
// Boxes are centred, so a wall on the floor sits at half its height.
|
|
121
|
+
assert.equal(w.centre.z, WALL_HEIGHT_M / 2);
|
|
122
|
+
assert.equal(w.centre.x, 5);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a wall on an upper level stands on THAT level's floor", () => {
|
|
126
|
+
const l = level({
|
|
127
|
+
elevationM: 6,
|
|
128
|
+
vertices: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
|
|
129
|
+
walls: [{ start: 0, end: 1 }],
|
|
130
|
+
});
|
|
131
|
+
assert.equal(levelSolids(l, 100).walls[0].centre.z, 6 + WALL_HEIGHT_M / 2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("wall rotation follows the segment", () => {
|
|
135
|
+
const l = level({
|
|
136
|
+
vertices: [{ x: 0, y: 0 }, { x: 0, y: 100 }],
|
|
137
|
+
walls: [{ start: 0, end: 1 }],
|
|
138
|
+
});
|
|
139
|
+
// Pixel y grows downward and the transform flips it, so a wall drawn down the
|
|
140
|
+
// image runs in -y on the ground.
|
|
141
|
+
assert.equal(Math.abs(levelSolids(l, 100).walls[0].yaw), Math.PI / 2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a zero-length wall is skipped, not emitted as a degenerate box", () => {
|
|
145
|
+
// Two annotations on one vertex would otherwise flicker like a graphics fault.
|
|
146
|
+
const l = level({
|
|
147
|
+
vertices: [{ x: 10, y: 10 }],
|
|
148
|
+
walls: [{ start: 0, end: 0 }, { start: 0, end: 9 }],
|
|
149
|
+
});
|
|
150
|
+
assert.deepEqual(levelSolids(l, 100).walls, []);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a floor polygon needs three points", () => {
|
|
154
|
+
const l = level({
|
|
155
|
+
vertices: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }],
|
|
156
|
+
floors: [{ vertices: [0, 1] }, { vertices: [0, 1, 2] }],
|
|
157
|
+
});
|
|
158
|
+
const { floors } = levelSolids(l, 100);
|
|
159
|
+
assert.equal(floors.length, 1);
|
|
160
|
+
assert.equal(floors[0].points.length, 3);
|
|
161
|
+
assert.equal(floors[0].hole, false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a hole is kept as a hole — that is how a lift shaft is cut", () => {
|
|
165
|
+
const l = level({
|
|
166
|
+
vertices: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }],
|
|
167
|
+
floors: [{ vertices: [0, 1, 2], hole: true }],
|
|
168
|
+
});
|
|
169
|
+
assert.equal(levelSolids(l, 100).floors[0].hole, true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("every level is generated for a whole-building view", () => {
|
|
173
|
+
const map = {
|
|
174
|
+
name: "b",
|
|
175
|
+
lifts: [],
|
|
176
|
+
levels: [level({ name: "L1" }), level({ name: "L2", elevationM: 3 })],
|
|
177
|
+
};
|
|
178
|
+
const solids = buildingSolids(map, () => 100);
|
|
179
|
+
assert.deepEqual(solids.map((s) => s.level), ["L1", "L2"]);
|
|
180
|
+
assert.deepEqual(solids.map((s) => s.elevationM), [0, 3]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/* ------------------------------------------ automatic wall extraction */
|
|
184
|
+
|
|
185
|
+
/** A grid where 0 is a wall and 255 is free space. */
|
|
186
|
+
function grid(rows) {
|
|
187
|
+
const height = rows.length;
|
|
188
|
+
const width = rows[0].length;
|
|
189
|
+
const data = new Uint8Array(width * height);
|
|
190
|
+
rows.forEach((row, y) =>
|
|
191
|
+
[...row].forEach((ch, x) => {
|
|
192
|
+
data[y * width + x] = ch === "#" ? 0 : 255;
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
return { data, width, height };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test("a long dark run in a floor plan becomes ONE wall, not one per pixel", () => {
|
|
199
|
+
// The requirement: uploading a floor plan produces a layout without anyone
|
|
200
|
+
// drawing walls. A SLAM map already marks walls as dark pixels.
|
|
201
|
+
const { vertices, walls } = wallsFromOccupancyGrid(
|
|
202
|
+
grid(["..........", "##########", ".........."]),
|
|
203
|
+
{ minRunPx: 4 },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
assert.equal(walls.length, 1);
|
|
207
|
+
assert.equal(vertices[walls[0].start].x, 0);
|
|
208
|
+
assert.equal(vertices[walls[0].end].x, 9);
|
|
209
|
+
assert.equal(vertices[walls[0].start].y, 1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("vertical walls are found too", () => {
|
|
213
|
+
const { vertices, walls } = wallsFromOccupancyGrid(
|
|
214
|
+
grid(["..#..", "..#..", "..#..", "..#..", "..#.."]),
|
|
215
|
+
{ minRunPx: 4 },
|
|
216
|
+
);
|
|
217
|
+
assert.equal(walls.length, 1);
|
|
218
|
+
assert.equal(vertices[walls[0].start].x, 2);
|
|
219
|
+
assert.equal(vertices[walls[0].end].y, 4);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a closed room yields its four walls", () => {
|
|
223
|
+
const { walls } = wallsFromOccupancyGrid(
|
|
224
|
+
grid([
|
|
225
|
+
"######",
|
|
226
|
+
"#....#",
|
|
227
|
+
"#....#",
|
|
228
|
+
"#....#",
|
|
229
|
+
"######",
|
|
230
|
+
]),
|
|
231
|
+
{ minRunPx: 3 },
|
|
232
|
+
);
|
|
233
|
+
// Two horizontal runs, and the two uprights left once those are consumed.
|
|
234
|
+
assert.equal(walls.length, 4);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("speckle below the minimum run is ignored as noise", () => {
|
|
238
|
+
const { walls } = wallsFromOccupancyGrid(
|
|
239
|
+
grid(["#.#.#.#.#.", ".#.#.#.#.#", "#.#.#.#.#."]),
|
|
240
|
+
{ minRunPx: 4 },
|
|
241
|
+
);
|
|
242
|
+
assert.deepEqual(walls, []);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("corners are shared, so the map stays editable afterwards", () => {
|
|
246
|
+
const { vertices } = wallsFromOccupancyGrid(
|
|
247
|
+
grid(["#####", "#...#", "#####"]),
|
|
248
|
+
{ minRunPx: 3 },
|
|
249
|
+
);
|
|
250
|
+
// De-duplicated: a vertex appears once however many walls meet there.
|
|
251
|
+
const keys = new Set(vertices.map((v) => `${v.x}:${v.y}`));
|
|
252
|
+
assert.equal(keys.size, vertices.length);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("an unreadable grid returns nothing rather than throwing", () => {
|
|
256
|
+
assert.deepEqual(wallsFromOccupancyGrid({ data: [], width: 0, height: 0 }), {
|
|
257
|
+
vertices: [], walls: [],
|
|
258
|
+
});
|
|
259
|
+
assert.deepEqual(wallsFromOccupancyGrid({ data: [1, 2], width: 10, height: 10 }), {
|
|
260
|
+
vertices: [], walls: [],
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/* ------------------------------------------- from the robot's own maps */
|
|
265
|
+
|
|
266
|
+
test("the robot's floor maps become a building, lowest floor first", () => {
|
|
267
|
+
const map = buildingMapFromRobotMaps("Tengfei Tower", [
|
|
268
|
+
{ floorNo: 3, resolution: 0.05, originX: -10, originY: -20 },
|
|
269
|
+
{ floorNo: 1, resolution: 0.05, originX: -49.713, originY: -35.846 },
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
assert.deepEqual(map.levels.map((l) => l.name), ["L1", "L3"]);
|
|
273
|
+
assert.deepEqual(map.levels.map((l) => l.elevationM), [0, 6]);
|
|
274
|
+
assert.equal(map.levels[0].originXM, -49.713);
|
|
275
|
+
assert.equal(map.levels[0].scaleMPerPx, 0.05);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a cleaning area becomes an RMF cleaning-zone waypoint", () => {
|
|
279
|
+
// Their areas ARE RMF's zones - the Clean Task's own marker.
|
|
280
|
+
const map = buildingMapFromRobotMaps("b", [
|
|
281
|
+
{ floorNo: 1, areaList: [{ areaName: "AREA-FF0B0B83", x: 925, y: 604 }] },
|
|
282
|
+
]);
|
|
283
|
+
const zone = map.levels[0].vertices.find((v) => v.isCleaningZone);
|
|
284
|
+
assert.equal(zone.name, "AREA-FF0B0B83");
|
|
285
|
+
assert.equal(zone.x, 925);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a dock at 0,0 on every floor is NOT planted as a charger", () => {
|
|
289
|
+
// The vendor's sample reports baseX/baseY as 0 for every floor. A charger at
|
|
290
|
+
// the origin of every level is worse than none.
|
|
291
|
+
const none = buildingMapFromRobotMaps("b", [{ floorNo: 1, baseX: 0, baseY: 0 }]);
|
|
292
|
+
assert.equal(none.levels[0].vertices.some((v) => v.isCharger), false);
|
|
293
|
+
|
|
294
|
+
const real = buildingMapFromRobotMaps("b", [
|
|
295
|
+
{ floorNo: 1, baseX: 12.5, baseY: 3.25, baseTheta: 1.57 },
|
|
296
|
+
]);
|
|
297
|
+
const charger = real.levels[0].vertices.find((v) => v.isCharger);
|
|
298
|
+
assert.equal(charger.x, 12.5);
|
|
299
|
+
assert.equal(charger.theta, 1.57);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a missing resolution falls back to traffic_editor's default", () => {
|
|
303
|
+
const map = buildingMapFromRobotMaps("b", [{ floorNo: 1 }]);
|
|
304
|
+
assert.equal(map.levels[0].scaleMPerPx, DEFAULT_SCALE_M_PER_PX);
|
|
305
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ROBOT_CAPABILITIES,
|
|
6
|
+
clearRobotAdapters,
|
|
7
|
+
commandSupported,
|
|
8
|
+
missingForTier,
|
|
9
|
+
registerRobotAdapter,
|
|
10
|
+
registeredRobotBrands,
|
|
11
|
+
robotAdapterFor,
|
|
12
|
+
robotTier,
|
|
13
|
+
supports,
|
|
14
|
+
uncontrollableFleetWarning,
|
|
15
|
+
} from "./.build/utils/robot-adapter.util.mjs";
|
|
16
|
+
|
|
17
|
+
/* --------------------------------------------------------------------------
|
|
18
|
+
* The goal changed: fleet management for every brand and model on the market,
|
|
19
|
+
* not one vendor. That only works if the screens ask the ROBOT what it can do
|
|
20
|
+
* instead of assuming, so these guard the capability model and the registry.
|
|
21
|
+
*
|
|
22
|
+
* The capability names and the tier requirements are Open-RMF's, verbatim from
|
|
23
|
+
* the "Fleet Adapters" table in ros2multirobotbook. Keeping them is what lets a
|
|
24
|
+
* brand's datasheet be read straight into a tier.
|
|
25
|
+
* ----------------------------------------------------------------------- */
|
|
26
|
+
|
|
27
|
+
const ALL = Object.fromEntries(ROBOT_CAPABILITIES.map((c) => [c, true]));
|
|
28
|
+
const caps = (...names) => Object.fromEntries(names.map((n) => [n, true]));
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ tiers */
|
|
31
|
+
|
|
32
|
+
test("everything supported is Full Control", () => {
|
|
33
|
+
assert.equal(robotTier(ALL), "full-control");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("nothing supported is no-interface — a real answer, not an error", () => {
|
|
37
|
+
// Our own security robot is this today: a web page on a building's LAN.
|
|
38
|
+
assert.equal(robotTier({}), "no-interface");
|
|
39
|
+
assert.equal(robotTier(null), "no-interface");
|
|
40
|
+
assert.equal(robotTier(undefined), "no-interface");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a tier needs EVERY requirement — partial credit misses the point", () => {
|
|
44
|
+
const nearlyFull = { ...ALL };
|
|
45
|
+
delete nearlyFull.relocalize;
|
|
46
|
+
assert.notEqual(robotTier(nearlyFull), "full-control");
|
|
47
|
+
|
|
48
|
+
const trafficLight = caps(
|
|
49
|
+
"readLocation", "pauseResume", "battery", "sendToDock",
|
|
50
|
+
"startProcess", "pauseProcess", "processCompletion",
|
|
51
|
+
);
|
|
52
|
+
assert.equal(robotTier(trafficLight), "traffic-light");
|
|
53
|
+
|
|
54
|
+
const { battery, ...noBattery } = trafficLight;
|
|
55
|
+
assert.notEqual(robotTier(noBattery), "traffic-light");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("Ounitech today does not reach even Read Only", () => {
|
|
59
|
+
// What their documented API actually gives: pose, dock, and "start a process"
|
|
60
|
+
// (start-area-cleaning). No battery, no path/ETA, no reliable completion.
|
|
61
|
+
const ounitech = caps("readLocation", "sendToDock", "startProcess");
|
|
62
|
+
assert.equal(robotTier(ounitech), "no-interface");
|
|
63
|
+
|
|
64
|
+
// And this is the ask that moves them, in order.
|
|
65
|
+
assert.deepEqual(missingForTier(ounitech, "read-only"), [
|
|
66
|
+
"pathAndEta",
|
|
67
|
+
"battery",
|
|
68
|
+
"processCompletion",
|
|
69
|
+
]);
|
|
70
|
+
assert.deepEqual(missingForTier(ounitech, "traffic-light"), [
|
|
71
|
+
"pauseResume",
|
|
72
|
+
"battery",
|
|
73
|
+
"pauseProcess",
|
|
74
|
+
"processCompletion",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a capability set with false values does not count as support", () => {
|
|
79
|
+
assert.equal(supports({ battery: false }, "battery"), false);
|
|
80
|
+
assert.equal(supports({ battery: true }, "battery"), true);
|
|
81
|
+
assert.equal(supports({}, "battery"), false);
|
|
82
|
+
assert.equal(supports(null, "battery"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a prototype key is not a capability", () => {
|
|
86
|
+
// hasOwnProperty, not a bare lookup - otherwise "constructor" reads as true.
|
|
87
|
+
assert.equal(supports({}, "constructor"), false);
|
|
88
|
+
assert.equal(supports({}, "toString"), false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/* ------------------------------------------- the shared-space safety rule */
|
|
92
|
+
|
|
93
|
+
test("two uncontrollable fleets in one space is refused", () => {
|
|
94
|
+
// Open-RMF: at most ONE read-only fleet in a shared space, "having none is
|
|
95
|
+
// ideal". A read-only cleaning fleet plus a no-interface security robot is
|
|
96
|
+
// exactly the pair to warn about.
|
|
97
|
+
assert.equal(uncontrollableFleetWarning(["read-only"]).ok, true);
|
|
98
|
+
assert.equal(uncontrollableFleetWarning(["no-interface"]).ok, true);
|
|
99
|
+
assert.equal(uncontrollableFleetWarning(["full-control", "read-only"]).ok, true);
|
|
100
|
+
|
|
101
|
+
const bad = uncontrollableFleetWarning(["read-only", "no-interface"]);
|
|
102
|
+
assert.equal(bad.ok, false);
|
|
103
|
+
assert.equal(bad.count, 2);
|
|
104
|
+
assert.match(bad.message, /at most one/i);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("controllable fleets never trigger the warning, however many", () => {
|
|
108
|
+
const many = ["full-control", "full-control", "traffic-light", "traffic-light"];
|
|
109
|
+
assert.equal(uncontrollableFleetWarning(many).ok, true);
|
|
110
|
+
assert.equal(uncontrollableFleetWarning([]).ok, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/* --------------------------------------------------------------- commands */
|
|
114
|
+
|
|
115
|
+
test("a command the brand never claimed is refused", () => {
|
|
116
|
+
const readOnlyish = caps("readLocation");
|
|
117
|
+
for (const kind of ["full", "area", "spot", "return", "pause", "navigate", "relocalize"]) {
|
|
118
|
+
assert.equal(commandSupported(readOnlyish, kind), false, kind);
|
|
119
|
+
}
|
|
120
|
+
assert.equal(commandSupported(ALL, "navigate"), true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("each command maps to the capability it actually needs", () => {
|
|
124
|
+
assert.equal(commandSupported(caps("startProcess"), "area"), true);
|
|
125
|
+
assert.equal(commandSupported(caps("startProcess"), "return"), false);
|
|
126
|
+
assert.equal(commandSupported(caps("sendToDock"), "return"), true);
|
|
127
|
+
assert.equal(commandSupported(caps("pauseProcess"), "cancel"), true);
|
|
128
|
+
assert.equal(commandSupported(caps("navigateToPoint"), "navigate"), true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("an unknown command kind is refused, not defaulted", () => {
|
|
132
|
+
for (const kind of ["selfDestruct", "", null, undefined, "constructor"]) {
|
|
133
|
+
assert.equal(commandSupported(ALL, kind), false, String(kind));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/* --------------------------------------------------------------- registry */
|
|
138
|
+
|
|
139
|
+
test("an adapter is found by brand, and never by fallback", () => {
|
|
140
|
+
clearRobotAdapters();
|
|
141
|
+
const a = { brand: "brand-a", displayName: "Brand A", capabilities: ALL, listRobots: async () => [], getRobot: async () => null };
|
|
142
|
+
registerRobotAdapter(a);
|
|
143
|
+
|
|
144
|
+
assert.equal(robotAdapterFor("brand-a"), a);
|
|
145
|
+
// Falling back to the only registered adapter would send one brand's robot a
|
|
146
|
+
// command in a protocol it does not speak.
|
|
147
|
+
assert.equal(robotAdapterFor("brand-b"), null);
|
|
148
|
+
assert.equal(robotAdapterFor(""), null);
|
|
149
|
+
assert.equal(robotAdapterFor(null), null);
|
|
150
|
+
assert.equal(robotAdapterFor(undefined), null);
|
|
151
|
+
assert.equal(robotAdapterFor(42), null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("an adapter without a brand is refused at registration", () => {
|
|
155
|
+
clearRobotAdapters();
|
|
156
|
+
assert.throws(() => registerRobotAdapter({ displayName: "x" }), /must name its brand/);
|
|
157
|
+
assert.throws(() => registerRobotAdapter(null), /must name its brand/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("the brand list reports each brand's tier, for a settings picker", () => {
|
|
161
|
+
clearRobotAdapters();
|
|
162
|
+
registerRobotAdapter({ brand: "good", displayName: "Good", capabilities: ALL, listRobots: async () => [], getRobot: async () => null });
|
|
163
|
+
registerRobotAdapter({ brand: "poor", displayName: "Poor", capabilities: caps("readLocation"), listRobots: async () => [], getRobot: async () => null });
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(registeredRobotBrands().sort((x, y) => x.brand.localeCompare(y.brand)), [
|
|
166
|
+
{ brand: "good", displayName: "Good", tier: "full-control" },
|
|
167
|
+
{ brand: "poor", displayName: "Poor", tier: "no-interface" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("re-registering a brand replaces it rather than duplicating", () => {
|
|
172
|
+
clearRobotAdapters();
|
|
173
|
+
const base = { brand: "x", displayName: "One", capabilities: {}, listRobots: async () => [], getRobot: async () => null };
|
|
174
|
+
registerRobotAdapter(base);
|
|
175
|
+
registerRobotAdapter({ ...base, displayName: "Two" });
|
|
176
|
+
|
|
177
|
+
assert.equal(registeredRobotBrands().length, 1);
|
|
178
|
+
assert.equal(robotAdapterFor("x").displayName, "Two");
|
|
179
|
+
});
|