lct-hack/frontend/scripts/test-ws-outbox.mjs

137 lines
6.5 KiB
JavaScript
Raw Normal View History

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");