@7365admin1/core 3.80.0 → 3.82.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 +67 -0
- package/dist/index.d.ts +953 -4
- package/dist/index.js +5799 -4728
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5309 -4288
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/robot-scope.e2e.test.mjs +57 -33
- package/test/lift-contract.test.mjs +271 -0
- package/test/robot-apps.test.mjs +154 -0
- package/test/robot-cloud.test.mjs +222 -0
- package/test/robot-command.test.mjs +140 -0
- package/test/robot-permission.test.mjs +133 -0
- package/test/robot-tracking.test.mjs +231 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
TRACKING_IDLE_TIMEOUT_MS,
|
|
6
|
+
applyTrackingFrame,
|
|
7
|
+
createSseParser,
|
|
8
|
+
decodeTrackingFrame,
|
|
9
|
+
disconnectedState,
|
|
10
|
+
isAvailableForWork,
|
|
11
|
+
isTrackingStale,
|
|
12
|
+
trackingBackoffMs,
|
|
13
|
+
} from "./.build/utils/robot-tracking.util.mjs";
|
|
14
|
+
|
|
15
|
+
/* --------------------------------------------------------------------------
|
|
16
|
+
* What these guard.
|
|
17
|
+
*
|
|
18
|
+
* The tracking stream is the only thing that says where a robot is and whether
|
|
19
|
+
* it is working. It is also the only part of the integration with no safety net
|
|
20
|
+
* from the vendor: no heartbeat, no sequence number, no command id. So the
|
|
21
|
+
* failure modes worth testing are the quiet ones - an event split across two TCP
|
|
22
|
+
* reads, a connection that died while the robot happened to be idle, and a
|
|
23
|
+
* reconnect storm we cause ourselves.
|
|
24
|
+
* ----------------------------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
const frame = (o) => `data: ${JSON.stringify(o)}\n\n`;
|
|
27
|
+
const POSE = { status: 2, x: 0.203, y: -0.063, theta: 0.041 };
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------ SSE parsing */
|
|
30
|
+
|
|
31
|
+
test("a whole event in one chunk decodes", () => {
|
|
32
|
+
const p = createSseParser();
|
|
33
|
+
const { events } = p.push(frame(POSE));
|
|
34
|
+
assert.equal(events.length, 1);
|
|
35
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an event split across chunks is reassembled, not lost", () => {
|
|
39
|
+
// The defect this exists for: splitting each TCP chunk on its own drops or
|
|
40
|
+
// corrupts whatever event straddles the boundary.
|
|
41
|
+
const whole = frame(POSE);
|
|
42
|
+
const p = createSseParser();
|
|
43
|
+
|
|
44
|
+
for (let cut = 1; cut < whole.length; cut++) {
|
|
45
|
+
const q = createSseParser();
|
|
46
|
+
const first = q.push(whole.slice(0, cut));
|
|
47
|
+
const second = q.push(whole.slice(cut));
|
|
48
|
+
const events = [...first.events, ...second.events];
|
|
49
|
+
|
|
50
|
+
assert.equal(events.length, 1, `split at ${cut}`);
|
|
51
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE, `split at ${cut}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// and the single-chunk case still behaves
|
|
55
|
+
assert.equal(p.push(whole).events.length, 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("several events in one chunk all decode, in order", () => {
|
|
59
|
+
const p = createSseParser();
|
|
60
|
+
const { events } = p.push(
|
|
61
|
+
frame({ ...POSE, x: 1 }) + frame({ ...POSE, x: 2 }) + frame({ ...POSE, x: 3 })
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
events.map((e) => JSON.parse(e.data).x),
|
|
65
|
+
[1, 2, 3]
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a comment line is keep-alive: activity, but not an event", () => {
|
|
70
|
+
const p = createSseParser();
|
|
71
|
+
const { events, sawActivity } = p.push(": keep-alive\n\n");
|
|
72
|
+
assert.equal(events.length, 0);
|
|
73
|
+
assert.equal(sawActivity, true, "the watchdog must count this as alive");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an incomplete event is held back until it completes", () => {
|
|
77
|
+
const p = createSseParser();
|
|
78
|
+
assert.equal(p.push('data: {"status": 1,').events.length, 0);
|
|
79
|
+
assert.equal(p.push('"x": 0, "y": 0,').events.length, 0);
|
|
80
|
+
|
|
81
|
+
const { events } = p.push('"theta": 0}\n\n');
|
|
82
|
+
assert.equal(events.length, 1);
|
|
83
|
+
assert.deepEqual(JSON.parse(events[0].data), {
|
|
84
|
+
status: 1,
|
|
85
|
+
x: 0,
|
|
86
|
+
y: 0,
|
|
87
|
+
theta: 0,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("CRLF framing decodes the same as LF", () => {
|
|
92
|
+
const p = createSseParser();
|
|
93
|
+
const { events } = p.push(`data: ${JSON.stringify(POSE)}\r\n\r\n`);
|
|
94
|
+
assert.equal(events.length, 1);
|
|
95
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("reset drops a half-received event so a reconnect cannot splice it", () => {
|
|
99
|
+
const p = createSseParser();
|
|
100
|
+
p.push('data: {"status": 1, "x": 0');
|
|
101
|
+
p.reset();
|
|
102
|
+
|
|
103
|
+
// Without the reset, this would join the dead socket's fragment and parse.
|
|
104
|
+
const { events } = p.push(frame(POSE));
|
|
105
|
+
assert.equal(events.length, 1);
|
|
106
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/* ---------------------------------------------------------- pose decoding */
|
|
110
|
+
|
|
111
|
+
test("a good frame decodes to a pose with its normalised mode", () => {
|
|
112
|
+
const pose = decodeTrackingFrame(JSON.stringify(POSE));
|
|
113
|
+
assert.deepEqual(pose, {
|
|
114
|
+
x: 0.203,
|
|
115
|
+
y: -0.063,
|
|
116
|
+
theta: 0.041,
|
|
117
|
+
statusFlag: 2,
|
|
118
|
+
mode: "WORKING",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a partial pose is rejected whole, never defaulted to the origin", () => {
|
|
123
|
+
// (0,0) is a real location on these maps, so a missing field must not become
|
|
124
|
+
// a position.
|
|
125
|
+
for (const missing of ["x", "y", "theta", "status"]) {
|
|
126
|
+
const o = { ...POSE };
|
|
127
|
+
delete o[missing];
|
|
128
|
+
assert.equal(decodeTrackingFrame(JSON.stringify(o)), null, `no ${missing}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a malformed frame returns null instead of throwing", () => {
|
|
133
|
+
// One bad frame must not tear down a connection that is otherwise healthy.
|
|
134
|
+
for (const bad of ['{"status":', "", "null", "[]", '"a string"', "{}"]) {
|
|
135
|
+
assert.equal(decodeTrackingFrame(bad), null, JSON.stringify(bad));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("non-numeric and non-finite coordinates are refused", () => {
|
|
140
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":"0","y":0,"theta":0}'), null);
|
|
141
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":null,"y":0,"theta":0}'), null);
|
|
142
|
+
// NaN and Infinity are not JSON literals, so they arrive as these:
|
|
143
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":1e999,"y":0,"theta":0}'), null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------- staleness */
|
|
147
|
+
|
|
148
|
+
test("silence past the idle timeout is stale", () => {
|
|
149
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS - 1), false);
|
|
150
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS), true);
|
|
151
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS + 5_000), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a robot whose stream died while IDLE is NOT offered work", () => {
|
|
155
|
+
// The branch that matters. Holding the last known mode would leave this robot
|
|
156
|
+
// advertised as ready forever, and the dispatcher would keep sending it jobs
|
|
157
|
+
// nothing is listening for.
|
|
158
|
+
const idle = applyTrackingFrame(
|
|
159
|
+
disconnectedState("CASDF03456", 0),
|
|
160
|
+
decodeTrackingFrame(JSON.stringify({ ...POSE, status: 1 })),
|
|
161
|
+
0
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
assert.equal(isAvailableForWork(idle, 0), true, "fresh and idle");
|
|
165
|
+
assert.equal(
|
|
166
|
+
isAvailableForWork(idle, TRACKING_IDLE_TIMEOUT_MS),
|
|
167
|
+
false,
|
|
168
|
+
"same state, stream gone silent"
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a disconnected robot is never available, however recent", () => {
|
|
173
|
+
assert.equal(isAvailableForWork(disconnectedState("R1", 1_000), 1_000), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a connected, fresh, but WORKING robot is not available", () => {
|
|
177
|
+
const working = applyTrackingFrame(
|
|
178
|
+
disconnectedState("R1", 0),
|
|
179
|
+
decodeTrackingFrame(JSON.stringify(POSE)), // status 2 = cleaning
|
|
180
|
+
0
|
|
181
|
+
);
|
|
182
|
+
assert.equal(isAvailableForWork(working, 0), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a pose update does not invent a floor", () => {
|
|
186
|
+
// The stream carries no floorNo. Anything dispatching to a floor must see
|
|
187
|
+
// floorKnown: false rather than a defaulted number.
|
|
188
|
+
const state = applyTrackingFrame(
|
|
189
|
+
disconnectedState("R1", 0),
|
|
190
|
+
decodeTrackingFrame(JSON.stringify(POSE)),
|
|
191
|
+
0
|
|
192
|
+
);
|
|
193
|
+
assert.equal(state.floorNo, null);
|
|
194
|
+
assert.equal(state.floorKnown, false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/* -------------------------------------------------------------- reconnect */
|
|
198
|
+
|
|
199
|
+
test("backoff grows exponentially and then stops at the cap", () => {
|
|
200
|
+
const always1 = () => 1; // full jitter at its maximum = the ceiling itself
|
|
201
|
+
assert.equal(trackingBackoffMs(0, always1), 1_000);
|
|
202
|
+
assert.equal(trackingBackoffMs(1, always1), 2_000);
|
|
203
|
+
assert.equal(trackingBackoffMs(2, always1), 4_000);
|
|
204
|
+
assert.equal(trackingBackoffMs(6, always1), 60_000, "capped");
|
|
205
|
+
assert.equal(trackingBackoffMs(50, always1), 60_000, "still capped");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a huge attempt count cannot produce NaN and a hot retry loop", () => {
|
|
209
|
+
// 2 ** 1e9 is Infinity, and Infinity * random() is NaN; setTimeout(NaN) fires
|
|
210
|
+
// immediately - a retry storm precisely when the cloud is least able to take
|
|
211
|
+
// it. The exponent is capped before the power is taken.
|
|
212
|
+
for (const attempt of [1e6, 1e9, Number.MAX_SAFE_INTEGER]) {
|
|
213
|
+
const delay = trackingBackoffMs(attempt);
|
|
214
|
+
assert.equal(Number.isFinite(delay), true, `attempt ${attempt}`);
|
|
215
|
+
assert.ok(delay >= 0 && delay <= 60_000, `attempt ${attempt} -> ${delay}`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a negative or fractional attempt is treated as the first", () => {
|
|
220
|
+
const always1 = () => 1;
|
|
221
|
+
assert.equal(trackingBackoffMs(-5, always1), 1_000);
|
|
222
|
+
assert.equal(trackingBackoffMs(0.9, always1), 1_000);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("jitter actually spreads reconnects apart", () => {
|
|
226
|
+
// Without this, every robot dropped by one cloud restart retries in the same
|
|
227
|
+
// millisecond, repeatedly - we become the reason it struggles to come back.
|
|
228
|
+
const delays = new Set();
|
|
229
|
+
for (let i = 0; i < 200; i++) delays.add(trackingBackoffMs(5));
|
|
230
|
+
assert.ok(delays.size > 50, `too clustered: ${delays.size} distinct values`);
|
|
231
|
+
});
|