@7365admin1/core 3.137.0 → 3.139.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.137.0",
4
+ "version": "3.139.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/services/ounitech.adapter.ts src/services/robot-cloud.service.ts src/services/robot-fleet.service.ts src/services/padbot.adapter.ts src/utils/padbot-sign.util.ts src/utils/padbot-mqtt.util.ts src/services/open-rmf.adapter.ts src/utils/robot-credentials.util.ts src/utils/rmf-building.util.ts src/utils/door-contract.util.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/robot-network.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/site-owner-edit.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/notification-kind.util.ts src/utils/notification-email.util.ts src/models/notification.model.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/site.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 src/utils/customer-site-list.util.ts src/utils/org-search.util.ts src/utils/invite-origin.util.ts src/utils/member-role-assign.util.ts src/utils/cross-site-role.util.ts src/utils/cross-site-role-fix.util.ts src/utils/visitor-search.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/services/ounitech.adapter.ts src/utils/ounitech-codes.util.ts src/services/robot-cloud.service.ts src/services/robot-fleet.service.ts src/services/padbot.adapter.ts src/utils/padbot-sign.util.ts src/utils/padbot-mqtt.util.ts src/services/open-rmf.adapter.ts src/utils/robot-credentials.util.ts src/utils/rmf-building.util.ts src/utils/door-contract.util.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/robot-network.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/site-owner-edit.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/notification-kind.util.ts src/utils/notification-email.util.ts src/models/notification.model.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/site.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 src/utils/customer-site-list.util.ts src/utils/org-search.util.ts src/utils/invite-origin.util.ts src/utils/member-role-assign.util.ts src/utils/cross-site-role.util.ts src/utils/cross-site-role-fix.util.ts src/utils/visitor-search.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 && tsup src/repositories/visitor-transaction.repo.ts --format esm --out-dir test/.build-e2e/repositories --no-dts --silent && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
19
19
  },
@@ -354,12 +354,15 @@ describe("SP invitation: email status, Super Admin alert, new company typed by s
354
354
 
355
355
  const repaired = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
356
356
  assert.equal(repaired.status, 200, JSON.stringify(repaired.body));
357
- assert.equal(repaired.body.changed, 2);
357
+ // 2 memberships + the unused "Admin Service Provider" role left in JLL's role list
358
+ assert.equal(repaired.body.changed, 3);
358
359
  assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 0);
360
+ assert.equal((await h.db.collection("roles").findOne({ _id: spRole })).status, "deleted");
359
361
 
360
362
  const undone = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/undo`, { method: "PUT", sid: adminSid });
361
363
  assert.equal(undone.status, 200, JSON.stringify(undone.body));
362
364
  assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 2);
365
+ assert.equal((await h.db.collection("roles").findOne({ _id: spRole })).status, "active", "undo restores the role");
363
366
 
364
367
  await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
365
368
  const again = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
@@ -523,6 +526,107 @@ describe("SP invitation: email status, Super Admin alert, new company typed by s
523
526
  assert.ok(await h.db.collection("customer.sites").findOne({ org: org._id, site, status: "active" }));
524
527
  assert.equal((await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) })).status, "complete");
525
528
  });
529
+
530
+ // ------------------- E. "two organisations" after Finish setup (2026-09-30)
531
+
532
+ // latitude.sec@ on production after Repair + Finish setup: two INACTIVE
533
+ // JLL memberships (older) + the unused "*" role the old sign-up left in JLL,
534
+ // and its own Henderson Security company with org-level owner rows (no site)
535
+ // engaged at Latitude through customer.sites.
536
+ async function latitudeShape(email) {
537
+ const site = new ObjectId();
538
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
539
+ const inviteId = await invite(email, "security_agency", site);
540
+ await approve(inviteId);
541
+ const sid = await newAccount(email);
542
+ const user = await h.db.collection("users").findOne({ email });
543
+ const spRole = new ObjectId();
544
+ await h.db.collection("roles").insertOne({ _id: spRole, name: "Admin Service Provider", type: "security_agency", org: id.pmOrg.toString(), site: site.toString(), permissions: ["*"], status: "active", createdAt: new Date(Date.now() + 500).toISOString() });
545
+ const later = new Date(Date.now() + 1000);
546
+ await h.db.collection("members").insertMany([
547
+ { user: user._id, org: id.pmOrg, role: spRole, type: "organization", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
548
+ { user: user._id, org: id.pmOrg, role: spRole, type: "security_agency", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
549
+ ]);
550
+ await h.db.collection("users").updateOne({ _id: user._id }, { $set: { defaultOrg: id.pmOrg.toString() } });
551
+ // the 30 Sep Repair as it ran then: memberships only (the role stayed)
552
+ await h.db.collection("members").updateMany({ user: user._id, org: id.pmOrg }, { $set: { status: "inactive" } });
553
+ await h.db.collection("users").updateOne({ _id: user._id }, { $unset: { defaultOrg: "" } });
554
+ const applied = await h.api(`/service-provider-invitations/${inviteId}/finish-setup`, { method: "PUT", sid: adminSid, body: { companyName: "Henderson Security Services Pte Ltd" } });
555
+ assert.equal(applied.status, 200, JSON.stringify(applied.body));
556
+ return { site, inviteId, sid, user, spRole, orgId: applied.body.orgId };
557
+ }
558
+
559
+ it("E1. every app finds the account's OWN company: membership lookup, organisation switcher, sites", async () => {
560
+ const { site, sid, user, orgId } = await latitudeShape("latitude-shape@e2e.example.com");
561
+ for (const app of ["organization", "security_agency"]) {
562
+ const m = await h.api(`/members/user/${user._id}/app/${app}`, { sid });
563
+ assert.equal(m.status, 200, JSON.stringify(m.body));
564
+ assert.equal(String(m.body.org), orgId, `${app}: Henderson, not the inactive JLL row`);
565
+ assert.equal(m.body.status, "active");
566
+ assert.ok(!m.body.siteId, "org-level owner row: no site of its own");
567
+ }
568
+ const orgs = await h.api(`/organizations/user/${user._id}?type=organization&limit=50`, { sid });
569
+ assert.equal(orgs.status, 200, JSON.stringify(orgs.body));
570
+ assert.deepEqual(orgs.body.items.map((o) => String(o.value)), [orgId], "one organisation, not JLL beside it");
571
+ // the security app's (and the mobile apps') site list: the engaged site
572
+ const sites = await h.api(`/customer-sites?org=${orgId}&status=active&limit=50`, { sid });
573
+ assert.equal(sites.status, 200, JSON.stringify(sites.body));
574
+ assert.deepEqual(sites.body.items.map((x) => String(x.site)), [site.toString()]);
575
+ });
576
+
577
+ it("E2. Repair on an already-repaired account retires the leftover full-access role only while nobody uses it; Undo restores it", async () => {
578
+ const { inviteId, spRole, user } = await latitudeShape("latitude-role@e2e.example.com");
579
+ const preview = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
580
+ assert.equal(preview.status, 200, JSON.stringify(preview.body));
581
+ assert.equal(preview.body.memberships.length, 0);
582
+ assert.deepEqual(preview.body.leftoverRoles.map((r) => r.name), ["Admin Service Provider"]);
583
+
584
+ // someone at JLL was since given that role -> it is JLL's now, never touched
585
+ const other = await h.db.collection("members").insertOne({ user: new ObjectId(), org: id.pmOrg, role: spRole, type: "security_agency", status: "active", createdAt: new Date() });
586
+ const kept = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
587
+ assert.equal(kept.body.changed, 0, JSON.stringify(kept.body));
588
+ assert.equal((await h.db.collection("roles").findOne({ _id: spRole })).status, "active");
589
+ await h.db.collection("members").deleteOne({ _id: other.insertedId });
590
+
591
+ const repaired = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
592
+ assert.equal(repaired.status, 200, JSON.stringify(repaired.body));
593
+ assert.equal(repaired.body.changed, 1);
594
+ assert.equal((await h.db.collection("roles").findOne({ _id: spRole })).status, "deleted");
595
+ // Henderson is untouched
596
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 2);
597
+
598
+ const undone = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/undo`, { method: "PUT", sid: adminSid });
599
+ assert.equal(undone.status, 200, JSON.stringify(undone.body));
600
+ assert.equal((await h.db.collection("roles").findOne({ _id: spRole })).status, "active");
601
+ // Undo of a role-only repair re-activates no membership
602
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id, org: id.pmOrg, status: "active" }), 0);
603
+ });
604
+
605
+ it("E3. accept-new-company is idempotent per invitation: a retry after a half-finished attempt reuses the company; a second click is refused; one company", async () => {
606
+ const site = new ObjectId();
607
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
608
+ const email = "double-click@e2e.example.com";
609
+ const inviteId = await invite(email, "security_agency", site);
610
+ await approve(inviteId);
611
+ const sid = await newAccount(email);
612
+ const user = await h.db.collection("users").findOne({ email });
613
+ // the first attempt died after creating the organisation (no roles, no members, invite still pending)
614
+ const half = new ObjectId();
615
+ await h.db.collection("organizations").insertOne({ _id: half, name: "Henderson Security Services Pte Ltd", type: "security_agency", nature: "security_agency", email, createdBy: user._id, invitedFrom: inviteId, status: "active", createdAt: new Date().toISOString() });
616
+
617
+ const retry = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Henderson Security Services Pte Ltd" } });
618
+ assert.equal(retry.status, 200, JSON.stringify(retry.body));
619
+ assert.equal(retry.body.orgId, half.toString(), "the half-made company, not a second one");
620
+ assert.equal(await h.db.collection("organizations").countDocuments({ invitedFrom: inviteId }), 1);
621
+ const owners = await h.db.collection("members").find({ user: user._id, org: half, status: "active" }).toArray();
622
+ assert.deepEqual(owners.map((m) => m.type).sort(), ["organization", "security_agency"]);
623
+ assert.ok(await h.db.collection("customer.sites").findOne({ org: half, site, status: "active" }));
624
+
625
+ const again = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Henderson Security Services Pte Ltd" } });
626
+ assert.equal(again.status, 400, JSON.stringify(again.body));
627
+ assert.equal(await h.db.collection("organizations").countDocuments({ createdBy: { $in: [user._id, user._id.toString()] } }), 1);
628
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id, org: half }), 2, "no duplicate owner rows");
629
+ });
526
630
  });
527
631
 
528
632
  async function seed(h) {
@@ -0,0 +1,28 @@
1
+ // latitude.sec@ (2026-09-30): an INACTIVE membership in another organisation
2
+ // (the sender, after Repair) must never be the app's membership over the
3
+ // person's live one. See preferWorkingMembership in member-lookup.util.ts.
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { preferWorkingMembership, pickLookupMembership } from "./.build/utils/member-lookup.util.mjs";
7
+
8
+ const jll = { org: "jll", type: "organization", status: "inactive", siteId: "latitude" };
9
+ const hen = { org: "hen", type: "organization", status: "active", siteId: "" };
10
+ const other = { org: "oth", type: "organization", status: "active" };
11
+
12
+ test("a working row beats an older inactive one", () => {
13
+ assert.equal(preferWorkingMembership([jll, hen]), hen);
14
+ });
15
+ test("the default organisation wins among working rows", () => {
16
+ assert.equal(preferWorkingMembership([jll, other, hen], "HEN"), hen);
17
+ assert.equal(preferWorkingMembership([jll, other, hen]), other);
18
+ });
19
+ test("no working row: the old answer (never not-found), deleted last", () => {
20
+ const sus = { org: "a", status: "suspended" };
21
+ const del = { org: "b", status: "deleted" };
22
+ assert.equal(preferWorkingMembership([del, sus]), sus);
23
+ assert.equal(preferWorkingMembership([del]), del);
24
+ assert.equal(preferWorkingMembership([]), null);
25
+ });
26
+ test("the lookup used by the module gate follows the same rule", () => {
27
+ assert.equal(pickLookupMembership([jll, hen], "organization"), hen);
28
+ });
@@ -6,10 +6,14 @@ import { after, before, test } from "node:test";
6
6
 
7
7
  import { ROBOT_CLOUD_PATHS, OUNITECH_TIMESTAMP_SKEW_S } from "./.build/utils/robot-cloud.util.mjs";
8
8
  import { RobotCloudError, useRobotCloudService } from "./.build/services/robot-cloud.service.mjs";
9
+ import { OUNITECH_CODES, ounitechCodeWords } from "./.build/utils/ounitech-codes.util.mjs";
10
+ import { registerRobotBrand, robotManifest } from "./.build/utils/robot-adapter.util.mjs";
11
+ import { robotFaultText } from "./.build/utils/robot-fault.util.mjs";
9
12
  import {
10
13
  createOunitechAdapter,
11
14
  ounitechRobotFromHomeRow,
12
15
  ounitechFault,
16
+ OUNITECH_BRAND_DEFINITION,
13
17
  ounitechJobWords,
14
18
  OUNITECH_FRESH_MS,
15
19
  ounitechMapShare,
@@ -313,7 +317,7 @@ test("status 7 costs one extra read, get-robot-state-v2.0 by robotId, to name th
313
317
  const r = await adapter().getRobot("ERROR1");
314
318
  assert.deepEqual(seen.map((s) => s.path), [ROBOT_CLOUD_PATHS.homeRobotList, ROBOT_CLOUD_PATHS.robotState]);
315
319
  assert.equal(seen[1].body.robotId, 427);
316
- assert.deepEqual(r.fault, { code: "621", message: "Right wheel driver reset (fault 621)" });
320
+ assert.deepEqual(r.fault, { code: "621", message: "621 · Right wheel driver reset. Normal when the emergency stop is pressed or released - no action needed." });
317
321
  assert.deepEqual(ounitechFault([]), { code: "error", message: "The robot reports an error" });
318
322
  });
319
323
 
@@ -448,7 +452,7 @@ test("live state mapping, recorded reply (pure)", () => {
448
452
  assert.equal(off.statusName, "Offline");
449
453
  // a fault listed live is named even without status 7
450
454
  const faulty = ounitechRobotFromHomeRow({ robotSn: "E" }, { statusFlag: 1, rtFaultList: [{ errCode: "302", errMsg: "Clean water tank empty" }] }, now);
451
- assert.deepEqual(faulty.fault, { code: "302", message: "Clean water tank empty (fault 302)" });
455
+ assert.deepEqual(faulty.fault, { code: "302", message: "302 · Robot can't load its map. Call Ounitech support." }, "a listed code: the maker's table wins");
452
456
  });
453
457
 
454
458
  test("cleaning readings: water levels, tanks and covers, job progress only while cleaning (pure)", async () => {
@@ -557,8 +561,8 @@ test("records: jobs, alert history, consumables, power schedule, lifts, readings
557
561
  );
558
562
  assert.deepEqual(r.alerts.items.map((x) => [x.code, x.level, x.message, x.resolved]), [
559
563
  ["734", "Notice", "IMU zero offset too large", false],
560
- ["540", "Notice", "Floor check: cliff sensor switched on", false],
561
- ["621", null, "Right wheel driver reset", true],
564
+ ["540", "Notice", "Cliff sensor switched on", false],
565
+ ["621", null, "Right wheel driver reset. Normal when the emergency stop is pressed or released - no action needed.", true],
562
566
  ]);
563
567
  assert.deepEqual(r.consumables.items, [{ part: "Left side brush", hours: 705.5, days: 30 }]);
564
568
  assert.deepEqual(r.power.items, [{ days: [1, 2, 3, 4, 5], time: "20:00", action: "off", enabled: true }]);
@@ -580,11 +584,11 @@ test("records: a robot not on the account says so", async () => {
580
584
 
581
585
  test("alert, reading, task and schedule mapping (pure, recorded shapes)", () => {
582
586
  const a = ounitechAlertRecord({ errMsg: "785 - (提示)定位准备完成( 5)", createTime: "2026-09-30 07:02:57" });
583
- assert.deepEqual(a, { code: "785", level: "Notice", message: "Localisation ready", vendorText: "定位准备完成", resolved: false, at: "2026-09-29T23:02:57.000Z" });
587
+ assert.deepEqual(a, { code: "785", level: "Notice", message: "Position ready", vendorText: "定位准备完成", resolved: false, at: "2026-09-29T23:02:57.000Z" });
584
588
  assert.equal(ounitechAlertRecord({ errMsg: "999 - (警告)未知( 3)" }).message, "Robot alert 999: 未知", "unknown code: code first, the vendor's words kept");
585
589
  // Robot 424's other prod alerts (2026-09-30) read in English.
586
590
  assert.equal(ounitechAlertRecord({ errMsg: "589 - (提示)定时关机( 5)" }).message, "Switched off by the timed power schedule");
587
- assert.equal(ounitechAlertRecord({ errMsg: "205 - 边刷驱动器离线( 4)" }).message, "Side brush driver offline");
591
+ assert.equal(ounitechAlertRecord({ errMsg: "205 - 边刷驱动器离线( 4)" }).message, "Side-brush driver offline (side brush won't turn). Check the side-brush motor wire.");
588
592
  assert.equal(ounitechAlertRecord({ errMsg: "900 - plain words" }).message, "plain words");
589
593
  assert.equal(ounitechConsumable({ unitcode: "scraper", workminutes: 60 }).part, "Scraper");
590
594
  assert.equal(ounitechAlertRecord({ errMsg: "999 - (警告)未知( 3)" }).level, "Warning");
@@ -711,11 +715,50 @@ test("diagnose: the ten reads each answered, no Wi-Fi password, no phone number"
711
715
 
712
716
  // Live, robot 424, 30 Sep: rtFaultList came as plain strings, and area "default" (centre 0,0) faulted 306.
713
717
  test("a string fault from rtFaultList: code kept, English words", () => {
714
- assert.deepEqual(ounitechFault(["306 - 接收定点指定区域故障( 1)"]), { code: "306", message: "Robot couldn't receive the chosen cleaning area (fault 306)" });
715
- assert.deepEqual(ounitechFault(["999 - 未知( 1)"]), { code: "999", message: "Robot alert 999: 未知" });
718
+ assert.deepEqual(ounitechFault(["306 - 接收定点指定区域故障( 1)"]), {
719
+ code: "306",
720
+ message: "306 · Robot couldn't receive the chosen area. Clear the fault (press and release the emergency stop), then send the task again.",
721
+ });
722
+ assert.deepEqual(ounitechFault(["999 - 未知( 1)"]), { code: "999", message: "999 · 未知" }, "unknown code: vendor code + its own text");
723
+ assert.deepEqual(ounitechFault(["734 - (提示)IMU零点偏移过大( 5)"]), { code: "734", message: "734 · IMU zero offset too large" }, "not in the maker's list: translated vendor text");
724
+ assert.deepEqual(ounitechFault([{ errCode: 563, errMsg: "机器人已故障" }]).message.startsWith("563 · Robot is in a fault state. Find the cause"), true);
716
725
  assert.deepEqual(ounitechFault(["plain words"]), { code: "error", message: "plain words" });
717
726
  });
718
727
 
728
+ test("maker's code table: English + fix for every listed code, ranges filled, fix only where the manual gives one", () => {
729
+ const n = Object.keys(OUNITECH_CODES).length;
730
+ assert.ok(n >= 220, `${n} codes`);
731
+ for (const [code, [what, fix]] of Object.entries(OUNITECH_CODES)) {
732
+ assert.match(code, /^\d{3}$/);
733
+ assert.ok(what && !/[㐀-鿿]/.test(what) && !/[㐀-鿿]/.test(fix ?? ""), `${code} English only`);
734
+ assert.ok(!what.endsWith(".") && !(fix ?? "").endsWith("."), `${code} no trailing period (added when joined)`);
735
+ }
736
+ assert.equal(OUNITECH_CODES["734"], undefined, "734 is not in the maker's list");
737
+ assert.equal(ounitechCodeWords("306"), "Robot couldn't receive the chosen area. Clear the fault (press and release the emergency stop), then send the task again.");
738
+ assert.equal(ounitechCodeWords("588"), "Switched off with the power button", "informational: no fix");
739
+ assert.equal(ounitechCodeWords("750"), "Sensor or camera calibration. Recalibrate if it failed.", "range filled");
740
+ assert.equal(ounitechCodeWords("999"), null);
741
+ });
742
+
743
+ test("the fault notice reads the English words + fix once, no doubled period", () => {
744
+ const fault = ounitechFault(["306 - 接收定点指定区域故障( 1)"]);
745
+ assert.equal(
746
+ robotFaultText("HERO-OTH-CLEANING", fault).body,
747
+ "HERO-OTH-CLEANING reports a fault: 306 · Robot couldn't receive the chosen area. Clear the fault (press and release the emergency stop), then send the task again.",
748
+ );
749
+ });
750
+
751
+ test("Ounitech manifest carries the manual's fault-clearing steps; other brands none", () => {
752
+ registerRobotBrand(OUNITECH_BRAND_DEFINITION); // each test bundle carries its own registry
753
+ const steps = robotManifest("ounitech", "Q3").profile.clearFault;
754
+ assert.equal(steps.length, 4);
755
+ assert.match(steps[0], /emergency stop.*clockwise/);
756
+ assert.match(steps[1], /charging dock/);
757
+ assert.match(steps[2], /power switch at the back/);
758
+ assert.match(steps[3], /Child lock 3 times/);
759
+ assert.deepEqual(robotManifest("nope").profile.clearFault, []);
760
+ });
761
+
719
762
  test("Clean area refuses an area with no centre point before anything reaches the robot", async () => {
720
763
  clearOunitechCaches();
721
764
  seen.length = 0;