@7365admin1/core 3.87.0 → 3.88.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.87.0",
4
+ "version": "3.88.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/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-fault.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/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-fault.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/models/robot-building-map.model.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
  },
@@ -880,6 +880,20 @@ function buildApp(nsu, core) {
880
880
  robots.get("/organization/:organization/site/:site", requireAuth, getRobots);
881
881
  robots.patch("/:id([0-9a-fA-F]{24})", requireAuth, updateRobot);
882
882
  robots.delete("/:id([0-9a-fA-F]{24})", requireAuth, deleteRobot);
883
+
884
+ const m = core.useRobotMapController();
885
+ const MAP = "/building-maps/:mapId([0-9a-fA-F]{24})";
886
+ const SESSION = "/lift-sessions/:sessionId([0-9a-fA-F]{24})";
887
+ robots.get("/building-maps", requireAuth, m.listMaps);
888
+ robots.post("/building-maps", requireAuth, m.createMap);
889
+ robots.get(MAP, requireAuth, m.getMap);
890
+ robots.patch(MAP, requireAuth, m.updateMap);
891
+ robots.delete(MAP, requireAuth, m.deleteMap);
892
+ robots.get(`${MAP}/lifts`, requireAuth, m.getLiftQueue);
893
+ robots.post(`${MAP}/lifts/:liftName/sessions`, requireAuth, m.requestLift);
894
+ robots.post(`${SESSION}/refresh`, requireAuth, m.refreshLiftSession);
895
+ robots.post(`${SESSION}/release`, requireAuth, m.releaseLiftSession);
896
+ robots.get("/:id([0-9a-fA-F]{24})/lift-session", requireAuth, m.getRobotLiftSession);
883
897
  }
884
898
 
885
899
  // mirrors iservice365-API-core/src/routes/attendance-settings.route.ts
@@ -0,0 +1,297 @@
1
+ // End-to-end proof for building maps and the lift queue.
2
+ //
3
+ // 1. A map belongs to its robot's estate. The estate is copied from the STORED
4
+ // robot and every later call checks the STORED map - a caller elsewhere gets
5
+ // 404 by id, and cannot send a siteId to aim a map somewhere else.
6
+ // 2. A stale editor cannot overwrite a newer save (version check, 409).
7
+ // 3. The lift queue is the collection: one active holder per lift (enforced by a
8
+ // unique partial index, so racing promotions cannot both win), one open
9
+ // request per robot, timeouts that hand the lift on, release that hands it on.
10
+ // 4. Every write leaves a console-audit row.
11
+ //
12
+ // In-process MongoDB replica set, loopback Redis and mail sink only. No staging
13
+ // or production system, robot or lift is touched.
14
+ //
15
+ // Run with: yarn test:e2e
16
+
17
+ import { after, before, describe, it } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { ObjectId } from "mongodb";
20
+
21
+ import { startHarness } from "./harness.mjs";
22
+
23
+ const PASSWORD = "Robot-Passw0rd!";
24
+ const OUTSIDER = "ml-outsider@e2e.example.com"; // org A, site A only
25
+ const MANAGER = "ml-manager@e2e.example.com"; // org B
26
+
27
+ describe("building maps and the lift queue", { concurrency: 1 }, () => {
28
+ let h;
29
+ const id = {};
30
+ let outsiderSid;
31
+ let managerSid;
32
+ let mapId;
33
+
34
+ before(async () => {
35
+ h = await startHarness();
36
+ Object.assign(id, await seed(h));
37
+ await h.core.useRobotBuildingMapRepo().createIndex();
38
+ await h.core.useRobotLiftSessionRepo().createIndex();
39
+ outsiderSid = await h.login(OUTSIDER, PASSWORD);
40
+ managerSid = await h.login(MANAGER, PASSWORD);
41
+ }, { timeout: 300000 });
42
+
43
+ after(async () => {
44
+ if (h) await h.stop();
45
+ });
46
+
47
+ const body = {
48
+ name: "Tower B",
49
+ levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2, elevationM: 4.2 }, { floorNo: 3 }],
50
+ doors: [{ name: "Lobby", floorNo: 1, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }],
51
+ lifts: [{ name: "Lift A", x: 3, y: 3, floors: [1, 2, 3] }],
52
+ };
53
+
54
+ it("unauthenticated is 401", async () => {
55
+ const res = await h.api(`/robots/building-maps?robot=${id.demo1}`);
56
+ assert.equal(res.status, 401);
57
+ });
58
+
59
+ it("the estate cannot be sent in the body", async () => {
60
+ const res = await h.api("/robots/building-maps", {
61
+ method: "POST",
62
+ sid: managerSid,
63
+ body: { ...body, robotId: String(id.demo1), siteId: String(id.siteA) },
64
+ });
65
+ assert.equal(res.status, 400);
66
+ });
67
+
68
+ it("a caller at another estate cannot create a map on this robot", async () => {
69
+ const res = await h.api("/robots/building-maps", {
70
+ method: "POST",
71
+ sid: outsiderSid,
72
+ body: { ...body, robotId: String(id.demo1) },
73
+ });
74
+ assert.equal(res.status, 404);
75
+ assert.equal(await h.db.collection("robot-building-maps").countDocuments(), 0);
76
+ });
77
+
78
+ it("the estate's own manager saves a map; the estate is the robot's; audited", async () => {
79
+ const res = await h.api("/robots/building-maps", {
80
+ method: "POST",
81
+ sid: managerSid,
82
+ body: { ...body, robotId: String(id.demo1) },
83
+ });
84
+ assert.equal(res.status, 201, JSON.stringify(res.body));
85
+ mapId = res.body.id;
86
+ const stored = await h.db.collection("robot-building-maps").findOne({ _id: new ObjectId(mapId) });
87
+ assert.equal(String(stored.siteId), String(id.siteB));
88
+ assert.equal(String(stored.orgId), String(id.orgB));
89
+ assert.equal(stored.version, 1);
90
+ assert.ok(await findAudit(h, "robot.map-saved"), "audit row written");
91
+ });
92
+
93
+ it("list is paginated and scoped; another estate gets 404 by id", async () => {
94
+ const list = await h.api(`/robots/building-maps?robot=${id.demo1}&page=1&limit=5`, { sid: managerSid });
95
+ assert.equal(list.status, 200);
96
+ assert.equal(list.body.items.length, 1);
97
+ assert.equal(list.body.items[0].liftCount, 1);
98
+
99
+ assert.equal((await h.api(`/robots/building-maps?robot=${id.demo1}`, { sid: outsiderSid })).status, 404);
100
+ assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: outsiderSid })).status, 404);
101
+ const patch = await h.api(`/robots/building-maps/${mapId}`, {
102
+ method: "PATCH",
103
+ sid: outsiderSid,
104
+ body: { ...body, version: 1 },
105
+ });
106
+ assert.equal(patch.status, 404);
107
+ });
108
+
109
+ it("a save against an old version is refused with 409; the current one succeeds", async () => {
110
+ const ok = await h.api(`/robots/building-maps/${mapId}`, {
111
+ method: "PATCH",
112
+ sid: managerSid,
113
+ body: { ...body, name: "Tower B (edited)", version: 1 },
114
+ });
115
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
116
+ assert.equal(ok.body.version, 2);
117
+
118
+ const stale = await h.api(`/robots/building-maps/${mapId}`, {
119
+ method: "PATCH",
120
+ sid: managerSid,
121
+ body: { ...body, version: 1 },
122
+ });
123
+ assert.equal(stale.status, 409);
124
+ });
125
+
126
+ const ask = (robot, over = {}) =>
127
+ h.api(`/robots/building-maps/${mapId}/lifts/${encodeURIComponent("Lift A")}/sessions`, {
128
+ method: "POST",
129
+ sid: managerSid,
130
+ body: { robotId: String(robot), fromFloor: 1, toFloor: 3, ...over },
131
+ });
132
+
133
+ it("a non-demo robot cannot ride the simulated lift", async () => {
134
+ const res = await ask(id.ounitech);
135
+ assert.equal(res.status, 400);
136
+ });
137
+
138
+ it("a floor the lift does not serve is refused", async () => {
139
+ assert.equal((await ask(id.demo1, { toFloor: 7 })).status, 400);
140
+ });
141
+
142
+ it("first robot holds the lift, second queues, a robot cannot ask twice", async () => {
143
+ const first = await ask(id.demo1);
144
+ assert.equal(first.status, 201, JSON.stringify(first.body));
145
+ assert.equal(first.body.session.status, "active");
146
+
147
+ const second = await ask(id.demo2);
148
+ assert.equal(second.status, 201);
149
+ assert.equal(second.body.session.status, "queued");
150
+
151
+ assert.equal((await ask(id.demo1)).status, 409);
152
+
153
+ const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
154
+ assert.equal(queue.status, 200);
155
+ const lift = queue.body.items[0];
156
+ assert.equal(lift.simulated, true);
157
+ assert.equal(lift.active.robotName, "Demo 1");
158
+ assert.equal(lift.queue.length, 1);
159
+ assert.equal(lift.state.sessionId, String(lift.active._id));
160
+
161
+ const mine = await h.api(`/robots/${id.demo2}/lift-session`, { sid: managerSid });
162
+ assert.equal(mine.body.session.position, 1);
163
+
164
+ assert.equal((await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: outsiderSid })).status, 404);
165
+ });
166
+
167
+ it("releasing hands the lift to the next robot; release is audited", async () => {
168
+ const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
169
+ assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: outsiderSid })).status, 404);
170
+
171
+ const rel = await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid });
172
+ assert.equal(rel.status, 200);
173
+ const now = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
174
+ assert.equal(String(now.robotId), String(id.demo2));
175
+ assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid })).status, 409);
176
+ assert.ok(await findAudit(h, "robot.lift-released"));
177
+ assert.ok(await findAudit(h, "robot.lift-requested"));
178
+ });
179
+
180
+ it("a holder that stops refreshing loses the lift when its lease runs out", async () => {
181
+ const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
182
+ assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 200);
183
+
184
+ await ask(id.demo1); // queues behind demo2
185
+ await h.db.collection("robot-lift-sessions").updateOne(
186
+ { _id: holder._id },
187
+ { $set: { expiresAt: new Date(Date.now() - 1000) } },
188
+ );
189
+ const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
190
+ assert.equal(queue.body.items[0].active.robotName, "Demo 1");
191
+ const expired = await h.db.collection("robot-lift-sessions").findOne({ _id: holder._id });
192
+ assert.equal(expired.status, "expired");
193
+ assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 409);
194
+ });
195
+
196
+ it("racing promotions on many instances never produce two holders", async () => {
197
+ const repo = h.core.useRobotLiftSessionRepo();
198
+ const coll = h.db.collection("robot-lift-sessions");
199
+ await coll.updateMany({ open: true }, { $set: { status: "cancelled", open: false } });
200
+ const key = `race:${new ObjectId()}`;
201
+ const t = Date.now();
202
+ await coll.insertMany(
203
+ [0, 1, 2, 3, 4].map((i) => ({
204
+ liftKey: key,
205
+ liftName: "race",
206
+ mapId: new ObjectId(),
207
+ robotId: new ObjectId(),
208
+ status: "queued",
209
+ open: true,
210
+ requestedAt: new Date(t + i),
211
+ expiresAt: new Date(t + 600000),
212
+ })),
213
+ );
214
+ await Promise.all(Array.from({ length: 20 }, () => repo.advance(key)));
215
+ assert.equal(await coll.countDocuments({ liftKey: key, status: "active" }), 1);
216
+ const active = await coll.findOne({ liftKey: key, status: "active" });
217
+ assert.equal(active.requestedAt.getTime(), t, "the oldest request wins");
218
+
219
+ // And the database itself refuses a second holder, whatever the code does.
220
+ await assert.rejects(
221
+ coll.updateOne({ liftKey: key, status: "queued" }, { $set: { status: "active" } }),
222
+ (e) => e.code === 11000,
223
+ );
224
+ });
225
+
226
+ it("removing the map closes its lift queue and the map 404s", async () => {
227
+ await ask(id.demo2);
228
+ const del = await h.api(`/robots/building-maps/${mapId}`, { method: "DELETE", sid: managerSid });
229
+ assert.equal(del.status, 200);
230
+ assert.equal(await h.db.collection("robot-lift-sessions").countDocuments({ mapId: new ObjectId(mapId), open: true }), 0);
231
+ assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: managerSid })).status, 404);
232
+ assert.ok(await findAudit(h, "robot.map-removed"));
233
+ });
234
+ });
235
+
236
+ async function findAudit(h, action) {
237
+ const names = (await h.db.listCollections().toArray()).map((c) => c.name);
238
+ for (const name of names.filter((n) => /audit/i.test(n))) {
239
+ const row = await h.db.collection(name).findOne({ action });
240
+ if (row) return row;
241
+ }
242
+ return null;
243
+ }
244
+
245
+ async function seed(h) {
246
+ const now = new Date().toISOString();
247
+ const orgA = new ObjectId();
248
+ const orgB = new ObjectId();
249
+ const siteA = new ObjectId();
250
+ const siteB = new ObjectId();
251
+ const roleA = new ObjectId();
252
+ const roleB = new ObjectId();
253
+
254
+ await h.db.collection("organizations").insertMany([
255
+ { _id: orgA, name: "ML Org A", email: "mla@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
256
+ { _id: orgB, name: "ML Org B", email: "mlb@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
257
+ ]);
258
+ await h.db.collection("sites").insertMany([
259
+ { _id: siteA, name: "ML Site A", org: orgA, orgId: orgA, status: "active" },
260
+ { _id: siteB, name: "ML Site B", org: orgB, orgId: orgB, status: "active" },
261
+ ]);
262
+ await h.db.collection("roles").insertMany([
263
+ { _id: roleA, name: "Manager A", org: orgA, permissions: ["*"], status: "active" },
264
+ { _id: roleB, name: "Manager B", org: orgB, permissions: ["*"], status: "active" },
265
+ ]);
266
+ const emails = [OUTSIDER, MANAGER];
267
+ const users = await h.db.collection("users").insertMany(
268
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
269
+ );
270
+ const hashed = await h.hashPassword(PASSWORD);
271
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
272
+ const [outsider, manager] = [0, 1].map((i) => users.insertedIds[i]);
273
+ await h.db.collection("members").insertMany([
274
+ { user: outsider, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
275
+ { user: manager, org: orgB, type: "organization", role: roleB, status: "active" },
276
+ ]);
277
+
278
+ const robot = (name, brand) => ({
279
+ name,
280
+ brand,
281
+ type: "cleaning",
282
+ appKey: "e2e-fake-key",
283
+ appToken: "e2e-fake-token",
284
+ createdBy: manager,
285
+ createdByName: "ml-manager",
286
+ metadata: { orgId: orgB, siteId: siteB, administratorId: "" },
287
+ status: "",
288
+ deletedAt: "",
289
+ createdAt: new Date(),
290
+ });
291
+ const r = await h.db.collection("robots").insertMany([
292
+ robot("Demo 1", "demo"),
293
+ robot("Demo 2", "demo"),
294
+ robot("Real one", "ounitech"),
295
+ ]);
296
+ return { orgA, orgB, siteA, siteB, demo1: r.insertedIds[0], demo2: r.insertedIds[1], ounitech: r.insertedIds[2] };
297
+ }
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ LIFT_DOOR,
6
+ LIFT_MODE,
7
+ LIFT_SIM_BOARD_MS,
8
+ LIFT_SIM_DOOR_DWELL_MS,
9
+ LIFT_SIM_MS_PER_FLOOR,
10
+ liftQueueKey,
11
+ simulatedLiftState,
12
+ } from "./.build/utils/lift-contract.util.mjs";
13
+ import {
14
+ buildingMapCreateSchema,
15
+ buildingMapUpdateSchema,
16
+ liftRequestSchema,
17
+ } from "./.build/models/robot-building-map.model.mjs";
18
+
19
+ /* The lift broker's car is REPLAYED from the session, so every API instance
20
+ * must reach the same answer for the same inputs. These pin the phases. */
21
+
22
+ const FLOORS = ["1", "2", "3", "4"];
23
+ const session = (over = {}) => ({ id: "s1", fromFloor: "1", toFloor: "3", activeAt: 0, ...over });
24
+ const at = (ms, s = session()) => simulatedLiftState({ name: "Lift A", floors: FLOORS, session: s, now: ms });
25
+
26
+ test("no holder: the car is idle, in human mode, held by nobody", async () => {
27
+ const { state, phase } = await simulatedLiftState({ name: "Lift A", floors: FLOORS, session: null, now: 5000 });
28
+ assert.equal(phase, "idle");
29
+ assert.equal(state.sessionId, "");
30
+ assert.equal(state.currentMode, LIFT_MODE.HUMAN);
31
+ });
32
+
33
+ test("a session whose floors the lift does not serve is treated as no session", async () => {
34
+ const { phase } = await at(1000, session({ toFloor: "9" }));
35
+ assert.equal(phase, "idle");
36
+ });
37
+
38
+ test("pickup at the car's own floor boards at once, doors open, AGV mode", async () => {
39
+ const { state, phase } = await at(0);
40
+ assert.equal(phase, "boarding");
41
+ assert.equal(state.doorState, LIFT_DOOR.OPEN);
42
+ assert.equal(state.currentMode, LIFT_MODE.AGV);
43
+ assert.equal(state.sessionId, "s1");
44
+ });
45
+
46
+ test("boarding holds the doors longer than the dwell, then the car leaves", async () => {
47
+ const mid = await at(LIFT_SIM_BOARD_MS - 500);
48
+ assert.equal(mid.phase, "boarding");
49
+ assert.equal(mid.state.doorState, LIFT_DOOR.OPEN);
50
+
51
+ const moving = await at(LIFT_SIM_BOARD_MS + 1000);
52
+ assert.equal(moving.phase, "to-destination");
53
+ assert.equal(moving.state.doorState, LIFT_DOOR.CLOSED);
54
+ });
55
+
56
+ test("arrives at the destination and keeps the doors open while held", async () => {
57
+ const travel = 2 * LIFT_SIM_MS_PER_FLOOR;
58
+ const arrived = await at(LIFT_SIM_BOARD_MS + travel + 1000);
59
+ assert.equal(arrived.phase, "arrived");
60
+ assert.equal(arrived.state.currentFloor, "3");
61
+ assert.equal(arrived.state.doorState, LIFT_DOOR.OPEN);
62
+
63
+ // Long after the door dwell: the hold is still refreshed, so no door closes on the robot.
64
+ const later = await at(LIFT_SIM_BOARD_MS + travel + LIFT_SIM_DOOR_DWELL_MS * 5);
65
+ assert.equal(later.state.doorState, LIFT_DOOR.OPEN);
66
+ });
67
+
68
+ test("a pickup above the car travels there first", async () => {
69
+ const s = session({ fromFloor: "3", toFloor: "1" });
70
+ const start = await at(500, s);
71
+ assert.equal(start.phase, "to-pickup");
72
+ assert.equal(start.state.doorState, LIFT_DOOR.CLOSED);
73
+ const there = await at(2 * LIFT_SIM_MS_PER_FLOOR + 500, s);
74
+ assert.equal(there.phase, "boarding");
75
+ assert.equal(there.state.currentFloor, "3");
76
+ });
77
+
78
+ test("replay is deterministic - same inputs, same answer", async () => {
79
+ const a = await at(12_345);
80
+ const b = await at(12_345);
81
+ assert.deepEqual(a, b);
82
+ });
83
+
84
+ test("queue key separates same-named lifts in different buildings", () => {
85
+ assert.notEqual(liftQueueKey("a", "Lift A"), liftQueueKey("b", "Lift A"));
86
+ });
87
+
88
+ /* ---------------------------------------------------------------- schemas */
89
+
90
+ const robotId = "a".repeat(24);
91
+ const good = {
92
+ robotId,
93
+ name: "Tower 1",
94
+ levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2, elevationM: 4.5 }],
95
+ doors: [{ name: "Lobby door", floorNo: 1, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }],
96
+ lifts: [{ name: "Lift A", x: 3, y: 4, floors: [1, 2] }],
97
+ };
98
+
99
+ test("a sensible map validates and gets defaults", () => {
100
+ const { error, value } = buildingMapCreateSchema.validate(good);
101
+ assert.equal(error, undefined);
102
+ assert.equal(value.doors[0].type, "hinged");
103
+ assert.equal(value.lifts[0].widthM, 2);
104
+ });
105
+
106
+ test("two lifts may not share a name - it is the queue's key", () => {
107
+ const { error } = buildingMapCreateSchema.validate({
108
+ ...good,
109
+ lifts: [good.lifts[0], { ...good.lifts[0], x: 9 }],
110
+ });
111
+ assert.ok(error);
112
+ });
113
+
114
+ test("a lift must span at least two floors, and a level may not repeat", () => {
115
+ assert.ok(buildingMapCreateSchema.validate({ ...good, lifts: [{ ...good.lifts[0], floors: [1] }] }).error);
116
+ assert.ok(buildingMapCreateSchema.validate({ ...good, levels: [{ floorNo: 1 }, { floorNo: 1 }] }).error);
117
+ });
118
+
119
+ test("pixel-sized coordinates are refused - the map is in metres", () => {
120
+ const { error } = buildingMapCreateSchema.validate({
121
+ ...good,
122
+ doors: [{ ...good.doors[0], end: { x: 5000, y: 0 } }],
123
+ });
124
+ assert.ok(error);
125
+ });
126
+
127
+ test("the estate cannot be sent - siteId/orgId are not accepted fields", () => {
128
+ assert.ok(buildingMapCreateSchema.validate({ ...good, siteId: robotId }).error);
129
+ assert.ok(buildingMapCreateSchema.validate({ ...good, orgId: robotId }).error);
130
+ });
131
+
132
+ test("a save must say which version it edited", () => {
133
+ const { robotId: _r, ...body } = good;
134
+ assert.ok(buildingMapUpdateSchema.validate(body).error);
135
+ assert.equal(buildingMapUpdateSchema.validate({ ...body, version: 3 }).error, undefined);
136
+ });
137
+
138
+ test("a lift request needs two different floors", () => {
139
+ assert.ok(liftRequestSchema.validate({ robotId, fromFloor: 2, toFloor: 2 }).error);
140
+ assert.equal(liftRequestSchema.validate({ robotId, fromFloor: 1, toFloor: 2 }).error, undefined);
141
+ });