@7365admin1/core 3.133.1 → 3.135.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.133.1",
4
+ "version": "3.135.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,6 +13,7 @@
13
13
  import { after, before, describe, it } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
  import { ObjectId } from "mongodb";
16
+ import { readFileSync } from "node:fs";
16
17
 
17
18
  import { startHarness } from "./harness.mjs";
18
19
 
@@ -25,6 +26,9 @@ const U = {
25
26
  orgAdmin: "msa-org-admin@e2e.example.com",
26
27
  dual: "msa-dual@e2e.example.com",
27
28
  resident: "msa-resident@e2e.example.com",
29
+ tan: "msa-tan@e2e.example.com",
30
+ stray: "msa-stray@e2e.example.com",
31
+ pinnedGuard: "msa-pinned-guard@e2e.example.com",
28
32
  };
29
33
 
30
34
  describe("member site access", { concurrency: 1 }, () => {
@@ -69,7 +73,8 @@ describe("member site access", { concurrency: 1 }, () => {
69
73
  assert.equal(res.body.sites.length, 3);
70
74
  assert.equal(res.body.orgWide.length, 1);
71
75
  const a = res.body.sites.find((s) => s.site === String(id.A));
72
- assert.deepEqual(a.roles.map((r) => r.name).sort(), ["Cleaner at A", "X at A"]);
76
+ assert.deepEqual(a.roles.map((r) => r.name).sort(), ["Cleaner at A", "Security (Org)", "X at A"]);
77
+ assert.deepEqual(a.roles.filter((r) => r.scope === "organisation").map((r) => r.name), ["Security (Org)"], "org console roles are never offered");
73
78
  });
74
79
 
75
80
  it("3. A (role X) + B (role Y), organisation role removed: sees only A and B", async () => {
@@ -146,6 +151,84 @@ describe("member site access", { concurrency: 1 }, () => {
146
151
  assert.ok(refused(await save("orgAdmin", [[id.A, id.X]], { removeOrgAccess: true }, id.users.owner)));
147
152
  assert.equal((await save("owner", [[id.A, id.X]], {}, id.users.resident)).status, 400);
148
153
  });
154
+
155
+ // Owner request 2026-09-30: create a role ONCE at organisation level and give it to an
156
+ // organisation member at the sites picked - reaching those sites only, never org-wide.
157
+ describe("organisation roles used per site", () => {
158
+ const readTan = () => h.api(`/members/site-access?org=${id.org}&user=${id.users.tan}`, { sid: sid.tan });
159
+
160
+ it("15. before: Tan (organisation level) reaches every site", async () => {
161
+ assert.deepEqual((await sitesSeen("tan")).sites, sort(id.A, id.B, id.C));
162
+ });
163
+
164
+ it("16. owner gives Tan the organisation role Security (Org) at A and B only: he reaches A and B, nothing else", async () => {
165
+ const res = await save("owner", [[id.A, id.orgSec], [id.B, id.orgSec]], { removeOrgAccess: true }, id.users.tan);
166
+ assert.equal(res.status, 200, JSON.stringify(res.body));
167
+ const rows = await h.db.collection("members").find({ user: id.users.tan, status: { $ne: "deleted" } }).toArray();
168
+ assert.deepEqual(rows.map((m) => `${m.siteId}:${m.role}:${m.type}`).sort(), [`${id.A}:${id.orgSec}:${SEC}`, `${id.B}:${id.orgSec}:${SEC}`].sort());
169
+ assert.ok(rows.every((m) => String(m.createdBy) === String(id.users.owner)), "rows are stamped by the owner");
170
+ assert.deepEqual((await sitesSeen("tan")).sites, sort(id.A, id.B));
171
+ assert.equal((await invitesAt("tan", id.A)).status, 200, "acts at A");
172
+ assert.ok(refused(await invitesAt("tan", id.C)), "site C refused");
173
+ });
174
+
175
+ it("17. no organisation scope: Tan (role holds *) cannot open site access for anybody, nor list org-wide invitations", async () => {
176
+ assert.ok(refused(await readTan()));
177
+ assert.ok(refused(await view("tan", id.users.dual)));
178
+ assert.ok(refused(await save("tan", [[id.A, id.orgSec]], {}, id.users.dual)));
179
+ const orgInvites = await h.api(`/verifications?status=pending&type=member-invite&orgId=${id.org}`, { sid: sid.tan });
180
+ assert.ok(refused(orgInvites) || (orgInvites.body?.items ?? []).every((v) => String(v.metadata?.siteId ?? "") !== ""), JSON.stringify(orgInvites.body));
181
+ });
182
+
183
+ it("18. the 09-02 rule is untouched: an older pinned row with an organisation role (not placed by anyone) still reaches every site", async () => {
184
+ assert.deepEqual((await sitesSeen("stray")).sites, sort(id.A, id.B, id.C));
185
+ });
186
+
187
+ it("19. choosing the SAME organisation role in Site access re-stamps that row: then it reaches only its site", async () => {
188
+ assert.equal((await save("owner", [[id.A, id.orgSec]], {}, id.users.stray)).status, 200);
189
+ assert.deepEqual((await sitesSeen("stray")).sites, sort(id.A));
190
+ });
191
+
192
+ it("20. an organisation role of another organisation, or the org-console role, is refused for a site", async () => {
193
+ assert.equal((await save("owner", [[id.A, id.foreignOrgSec]], {}, id.users.tan)).status, 400);
194
+ assert.equal((await save("owner", [[id.A, id.orgRole]], {}, id.users.tan)).status, 400);
195
+ });
196
+
197
+ it("21. the owner creates an organisation role in the Organization app (full Security catalogue + inventory staff preset); the org list shows only site-less roles and Site access offers it at every site at once", async () => {
198
+ const catalogue = JSON.parse(readFileSync(new URL("./fixtures/lc-role-catalogues.json", import.meta.url), "utf8"));
199
+ const permissions = catalogue.security_agency ?? catalogue.catalogues?.security_agency;
200
+ assert.ok(Array.isArray(permissions) && permissions.includes("inventory:request-stock"), "fixture has the security catalogue");
201
+ const created = await h.api("/roles", { method: "POST", sid: sid.owner, body: { name: "Guard (Org)", permissions, type: SEC, org: String(id.org) } });
202
+ assert.equal(created.status, 201, JSON.stringify(created.body));
203
+ const staff = await h.api("/roles", { method: "POST", sid: sid.owner, body: { name: "Store staff (Org)", permissions: ["inventory:view-inventory", "inventory:request-stock", "inventory:record-usage"], type: SEC, org: String(id.org) } });
204
+ assert.equal(staff.status, 201, JSON.stringify(staff.body));
205
+
206
+ const list = await h.api(`/roles?org=${id.org}&type=${SEC}&scope=organisation&limit=100`, { sid: sid.owner });
207
+ assert.equal(list.status, 200, JSON.stringify(list.body));
208
+ assert.deepEqual(list.body.items.map((r) => r.name).sort(), ["Guard (Org)", "Security (Org)", "Store staff (Org)"]);
209
+ assert.equal((await h.api(`/roles?org=${id.org}&scope=everything`, { sid: sid.owner })).status, 400);
210
+
211
+ const box = await h.api(`/members/site-access?org=${id.org}&user=${id.users.tan}`, { sid: sid.owner });
212
+ for (const site of box.body.sites) {
213
+ assert.deepEqual(site.roles.filter((r) => r.scope === "organisation").map((r) => r.name).sort(), ["Guard (Org)", "Security (Org)", "Store staff (Org)"], site.name);
214
+ }
215
+ });
216
+
217
+ it("23. Assign Role (site Members) giving an organisation role to a member pinned to A keeps him at A", async () => {
218
+ const row = await h.db.collection("members").findOne({ user: id.users.pinnedGuard });
219
+ const res = await h.api(`/members/id/${row._id}/role/${id.orgSec}/type/${SEC}/org/${id.org}`, { method: "PUT", sid: sid.owner });
220
+ assert.equal(res.status, 200, JSON.stringify(res.body));
221
+ assert.equal(String((await h.db.collection("members").findOne({ _id: row._id })).role), String(id.orgSec));
222
+ assert.deepEqual((await sitesSeen("pinnedGuard")).sites, sort(id.A));
223
+ assert.ok(refused(await invitesAt("pinnedGuard", id.B)));
224
+ });
225
+
226
+ it("22. a site-confined member cannot create or list the organisation's roles for another organisation", async () => {
227
+ const res = await h.api("/roles", { method: "POST", sid: sid.tan, body: { name: "Sneaky", permissions: ["*"], type: SEC, org: String(id.other) } });
228
+ assert.ok(refused(res), JSON.stringify(res.body));
229
+ assert.ok(refused(await h.api(`/roles?org=${id.other}&scope=organisation`, { sid: sid.tan })));
230
+ });
231
+ });
149
232
  });
150
233
 
151
234
  async function seed(h) {
@@ -154,6 +237,7 @@ async function seed(h) {
154
237
  const A = new ObjectId(), B = new ObjectId(), C = new ObjectId(), foreign = new ObjectId();
155
238
  const W = new ObjectId();
156
239
  const X = new ObjectId(), Y = new ObjectId(), Z = new ObjectId(), orgRole = new ObjectId(), ownerRole = new ObjectId(), foreignRole = new ObjectId();
240
+ const orgSec = new ObjectId(), foreignOrgSec = new ObjectId();
157
241
 
158
242
  await h.db.collection("organizations").insertMany([
159
243
  { _id: org, name: "Aigle MSA", email: "aigle-msa@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
@@ -171,6 +255,8 @@ async function seed(h) {
171
255
  { _id: orgRole, name: "Org Admin", type: "organization", org, site: "", permissions: ["*"], status: "active" },
172
256
  { _id: ownerRole, name: "owner", type: "organization", org, site: "", permissions: ["*"], default: true, status: "active" },
173
257
  { _id: foreignRole, name: "Foreign", type: SEC, org: other, site: foreign, permissions: ["*"], status: "active" },
258
+ { _id: orgSec, name: "Security (Org)", type: SEC, org, site: "", permissions: ["*"], status: "active" },
259
+ { _id: foreignOrgSec, name: "Foreign (Org)", type: SEC, org: other, site: "", permissions: ["*"], status: "active" },
174
260
  ]);
175
261
  const users = {};
176
262
  for (const [k, email] of Object.entries(U)) {
@@ -190,6 +276,9 @@ async function seed(h) {
190
276
  { user: users.resident, email: U.resident, org, type: "resident", role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
191
277
  { user: users.kanesan, email: U.kanesan, org, type: "organization", role: orgRole, status: "active", deletedAt: "", createdAt: now },
192
278
  { user: users.siteAdmin, email: U.siteAdmin, org, type: SEC, role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
279
+ { user: users.tan, email: U.tan, org, type: "organization", role: orgRole, status: "active", deletedAt: "", createdAt: now },
280
+ { user: users.pinnedGuard, email: U.pinnedGuard, org, type: SEC, role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
281
+ { user: users.stray, email: U.stray, org, type: SEC, role: orgSec, siteId: A, status: "active", deletedAt: "", createdAt: now },
193
282
  ]);
194
- return { org, other, A, B, C, foreign, X, Y, Z, orgRole, foreignRole, users };
283
+ return { org, other, A, B, C, foreign, X, Y, Z, orgRole, foreignRole, orgSec, foreignOrgSec, users };
195
284
  }
@@ -144,8 +144,9 @@ describe("robot vendor failures are handled answers; diagnose names the step", {
144
144
  mode = "ok";
145
145
  const ok = await h.api(`/robots/${id.robot}/diagnose`, { sid: staffSid });
146
146
  assert.equal(ok.body.failedStep, null, JSON.stringify(ok.body));
147
- // credentials, sign-in, get robot, maps, then the 11 read-only 2025.10 calls (all answered).
148
- assert.equal(ok.body.steps.length, 15, JSON.stringify(ok.body.steps.map((s) => s.step)));
147
+ // credentials, sign-in, get robot, maps, then the 16 read-only 2025.10 calls (all answered;
148
+ // this mock's map has no numeric mapId, so the 4 per-map layer reads are skipped).
149
+ assert.equal(ok.body.steps.length, 20, JSON.stringify(ok.body.steps.map((s) => s.step)));
149
150
  assert.ok(ok.body.steps.every((s) => s.ok), JSON.stringify(ok.body.steps.filter((s) => !s.ok)));
150
151
  assert.match(ok.body.steps[2].detail, /model Q3, battery 88%, floor 1/);
151
152
  assert.ok(ok.body.clock?.vendorDate, "the vendor's clock is reported");
@@ -21,6 +21,14 @@ import {
21
21
  ounitechTime,
22
22
  pngSize,
23
23
  clearOunitechCaches,
24
+ ounitechShapes,
25
+ ounitechTrack,
26
+ ounitechStatusLog,
27
+ ounitechAppAction,
28
+ ounitechSettings,
29
+ ounitechObstacleImage,
30
+ ounitechMapVersion,
31
+ ounitechFloorMapHistory,
24
32
  } from "./.build/services/ounitech.adapter.mjs";
25
33
 
26
34
  /* --------------------------------------------------------------------------
@@ -121,6 +129,28 @@ const ALERTS = [
121
129
  { deviceFlag: 1, errCode: "621 - 右轮驱动器重置( 4)(Resolved)", errMsg: "621 - 右轮驱动器重置( 4)(Resolved)", createTime: "2025-10-20 14:23:45" },
122
130
  ];
123
131
 
132
+ /* The ten read-only map-layer / log / settings replies, in the document's shapes (2025.10.22 §3083-3688, §6989-7204, §1478). */
133
+ const WALLS = [{ wallId: 216395, wallName: "大门口", pointList: [[-1, -5], [1.5, -2.25], [9, 9]] }, { wallId: 216396, wallName: "", pointList: [[0, 0]] }];
134
+ const SAFE = [{ zoneId: 1, zoneName: "test", pointList: [[0.1, 0.1], [0.5, 0.5], [0.9, 0.1]] }];
135
+ const SLOW = [{ zoneId: 6, zoneName: "", pointList: [[-1, -5], [0, -5], [0, -4]] }];
136
+ const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
137
+ const TEACH = { since: "2025-03-04 11:29:10", offset: "2025-03-04 11:29:10", trackList: [{ x: 1, y: 1, theta: 0, mapid: 1 }, { x: 2, y: -1, theta: 0, mapid: 1 }, { x: 5, y: 5, theta: 0, mapid: 99 }] };
138
+ const VERSIONS = [{ versionId: 11206, versionNo: 1, mapId: 1, latestFlag: true, partitionFileKey: "/z.png", mapFileKey: "", backgroundFileKey: "/b.png", creatorFlag: 0, creatorId: 0, createTime: "2025-05-28 19:06:48" }];
139
+ const FLOOR_MAPS = [
140
+ { mapId: 2139, mapName: "欧亚学院 2F", sceneId: 94, backgroundFileKey: "/a.png", totalArea: 6106.217, floorNo: 2, latestFlag: true, enableFlag: true, createTime: "2024-08-20 16:26:45", updateTime: "2025-03-12 19:20:16" },
141
+ { mapId: 2071, mapName: "5楼会议室", sceneId: 94, totalArea: 22.585, floorNo: 2, latestFlag: false, enableFlag: false, createTime: "2024-07-26 14:22:18", updateTime: "2024-07-26 18:37:42" },
142
+ ];
143
+ const ROBOT_LOG = [
144
+ { taskId: 1822883, robotId: 590, robotName: "2A7F25DE96E56", sceneId: 603, sceneName: "2A7F25DE96E56", typeFlag: "9", timeFrom: "2025-10-22 17:48:39", typeName: "充电完成" },
145
+ { taskId: 1822707, robotId: 590, sceneId: 603, typeFlag: "16", timeFrom: "2025-10-22 17:01:06", typeName: "断点充电中" },
146
+ { taskId: 1822460, robotId: 590, sceneId: 603, typeFlag: "2", timeFrom: "2025-10-22 15:55:21", typeName: "清洁中" },
147
+ ];
148
+ const USER_LOG = [
149
+ { robotId: 318, actionId: 181630, actionName: "原地停止", createTime: "2025-10-20 14:22:32", userName: "梁家茂", userPhone: "17875645433" },
150
+ { robotId: 318, actionId: 181629, actionName: "下发清洁", createTime: "2025-10-20 14:22:07", userName: "梁家茂", userPhone: "17875645433" },
151
+ ];
152
+ const SETTING = { fanLevel: 0, soundLevel: 80, enablePlanFlag: true, wifiName: "SiteWifi", wifiPwd: "wifi-secret-123", minCleanBatteryPerc: 50, language: "zh", timeZone: 8, clean_speed_mode: "standard", garbage_threshold: 15 };
153
+
124
154
  let server;
125
155
  let base;
126
156
  const seen = [];
@@ -200,6 +230,26 @@ before(async () => {
200
230
  return ok({ liftlist: [{ liftid: 45, liftno: 3701, liftname: "Lift A", floorno: 13, onlineflag: 1, enableflag: true, simno: "868327073829804" }] });
201
231
  case ROBOT_CLOUD_PATHS.mapList:
202
232
  return ok({ mapList: [{ floorNo: 1 }] });
233
+ case ROBOT_CLOUD_PATHS.wallList:
234
+ return ok({ wallList: body.mapId === 1 ? WALLS : [] });
235
+ case ROBOT_CLOUD_PATHS.safeZoneList:
236
+ return ok({ safeZoneList: body.mapId === 1 ? SAFE : [] });
237
+ case ROBOT_CLOUD_PATHS.slowZoneList:
238
+ return ok({ slowZoneList: body.mapId === 1 ? SLOW : [] });
239
+ case ROBOT_CLOUD_PATHS.obstacleLayer:
240
+ return ok({ version: 123123123123, image: PNG_B64 });
241
+ case ROBOT_CLOUD_PATHS.teachTrack:
242
+ return ok(TEACH);
243
+ case ROBOT_CLOUD_PATHS.mapVersions:
244
+ return ok({ total: 1, versionList: VERSIONS });
245
+ case ROBOT_CLOUD_PATHS.floorMapHistory:
246
+ return ok({ total: 2, mapList: FLOOR_MAPS });
247
+ case ROBOT_CLOUD_PATHS.robotLog:
248
+ return ok({ total: 342, logList: ROBOT_LOG });
249
+ case ROBOT_CLOUD_PATHS.userLog:
250
+ return ok({ actionList: USER_LOG, total: 38 });
251
+ case ROBOT_CLOUD_PATHS.cleaningSetting:
252
+ return ok(SETTING);
203
253
  default:
204
254
  return text(res, 404, "not found");
205
255
  }
@@ -267,11 +317,22 @@ test("status 7 costs one extra read, get-robot-state-v2.0 by robotId, to name th
267
317
  assert.deepEqual(ounitechFault([]), { code: "error", message: "The robot reports an error" });
268
318
  });
269
319
 
270
- test("maps: get-floor-maps by the robot's sceneId, latest only, walls left out", async () => {
320
+ test("maps: get-floor-maps by the robot's sceneId, latest only, raw walls left out (drawn as zones)", async () => {
271
321
  clearOunitechCaches();
272
322
  seen.length = 0;
273
323
  const maps = await adapter().getMaps("F8FCA9A1B1C4C");
274
- assert.deepEqual(seen.map((s) => s.path), [ROBOT_CLOUD_PATHS.homeRobotList, ROBOT_CLOUD_PATHS.floorMaps, ROBOT_CLOUD_PATHS.filePreview]);
324
+ assert.deepEqual(seen.map((s) => s.path), [
325
+ ROBOT_CLOUD_PATHS.homeRobotList,
326
+ ROBOT_CLOUD_PATHS.floorMaps,
327
+ ROBOT_CLOUD_PATHS.filePreview,
328
+ ROBOT_CLOUD_PATHS.wallList,
329
+ ROBOT_CLOUD_PATHS.safeZoneList,
330
+ ROBOT_CLOUD_PATHS.slowZoneList,
331
+ ROBOT_CLOUD_PATHS.teachTrack,
332
+ ]);
333
+ assert.equal(seen[3].body.mapId, 1);
334
+ assert.equal(seen[6].body.robotId, 424);
335
+ assert.match(seen[6].body.offset, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
275
336
  assert.equal(seen[1].body.sceneId, 438);
276
337
  assert.equal(seen[1].body.enableFlag, 1);
277
338
  assert.equal(maps.length, 1);
@@ -564,3 +625,80 @@ test("records: 'no station bound (e=59)' means no base station - no error shown"
564
625
  const r = await adapter().getRecords("OFFLINE1");
565
626
  assert.deepEqual(r.station, { items: [] });
566
627
  });
628
+
629
+ test("map layers: walls, safe and slow zones and the taught route drawn on the robot's map (get-wall/safezone/slowzone-list, get-teach-track)", async () => {
630
+ clearOunitechCaches();
631
+ const [m] = await adapter().getMaps("F8FCA9A1B1C4C");
632
+ // Map: 200 x 100 px, origin (-1, -5), 0.05 m/px. (-1,-5) = bottom-left corner; (1.5,-2.25) = 25 %, 45 %; (9,9) past the edge -> clamped.
633
+ assert.deepEqual(m.zones[0], { kind: "wall", name: "大门口", points: [{ x: 0, y: 100 }, { x: 25, y: 45 }, { x: 100, y: 0 }] });
634
+ assert.equal(m.zones.filter((z) => z.kind === "wall").length, 1, "a one-point wall is not drawn");
635
+ assert.deepEqual(m.zones.find((z) => z.kind === "safe").name, "test");
636
+ assert.deepEqual(m.zones.find((z) => z.kind === "slow"), { kind: "slow", name: "Slow zone 1", points: [{ x: 0, y: 100 }, { x: 10, y: 100 }, { x: 10, y: 80 }] });
637
+ // Taught route: only points on this map (mapid 1); (1,1) -> 20 %, (1+5)/0.05=120 px up of 100 -> clamped top; (2,-1) -> 30 %, 20 %.
638
+ assert.deepEqual(m.taughtRoute, [{ x: 20, y: 0 }, { x: 30, y: 20 }]);
639
+ });
640
+
641
+ test("records: the ten slow reads - status log, app actions, settings (no Wi-Fi), obstacles, map versions, floor map history, zones", async () => {
642
+ clearOunitechCaches();
643
+ seen.length = 0;
644
+ const a = adapter();
645
+ const r = await a.getRecords("F8FCA9A1B1C4C");
646
+ assert.equal(seen.some((x) => COMMAND_PATHS.has(x.path)), false, "never a command");
647
+ assert.deepEqual(r.statusLog.items.map((x) => x.status), ["Charged", "Charging (mid-job)", "Cleaning"]);
648
+ assert.equal(r.statusLog.items[0].at, "2025-10-22T09:48:39.000Z");
649
+ const log = seen.find((x) => x.path === ROBOT_CLOUD_PATHS.robotLog).body;
650
+ assert.deepEqual([log.robotId, log.sceneId, log.pageSize], ["424", "438", 50], "robot log takes ids as strings (document)");
651
+ assert.deepEqual(r.appActions.items, [
652
+ { at: "2025-10-20T06:22:32.000Z", action: "Stopped in place", by: "梁家茂" },
653
+ { at: "2025-10-20T06:22:07.000Z", action: "Started a cleaning job", by: "梁家茂" },
654
+ ]);
655
+ const json = JSON.stringify(r);
656
+ assert.equal(json.includes("17875645433"), false, "app user's phone number left out");
657
+ assert.equal(json.includes("wifi-secret-123") || json.includes("SiteWifi"), false, "Wi-Fi name/password never passed on");
658
+ assert.ok(r.settings.items.some((x) => x.label === "Sound level" && x.value === "80 %"));
659
+ assert.ok(r.settings.items.some((x) => x.label === "Robot language" && x.value === "Chinese"));
660
+ assert.ok(r.settings.items.some((x) => x.label === "Robot time zone" && x.value === "UTC+8"));
661
+ assert.ok(r.settings.items.some((x) => x.group === "Other settings" && x.label === "garbage_threshold"));
662
+ assert.equal(r.obstacles.items[0].image, `data:image/png;base64,${PNG_B64}`);
663
+ assert.deepEqual(r.mapVersions.items, [{ version: 1, latest: true, createdAt: "2025-05-28T11:06:48.000Z" }]);
664
+ assert.equal(seen.find((x) => x.path === ROBOT_CLOUD_PATHS.mapVersions).body.mapId, 1);
665
+ assert.deepEqual(r.mapHistory.items.map((x) => [x.name, x.areaM2, x.latest, x.inUse]), [["欧亚学院 2F", 6106.2, true, true], ["5楼会议室", 22.6, false, false]]);
666
+ assert.deepEqual(seen.find((x) => x.path === ROBOT_CLOUD_PATHS.floorMapHistory).body.floorNo, 3);
667
+ assert.deepEqual(r.mapLayers.items[0].zones.map((z) => z.kind), ["wall", "safe", "slow"]);
668
+ assert.ok(r.tasks && r.alerts, "the fast records are still there");
669
+ const calls = seen.length;
670
+ await a.getRecords("F8FCA9A1B1C4C");
671
+ assert.equal(seen.length, calls, "both caches serve the second read");
672
+ });
673
+
674
+ test("the ten reads, pure mapping from the document's replies", () => {
675
+ assert.deepEqual(ounitechStatusLog({ typeFlag: "15", timeFrom: "2025-10-22 16:58:56", typeName: "断点回充" }), { at: "2025-10-22T08:58:56.000Z", status: "Returning to charge (mid-job)" });
676
+ assert.equal(ounitechStatusLog({ typeFlag: "77", typeName: "x" }).status, "x");
677
+ assert.equal(ounitechAppAction({ actionName: "示教路径控制" }).action, "Controlled a taught route");
678
+ assert.equal(ounitechAppAction({ actionName: "新动作" }).action, "App action: 新动作", "untranslated text is labelled, never bare Chinese");
679
+ assert.deepEqual(ounitechSettings({ wifiName: "a", wifiPwd: "b", ok: true, msg: null, enableWorkSound: true }), [
680
+ { key: "enableWorkSound", group: "Sound", label: "Sounds while working", value: "On", attention: false },
681
+ ]);
682
+ assert.equal(ounitechObstacleImage({ image: "" }), null);
683
+ assert.equal(ounitechObstacleImage({ image: "/9j/abc" }).image, "data:image/jpeg;base64,/9j/abc");
684
+ assert.equal(ounitechObstacleImage({ image: "A".repeat(1_500_001) }).tooLarge, true);
685
+ assert.deepEqual(ounitechMapVersion({ versionNo: 3, latestFlag: false, createTime: "2025-05-28 19:06:48" }), { version: 3, latest: false, createdAt: "2025-05-28T11:06:48.000Z" });
686
+ assert.equal(ounitechFloorMapHistory({ mapId: 5 }).name, "Map 5");
687
+ const frame = { width: 200, height: 100, resolution: 0.05, originX: -1, originY: -5 };
688
+ assert.deepEqual(ounitechShapes(frame, "safe", [{ zoneName: "", pointList: [[0.1, 0.1], [0.5, 0.5]] }])[0].name, "Safe zone 1");
689
+ assert.deepEqual(ounitechShapes(null, "wall", WALLS), [], "no frame, nothing drawn");
690
+ assert.equal(ounitechTrack({ ...frame, mapId: "1" }, Array.from({ length: 2000 }, () => ({ x: 0, y: 0, mapid: 1 }))).length, 500, "long routes thinned to 500 points");
691
+ });
692
+
693
+ test("diagnose: the ten reads each answered, no Wi-Fi password, no phone number", async () => {
694
+ clearOunitechCaches();
695
+ const steps = await adapter().diagnose("F8FCA9A1B1C4C");
696
+ for (const name of ["virtual walls", "safe zones", "slow zones", "map versions", "floor map history", "obstacle layer", "taught route", "robot status log", "app action log", "robot settings"]) {
697
+ const st = steps.find((x) => x.step.startsWith(name));
698
+ assert.ok(st, `step ${name}`);
699
+ assert.equal(st.code, 0, `${name}: ${st.message}`);
700
+ }
701
+ const json = JSON.stringify(steps);
702
+ assert.equal(json.includes("wifi-secret-123") || json.includes("17875645433"), false);
703
+ assert.equal(steps.find((x) => x.step.startsWith("obstacle layer")).data.imageChars, PNG_B64.length, "image size only, not the picture");
704
+ });
@@ -233,6 +233,13 @@ test("diagnose: every step passes on a healthy account, with the vendor's clock"
233
233
  ["power schedule (halt-plan/get)", true],
234
234
  ["base station (get-station-detail-state)", true],
235
235
  ["lifts (get-lift-list)", true],
236
+ // This account's map has no numeric mapId, so the per-map layer reads (walls, zones, versions) are skipped.
237
+ ["floor map history (v2405/get-floor-maplist)", true],
238
+ ["obstacle layer (get-obstacle-layer)", true],
239
+ ["taught route (get-teach-track)", true],
240
+ ["robot status log, last 7 days (get-robot-log-list)", true],
241
+ ["app action log, last 7 days (get-user-logs)", true],
242
+ ["robot settings (get-cleaning-setting)", true],
236
243
  ["current job (robot list taskState)", true],
237
244
  ]);
238
245
  assert.equal(seen.slice(from).some((b) => /cleanTimes|areaList|pointList|actionNo/.test(b)), false, "no command body was sent");