137 lines
6.5 KiB
JavaScript
137 lines
6.5 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { readFile } from "node:fs/promises";
|
||
import { transform } from "esbuild";
|
||
|
||
const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8");
|
||
const { code } = await transform(source, { loader: "ts", format: "esm" });
|
||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
|
||
const { callChannel, randomUuid, stationChannel } = await import(moduleUrl);
|
||
const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8");
|
||
const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" });
|
||
const { applyPatch, applyState, edit, empty } = await import(
|
||
`data:text/javascript;base64,${Buffer.from(mergeCode).toString("base64")}`,
|
||
);
|
||
|
||
class FakeWebSocket {
|
||
static OPEN = 1;
|
||
static instances = [];
|
||
readyState = 0;
|
||
sent = [];
|
||
|
||
constructor(url) {
|
||
this.url = url;
|
||
FakeWebSocket.instances.push(this);
|
||
}
|
||
|
||
send(value) { this.sent.push(JSON.parse(value)); }
|
||
open() { this.readyState = FakeWebSocket.OPEN; this.onopen(); }
|
||
drop() { this.readyState = 3; this.onclose(); }
|
||
receive(event) { this.onmessage({ data: JSON.stringify(event) }); }
|
||
}
|
||
|
||
globalThis.WebSocket = FakeWebSocket;
|
||
globalThis.location = { protocol: "http:", host: "localhost" };
|
||
const reconnectTimers = [];
|
||
globalThis.setTimeout = (callback) => (reconnectTimers.push(callback), reconnectTimers.length);
|
||
globalThis.clearTimeout = () => {};
|
||
|
||
function reconnect(socket) {
|
||
socket.drop();
|
||
reconnectTimers.shift()();
|
||
const next = FakeWebSocket.instances.at(-1);
|
||
next.open();
|
||
return next;
|
||
}
|
||
|
||
const call = callChannel("session-1").connect();
|
||
let socket = FakeWebSocket.instances.at(-1);
|
||
socket.open();
|
||
const patch = {
|
||
type: "kio.patch",
|
||
fields: {
|
||
address: "ул. Тестовая, 1",
|
||
notify: ["101", "СМП"],
|
||
coords: { latitude: 55.7, longitude: 37.6 },
|
||
},
|
||
};
|
||
assert.equal(call.send(patch), true);
|
||
const sentPatch = socket.sent[0];
|
||
assert.equal(sentPatch.type, patch.type);
|
||
assert.deepEqual(sentPatch.fields, patch.fields);
|
||
assert.equal(typeof sentPatch._command_id, "string");
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentPatch], "unacknowledged KIO patch is replayed after reconnect");
|
||
socket.receive({ type: "kio.patch", fields: patch.fields, source: "auto" });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentPatch], "unrelated automatic patch does not acknowledge operator input");
|
||
socket.receive({ type: "kio.patch", fields: { address: "ул. Другая, 2" }, source: "operator" });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentPatch], "different operator state does not acknowledge the pending patch");
|
||
socket.receive({ type: "kio.patch", fields: {
|
||
address: patch.fields.address,
|
||
notify: ["101", "СМП"],
|
||
coords: { longitude: 37.6, latitude: 55.7 },
|
||
}, source: "operator" });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
||
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
|
||
call.send(refused);
|
||
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
|
||
socket.sent = [];
|
||
call.send({ type: "kio.patch", fields: { floor: "2" } });
|
||
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
|
||
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
|
||
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
|
||
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
|
||
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
|
||
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
||
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
||
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
||
|
||
const dds = stationChannel("session-1", "dds").connect();
|
||
socket = FakeWebSocket.instances.at(-1);
|
||
socket.open();
|
||
const crewSelect = { type: "crew.select", crew: "ПСЧ-1" };
|
||
assert.equal(dds.send(crewSelect), true);
|
||
const sentCrewSelect = socket.sent[0];
|
||
assert.equal(sentCrewSelect.type, crewSelect.type);
|
||
assert.equal(sentCrewSelect.crew, crewSelect.crew);
|
||
assert.equal(typeof sentCrewSelect._command_id, "string");
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentCrewSelect], "station command is replayed with its stable ID");
|
||
socket.receive({ type: "command.ack", command_id: sentCrewSelect._command_id });
|
||
socket.sent = [];
|
||
|
||
const reply = { type: "card.reply", card_id: "card-1", text: "Принято" };
|
||
assert.equal(dds.send(reply), true);
|
||
const sentReply = socket.sent[0];
|
||
assert.equal(sentReply.type, reply.type);
|
||
assert.equal(sentReply.card_id, reply.card_id);
|
||
assert.equal(sentReply.text, reply.text);
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentReply], "unacknowledged reply is replayed after reconnect");
|
||
socket.receive({ type: "station.state", snapshot: { card_id: "card-2", reply_text: "Принято" } });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [sentReply], "reply for another card does not acknowledge this card");
|
||
socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_text: "Принято" } });
|
||
socket = reconnect(socket);
|
||
assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply");
|
||
|
||
// Стенд по http://<IP> — не защищённый контекст: randomUUID у браузера нет.
|
||
const nativeRandomUuid = Object.getOwnPropertyDescriptor(globalThis.crypto, "randomUUID");
|
||
Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true });
|
||
try {
|
||
const ids = Array.from({ length: 200 }, () => randomUuid());
|
||
for (const id of ids) {
|
||
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, "UUID v4 without randomUUID");
|
||
}
|
||
assert.equal(new Set(ids).size, ids.length, "UUIDs without randomUUID do not repeat");
|
||
} finally {
|
||
if (nativeRandomUuid) Object.defineProperty(globalThis.crypto, "randomUUID", nativeRandomUuid);
|
||
else delete globalThis.crypto.randomUUID;
|
||
}
|
||
assert.match(randomUuid(), /^[0-9a-f-]{36}$/, "native randomUUID is used when present");
|
||
|
||
console.log("WebSocket durable outbox replay/ack tests passed");
|