@7365admin1/core 3.87.0 → 3.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +352 -3
- package/dist/index.js +6417 -5440
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6504 -5537
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +26 -0
- package/test/e2e/robot-map-lift.e2e.test.mjs +297 -0
- package/test/e2e/robot-network.e2e.test.mjs +269 -0
- package/test/robot-map-lift.test.mjs +141 -0
- package/test/robot-network.test.mjs +93 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
NETWORK_PROJECTION,
|
|
6
|
+
escapeRegex,
|
|
7
|
+
networkListPipeline,
|
|
8
|
+
networkMatch,
|
|
9
|
+
networkSummaryPipeline,
|
|
10
|
+
shapeNetworkSummary,
|
|
11
|
+
} from "./.build/utils/robot-network.util.mjs";
|
|
12
|
+
|
|
13
|
+
/* --------------------------------------------------------------------------
|
|
14
|
+
* The Seven365 console's all-robots list (owner, 2026-09-23). The pipeline is
|
|
15
|
+
* where it can go wrong quietly: a removed robot listed, a credential
|
|
16
|
+
* projected, a filter on the default class that loses every row written before
|
|
17
|
+
* the field existed, or a search string treated as a pattern.
|
|
18
|
+
* ------------------------------------------------------------------------ */
|
|
19
|
+
|
|
20
|
+
const ORG = "64b7f0c2a1b2c3d4e5f60718";
|
|
21
|
+
const SITE = "64b7f0c2a1b2c3d4e5f60719";
|
|
22
|
+
|
|
23
|
+
test("a removed robot never lists: the soft-delete test is always first", () => {
|
|
24
|
+
const [first] = networkListPipeline({});
|
|
25
|
+
assert.deepEqual(first.$match.deletedAt, { $in: ["", null] });
|
|
26
|
+
const [summaryFirst] = networkSummaryPipeline();
|
|
27
|
+
assert.deepEqual(summaryFirst.$match.deletedAt, { $in: ["", null] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the credentials can never leave: the projection is an allow-list without them", () => {
|
|
31
|
+
assert.equal("appKey" in NETWORK_PROJECTION, false);
|
|
32
|
+
assert.equal("appToken" in NETWORK_PROJECTION, false);
|
|
33
|
+
const facet = networkListPipeline({}).at(-1).$facet.items;
|
|
34
|
+
assert.deepEqual(facet.at(-1).$project, NETWORK_PROJECTION);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("client and site filters are ObjectIds on the indexed metadata fields", () => {
|
|
38
|
+
const m = networkMatch({ org: ORG, site: SITE });
|
|
39
|
+
assert.equal(String(m["metadata.orgId"]), ORG);
|
|
40
|
+
assert.equal(String(m["metadata.siteId"]), SITE);
|
|
41
|
+
assert.equal(typeof m["metadata.orgId"], "object");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("filtering on the DEFAULT class/brand still finds rows written before the field existed", () => {
|
|
45
|
+
assert.deepEqual(networkMatch({ type: "cleaning" }).type, { $in: ["cleaning", null, ""] });
|
|
46
|
+
assert.deepEqual(networkMatch({ brand: "ounitech" }).brand, { $in: ["ounitech", null, ""] });
|
|
47
|
+
// A non-default value is matched exactly.
|
|
48
|
+
assert.equal(networkMatch({ type: "security" }).type, "security");
|
|
49
|
+
assert.equal(networkMatch({ brand: "demo" }).brand, "demo");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a search string is data, not a pattern", () => {
|
|
53
|
+
assert.equal(escapeRegex("a.b*(c)"), String.raw`a\.b\*\(c\)`);
|
|
54
|
+
const search = networkListPipeline({ search: "Tower (1)" }).find((s) => s.$match?.$or);
|
|
55
|
+
assert.equal(search.$match.$or[0].name.$regex, String.raw`Tower \(1\)`);
|
|
56
|
+
// and the escaped pattern matches the literal text only
|
|
57
|
+
assert.ok(new RegExp(escapeRegex("a.b*(c)")).test("xa.b*(c)y"));
|
|
58
|
+
assert.equal(new RegExp(escapeRegex("a.b")).test("axb"), false);
|
|
59
|
+
// searches the joined client and site names too
|
|
60
|
+
const fields = search.$match.$or.map((c) => Object.keys(c)[0]);
|
|
61
|
+
assert.deepEqual(fields, ["name", "robotSn", "client.name", "site.name"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("paging is bounded and the default order groups by client then site", () => {
|
|
65
|
+
const p = networkListPipeline({ page: 3, limit: 500 });
|
|
66
|
+
const facet = p.at(-1).$facet.items;
|
|
67
|
+
assert.equal(facet[1].$limit, 100, "limit capped at 100");
|
|
68
|
+
assert.equal(facet[0].$skip, 200);
|
|
69
|
+
const sort = p.find((s) => s.$sort).$sort;
|
|
70
|
+
assert.deepEqual(Object.keys(sort), ["client.name", "site.name", "name", "_id"]);
|
|
71
|
+
const bySite = networkListPipeline({ sort: "site", order: "desc" }).find((s) => s.$sort).$sort;
|
|
72
|
+
assert.deepEqual(bySite, { "site.name": -1, _id: 1 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the summary never claims a fault count it did not measure", () => {
|
|
76
|
+
const s = shapeNetworkSummary({
|
|
77
|
+
total: [{ n: 5 }],
|
|
78
|
+
sites: [{ n: 2 }],
|
|
79
|
+
byType: [{ _id: "cleaning", count: 4 }, { _id: "security", count: 1 }],
|
|
80
|
+
byBrand: [{ _id: "demo", count: 3 }, { _id: "ounitech", count: 2 }],
|
|
81
|
+
byClient: [{ _id: ORG, name: "Client A", count: 5, sites: 2 }],
|
|
82
|
+
});
|
|
83
|
+
assert.equal(s.total, 5);
|
|
84
|
+
assert.equal(s.sites, 2);
|
|
85
|
+
assert.deepEqual(s.byType, [{ key: "cleaning", count: 4 }, { key: "security", count: 1 }]);
|
|
86
|
+
assert.equal(s.demo, 3);
|
|
87
|
+
assert.equal(s.byClient[0].name, "Client A");
|
|
88
|
+
assert.deepEqual(s.faults, { live: false, count: null });
|
|
89
|
+
// empty collection: zeros, not a crash
|
|
90
|
+
const empty = shapeNetworkSummary(undefined);
|
|
91
|
+
assert.equal(empty.total, 0);
|
|
92
|
+
assert.deepEqual(empty.byType.map((t) => t.count), [0, 0]);
|
|
93
|
+
});
|