@7365admin1/core 3.81.0 → 3.83.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.81.0",
4
+ "version": "3.83.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/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/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,271 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ LIFT_DOOR,
6
+ LIFT_MODE,
7
+ LIFT_MOTION,
8
+ LIFT_REQUEST,
9
+ createLiftSimulator,
10
+ holdsLift,
11
+ isLiftAvailable,
12
+ isSafeToCross,
13
+ servesFloor,
14
+ } from "./.build/utils/lift-contract.util.mjs";
15
+
16
+ /* --------------------------------------------------------------------------
17
+ * The lift is the one part of this system that can injure somebody, and the
18
+ * Inovance register map has not arrived, so every decision here is made against
19
+ * a simulator rather than a real car. These assert the decisions that would
20
+ * otherwise only be discovered on a live lift.
21
+ *
22
+ * Enum values match rmf_lift_msgs verbatim (open-rmf/rmf_internal_msgs).
23
+ * ----------------------------------------------------------------------- */
24
+
25
+ const FLOORS = ["L1", "L2", "L3", "L4"];
26
+ const sim = (o = {}) => createLiftSimulator({ name: "LIFT-A", floors: FLOORS, ...o });
27
+
28
+ const stateWith = (over = {}) => ({
29
+ liftName: "LIFT-A",
30
+ liftTime: 0,
31
+ availableFloors: [...FLOORS],
32
+ currentFloor: "L1",
33
+ destinationFloor: "L1",
34
+ doorState: LIFT_DOOR.OPEN,
35
+ motionState: LIFT_MOTION.STOPPED,
36
+ currentMode: LIFT_MODE.HUMAN,
37
+ sessionId: "",
38
+ ...over,
39
+ });
40
+
41
+ /* ------------------------------------------------- the enums match RMF */
42
+
43
+ test("the enum values are rmf_lift_msgs values, not our own numbering", () => {
44
+ assert.deepEqual(LIFT_DOOR, { CLOSED: 0, MOVING: 1, OPEN: 2 });
45
+ assert.deepEqual(LIFT_MOTION, { STOPPED: 0, UP: 1, DOWN: 2, UNKNOWN: 3 });
46
+ assert.deepEqual(LIFT_MODE, {
47
+ UNKNOWN: 0, HUMAN: 1, AGV: 2, FIRE: 3, OFFLINE: 4, EMERGENCY: 5,
48
+ });
49
+ assert.deepEqual(LIFT_REQUEST, { END_SESSION: 0, AGV_MODE: 1, HUMAN_MODE: 2 });
50
+ });
51
+
52
+ /* ------------------------------------------------------------ availability */
53
+
54
+ test("a lift in FIRE or EMERGENCY mode is never available", () => {
55
+ // During an evacuation the lift belongs to the fire service. A robot must not
56
+ // be queued for it, whatever else is true.
57
+ for (const mode of [LIFT_MODE.FIRE, LIFT_MODE.EMERGENCY, LIFT_MODE.OFFLINE, LIFT_MODE.UNKNOWN]) {
58
+ const s = stateWith({ currentMode: mode });
59
+ assert.equal(isLiftAvailable(s), false, `mode ${mode}`);
60
+ assert.equal(isSafeToCross(s, "L1"), false, `mode ${mode}`);
61
+ }
62
+ });
63
+
64
+ test("a lift that has never reported is not available", () => {
65
+ assert.equal(isLiftAvailable(null), false);
66
+ assert.equal(isSafeToCross(null, "L1"), false);
67
+ assert.equal(servesFloor(null, "L1"), false);
68
+ });
69
+
70
+ /* -------------------------------------------------------- crossing the door */
71
+
72
+ test("a MOVING door is not an open door", () => {
73
+ // The branch that matters most: telling a robot to cross a closing door plane
74
+ // is how a robot gets struck.
75
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.MOVING }), "L1"), false);
76
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.CLOSED }), "L1"), false);
77
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.OPEN }), "L1"), true);
78
+ });
79
+
80
+ test("a car still in motion is never safe to cross, doors notwithstanding", () => {
81
+ for (const motion of [LIFT_MOTION.UP, LIFT_MOTION.DOWN, LIFT_MOTION.UNKNOWN]) {
82
+ assert.equal(
83
+ isSafeToCross(stateWith({ motionState: motion, doorState: LIFT_DOOR.OPEN }), "L1"),
84
+ false,
85
+ `motion ${motion}`
86
+ );
87
+ }
88
+ });
89
+
90
+ test("a car at the WRONG floor is not safe to cross", () => {
91
+ // A passenger redirecting the car mid-journey is exactly this case.
92
+ assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L1"), false);
93
+ assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L3"), true);
94
+ });
95
+
96
+ /* ------------------------------------------------------------- the session */
97
+
98
+ test("an empty session id never holds the lift", () => {
99
+ // Otherwise a caller that forgot to set one is handed a lift somebody is riding.
100
+ assert.equal(holdsLift(stateWith({ sessionId: "" }), ""), false);
101
+ assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), ""), false);
102
+ assert.equal(holdsLift(stateWith({ sessionId: "" }), "j-1"), false);
103
+ assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), "j-1"), true);
104
+ });
105
+
106
+ test("a floor the lift does not serve is not a destination", () => {
107
+ assert.equal(servesFloor(stateWith(), "L9"), false);
108
+ assert.equal(servesFloor(stateWith(), ""), false);
109
+ assert.equal(servesFloor(stateWith(), "L3"), true);
110
+ });
111
+
112
+ /* ------------------------------------------------------------- simulator */
113
+
114
+ test("a requested lift travels, arrives, and opens its doors", async () => {
115
+ const lift = sim({ msPerFloor: 1_000 });
116
+
117
+ await lift.request({
118
+ liftName: "LIFT-A", sessionId: "j-1",
119
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L3",
120
+ doorState: LIFT_DOOR.OPEN,
121
+ });
122
+
123
+ let s = await lift.read();
124
+ assert.equal(s.motionState, LIFT_MOTION.UP);
125
+ assert.equal(s.doorState, LIFT_DOOR.CLOSED, "doors shut while travelling");
126
+ assert.equal(isSafeToCross(s, "L3"), false);
127
+
128
+ lift.tick(2_000); // two floors
129
+ s = await lift.read();
130
+ assert.equal(s.currentFloor, "L3");
131
+ assert.equal(s.motionState, LIFT_MOTION.STOPPED);
132
+ assert.equal(s.doorState, LIFT_DOOR.OPEN);
133
+ assert.equal(isSafeToCross(s, "L3"), true);
134
+ });
135
+
136
+ test("the doors close themselves — this is why the hold must be refreshed", async () => {
137
+ // The tightest timing in the whole design: a passenger lift times its doors
138
+ // out while the robot is still crossing.
139
+ const lift = sim({ msPerFloor: 1_000, doorDwellMs: 4_000 });
140
+ await lift.request({
141
+ liftName: "LIFT-A", sessionId: "j-1",
142
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
143
+ doorState: LIFT_DOOR.OPEN,
144
+ });
145
+
146
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
147
+ lift.tick(4_000);
148
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED, "closed on its own");
149
+ });
150
+
151
+ test("refreshing the hold keeps the doors open", async () => {
152
+ const lift = sim({ doorDwellMs: 4_000 });
153
+ await lift.request({
154
+ liftName: "LIFT-A", sessionId: "j-1",
155
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
156
+ doorState: LIFT_DOOR.OPEN,
157
+ });
158
+
159
+ for (let i = 0; i < 5; i++) {
160
+ lift.tick(3_000);
161
+ await lift.refresh("j-1");
162
+ }
163
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
164
+ });
165
+
166
+ test("refreshing somebody else's session does NOT hold the doors", async () => {
167
+ const lift = sim({ doorDwellMs: 4_000 });
168
+ await lift.request({
169
+ liftName: "LIFT-A", sessionId: "j-1",
170
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
171
+ doorState: LIFT_DOOR.OPEN,
172
+ });
173
+
174
+ lift.tick(3_000);
175
+ await lift.refresh("j-2"); // not the holder
176
+ lift.tick(2_000);
177
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED);
178
+ });
179
+
180
+ test("a lift held by one journey is refused to another, not stolen", async () => {
181
+ const lift = sim();
182
+ await lift.request({
183
+ liftName: "LIFT-A", sessionId: "j-1",
184
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
185
+ doorState: LIFT_DOOR.OPEN,
186
+ });
187
+
188
+ await assert.rejects(
189
+ () => lift.request({
190
+ liftName: "LIFT-A", sessionId: "j-2",
191
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
192
+ doorState: LIFT_DOOR.OPEN,
193
+ }),
194
+ /held by j-1/
195
+ );
196
+ });
197
+
198
+ test("release is safe to call twice, and frees the lift for the next journey", async () => {
199
+ const lift = sim();
200
+ await lift.request({
201
+ liftName: "LIFT-A", sessionId: "j-1",
202
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
203
+ doorState: LIFT_DOOR.OPEN,
204
+ });
205
+
206
+ await lift.release("j-1");
207
+ await lift.release("j-1"); // the watchdog fires unconditionally
208
+ const s = await lift.read();
209
+ assert.equal(s.sessionId, "");
210
+ assert.equal(s.currentMode, LIFT_MODE.HUMAN, "handed back to passengers");
211
+
212
+ await lift.request({
213
+ liftName: "LIFT-A", sessionId: "j-2",
214
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
215
+ doorState: LIFT_DOOR.OPEN,
216
+ });
217
+ assert.equal((await lift.read()).sessionId, "j-2");
218
+ });
219
+
220
+ test("releasing a lift somebody else holds does nothing", async () => {
221
+ const lift = sim();
222
+ await lift.request({
223
+ liftName: "LIFT-A", sessionId: "j-1",
224
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
225
+ doorState: LIFT_DOOR.OPEN,
226
+ });
227
+
228
+ await lift.release("j-2");
229
+ assert.equal((await lift.read()).sessionId, "j-1", "still held by j-1");
230
+ });
231
+
232
+ test("a floor the lift does not serve is refused", async () => {
233
+ const lift = sim();
234
+ await assert.rejects(
235
+ () => lift.request({
236
+ liftName: "LIFT-A", sessionId: "j-1",
237
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "B2",
238
+ doorState: LIFT_DOOR.OPEN,
239
+ }),
240
+ /does not serve B2/
241
+ );
242
+ });
243
+
244
+ test("END_SESSION hands the lift back to passengers", async () => {
245
+ const lift = sim();
246
+ await lift.request({
247
+ liftName: "LIFT-A", sessionId: "j-1",
248
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
249
+ doorState: LIFT_DOOR.OPEN,
250
+ });
251
+ assert.equal((await lift.read()).currentMode, LIFT_MODE.AGV);
252
+
253
+ await lift.request({
254
+ liftName: "LIFT-A", sessionId: "j-1",
255
+ requestType: LIFT_REQUEST.END_SESSION, destinationFloor: "L2",
256
+ doorState: LIFT_DOOR.CLOSED,
257
+ });
258
+ const s = await lift.read();
259
+ assert.equal(s.sessionId, "");
260
+ assert.equal(s.currentMode, LIFT_MODE.HUMAN);
261
+ });
262
+
263
+ test("read() returns a copy — a caller cannot mutate the lift", async () => {
264
+ const lift = sim();
265
+ const s = await lift.read();
266
+ s.availableFloors.push("L99");
267
+ s.currentFloor = "L4";
268
+ const again = await lift.read();
269
+ assert.equal(again.availableFloors.includes("L99"), false);
270
+ assert.equal(again.currentFloor, "L1");
271
+ });
@@ -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
+ });
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { parseRobotCommand } from "./.build/utils/robot-cloud.util.mjs";
5
+
6
+ /* --------------------------------------------------------------------------
7
+ * POST /robots/:id/command is the only endpoint that makes a physical machine
8
+ * move, and the vendor API returns no command id - so a command sent wrongly
9
+ * cannot be identified afterwards, let alone cancelled on its own.
10
+ *
11
+ * The rule these guard: fields are REFUSED on the commands that do not use
12
+ * them, never ignored. Silently dropping a spot polygon from an area command
13
+ * would send the robot to clean the wrong thing and report success.
14
+ * ----------------------------------------------------------------------- */
15
+
16
+ const ok = (body) => {
17
+ const r = parseRobotCommand(body);
18
+ assert.equal(r.ok, true, r.ok ? "" : r.message);
19
+ return r.command;
20
+ };
21
+ const bad = (body, re) => {
22
+ const r = parseRobotCommand(body);
23
+ assert.equal(r.ok, false, `expected refusal for ${JSON.stringify(body)}`);
24
+ if (re) assert.match(r.message, re);
25
+ return r.message;
26
+ };
27
+
28
+ const DEFAULTS = { cleanTimes: 1, modeFlag: 1, subModeFlag: 0, levelFlag: 1 };
29
+
30
+ /* ------------------------------------------------------------- the kinds */
31
+
32
+ test("a full clean takes the documented defaults", () => {
33
+ assert.deepEqual(ok({ kind: "full" }), { kind: "full", options: DEFAULTS });
34
+ });
35
+
36
+ test("an area clean carries its area names", () => {
37
+ assert.deepEqual(ok({ kind: "area", areas: ["AREA-FF0B0B83"] }), {
38
+ kind: "area",
39
+ areas: ["AREA-FF0B0B83"],
40
+ options: DEFAULTS,
41
+ });
42
+ });
43
+
44
+ test("a spot clean carries its polygon", () => {
45
+ const points = [
46
+ { x: 504, y: 416 }, { x: 504, y: 480 },
47
+ { x: 555, y: 480 }, { x: 555, y: 416 },
48
+ ];
49
+ assert.deepEqual(ok({ kind: "spot", points }), {
50
+ kind: "spot", points, options: DEFAULTS,
51
+ });
52
+ });
53
+
54
+ test("a return command carries nothing at all", () => {
55
+ assert.deepEqual(ok({ kind: "return" }), { kind: "return" });
56
+ });
57
+
58
+ test("an unknown kind is refused", () => {
59
+ for (const kind of ["mop", "", null, undefined, 1, "FULL"]) {
60
+ bad({ kind }, /kind must be one of/);
61
+ }
62
+ bad(null, /command body is required/);
63
+ bad("full", /command body is required/);
64
+ });
65
+
66
+ /* ------------------------------- fields refused where they do not belong */
67
+
68
+ test("a spot polygon on an area command is REFUSED, not ignored", () => {
69
+ // Ignoring it would clean the wrong thing and report success.
70
+ bad({ kind: "area", areas: ["A"], points: [{ x: 1, y: 1 }] }, /points is only valid/);
71
+ });
72
+
73
+ test("area names on a spot or full command are refused", () => {
74
+ bad({ kind: "spot", points: [{x:1,y:1},{x:2,y:2},{x:3,y:3}], areas: ["A"] }, /areas is only valid/);
75
+ bad({ kind: "full", areas: ["A"] }, /areas is only valid/);
76
+ });
77
+
78
+ test("a return command refuses cleaning options and shapes", () => {
79
+ // Returning to dock has no mode, no level and no area.
80
+ for (const extra of [
81
+ { cleanTimes: 2 }, { modeFlag: 2 }, { subModeFlag: 1 }, { levelFlag: 3 },
82
+ { areas: ["A"] }, { points: [{ x: 1, y: 1 }] },
83
+ ]) {
84
+ bad({ kind: "return", ...extra }, /not valid for a return command/);
85
+ }
86
+ });
87
+
88
+ /* --------------------------------------------------------- option ranges */
89
+
90
+ test("modeFlag, subModeFlag and levelFlag accept only their documented values", () => {
91
+ // 1 vacuum / 2 washing / 3 dry dust mop — confirmed against the three buttons
92
+ // on the robot's handlebar.
93
+ for (const v of [1, 2, 3]) assert.equal(ok({ kind: "full", modeFlag: v }).options.modeFlag, v);
94
+ for (const v of [0, 4, -1, 1.5, "1", null]) bad({ kind: "full", modeFlag: v }, /modeFlag/);
95
+
96
+ for (const v of [0, 1]) assert.equal(ok({ kind: "full", subModeFlag: v }).options.subModeFlag, v);
97
+ for (const v of [2, -1, "0"]) bad({ kind: "full", subModeFlag: v }, /subModeFlag/);
98
+
99
+ for (const v of [1, 2, 3]) assert.equal(ok({ kind: "full", levelFlag: v }).options.levelFlag, v);
100
+ for (const v of [0, 4, "2"]) bad({ kind: "full", levelFlag: v }, /levelFlag/);
101
+ });
102
+
103
+ test("cleanTimes accepts 0 (forever) through 99, and nothing else", () => {
104
+ assert.equal(ok({ kind: "full", cleanTimes: 0 }).options.cleanTimes, 0);
105
+ assert.equal(ok({ kind: "full", cleanTimes: 99 }).options.cleanTimes, 99);
106
+ for (const v of [-1, 100, 1.5, "3", null, NaN]) bad({ kind: "full", cleanTimes: v }, /cleanTimes/);
107
+ });
108
+
109
+ /* ------------------------------------------------------------- the shapes */
110
+
111
+ test("an area command needs at least one real area name", () => {
112
+ bad({ kind: "area" }, /areas must be/);
113
+ bad({ kind: "area", areas: [] }, /areas must be/);
114
+ bad({ kind: "area", areas: [""] }, /areas must be/);
115
+ bad({ kind: "area", areas: [1, 2] }, /areas must be/);
116
+ bad({ kind: "area", areas: "AREA-1" }, /areas must be/);
117
+ });
118
+
119
+ test("a spot command needs at least 3 points — fewer cannot enclose anything", () => {
120
+ bad({ kind: "spot" }, /at least 3/);
121
+ bad({ kind: "spot", points: [{ x: 1, y: 1 }] }, /at least 3/);
122
+ bad({ kind: "spot", points: [{ x: 1, y: 1 }, { x: 2, y: 2 }] }, /at least 3/);
123
+ });
124
+
125
+ test("a spot point must be two finite numbers", () => {
126
+ const three = (p) => [{ x: 1, y: 1 }, { x: 2, y: 2 }, p];
127
+ for (const p of [
128
+ { x: 1 }, { y: 1 }, { x: "1", y: 1 }, { x: 1, y: null },
129
+ { x: 1e999, y: 1 }, null, "1,1", [1, 1],
130
+ ]) {
131
+ bad({ kind: "spot", points: three(p) }, /points must be/);
132
+ }
133
+ });
134
+
135
+ test("a prototype key cannot masquerade as a command field", () => {
136
+ // hasOwnProperty, not a bare `in` — otherwise "constructor" reads as present.
137
+ const cmd = ok({ kind: "full" });
138
+ assert.deepEqual(cmd.options, DEFAULTS);
139
+ assert.equal(parseRobotCommand({ kind: "return", constructor: 1 }).ok, true);
140
+ });