Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
80
frontend/scripts/check-accessible-controls.mjs
Normal file
80
frontend/scripts/check-accessible-controls.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve("src");
|
||||
const files = [];
|
||||
function collect(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) collect(target);
|
||||
else if (/\.tsx?$/.test(entry.name)) files.push(target);
|
||||
}
|
||||
}
|
||||
collect(root);
|
||||
|
||||
const issues = [];
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const labelIds = new Set();
|
||||
function findLabels(node) {
|
||||
if (ts.isJsxOpeningElement(node) && node.tagName.getText(tree) === "label") {
|
||||
const htmlFor = node.attributes.properties.find((item) =>
|
||||
ts.isJsxAttribute(item) && item.name.getText(tree) === "htmlFor");
|
||||
if (htmlFor?.initializer && ts.isStringLiteral(htmlFor.initializer)) {
|
||||
labelIds.add(htmlFor.initializer.text);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, findLabels);
|
||||
}
|
||||
findLabels(tree);
|
||||
|
||||
function inspect(node) {
|
||||
let opening = null;
|
||||
if (ts.isJsxSelfClosingElement(node)) opening = node;
|
||||
else if (ts.isJsxElement(node)) opening = node.openingElement;
|
||||
if (opening) {
|
||||
const tag = opening.tagName.getText(tree);
|
||||
if (["input", "select", "textarea"].includes(tag)) {
|
||||
const attrs = opening.attributes.properties.filter(ts.isJsxAttribute);
|
||||
const hasAccessibleName = attrs.some((attr) => {
|
||||
const name = attr.name.getText(tree);
|
||||
if (!["aria-label", "aria-labelledby", "title"].includes(name)) return false;
|
||||
return Boolean(attr.initializer && (
|
||||
(ts.isStringLiteral(attr.initializer) && attr.initializer.text.trim())
|
||||
|| ts.isJsxExpression(attr.initializer)
|
||||
));
|
||||
});
|
||||
const idAttr = attrs.find((attr) => attr.name.getText(tree) === "id");
|
||||
const id = idAttr?.initializer && ts.isStringLiteral(idAttr.initializer)
|
||||
? idAttr.initializer.text : null;
|
||||
let parent = node.parent;
|
||||
let wrappedByLabel = false;
|
||||
while (parent) {
|
||||
if (ts.isJsxElement(parent) && parent.openingElement.tagName.getText(tree) === "label") {
|
||||
wrappedByLabel = true;
|
||||
break;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
const typeAttr = attrs.find((attr) => attr.name.getText(tree) === "type");
|
||||
const hidden = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer)
|
||||
&& typeAttr.initializer.text === "hidden";
|
||||
if (!hidden && !hasAccessibleName && !wrappedByLabel && !(id && labelIds.has(id))) {
|
||||
const { line } = tree.getLineAndCharacterOfPosition(opening.getStart(tree));
|
||||
issues.push(`${path.relative(root, file)}:${line + 1} <${tag}>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, inspect);
|
||||
}
|
||||
inspect(tree);
|
||||
}
|
||||
|
||||
if (issues.length) {
|
||||
console.error(`Элементы формы без доступного имени (${issues.length}):\n${issues.join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Доступные имена input/select/textarea: проверены.");
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
|||
|
||||
const types = readFileSync(new URL("../src/shared/types/generated.ts", import.meta.url), "utf8");
|
||||
const spec = readFileSync(new URL("../src/features/kio-card/fields.ts", import.meta.url), "utf8");
|
||||
const backendKio = readFileSync(new URL("../../backend/app/domain/kio.py", import.meta.url), "utf8");
|
||||
|
||||
// Поля, которых на форме нет намеренно.
|
||||
const SKIP = new Map([
|
||||
|
|
@ -42,4 +43,19 @@ if (missing.length || extra.length) {
|
|||
if (extra.length) console.error("на форме есть, а в контракте нет:", extra.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const editableBlock = backendKio.match(/EDITABLE_KIO_FIELDS:[\s\S]*?=\s*frozenset\(\{([\s\S]*?)\}\)/)?.[1];
|
||||
if (!editableBlock) throw new Error("в backend/app/domain/kio.py нет EDITABLE_KIO_FIELDS");
|
||||
const backendEditable = new Set([...editableBlock.matchAll(/"([^"]+)"/g)].map((match) => match[1]));
|
||||
const formEditable = new Set([...spec.matchAll(/\{\s*path:\s*"([^"]+)"([^}]*)\}/gs)]
|
||||
.filter((match) => !/readOnly:\s*true/.test(match[2]))
|
||||
.map((match) => match[1]));
|
||||
const unavailableInForm = [...backendEditable].filter((path) => !formEditable.has(path));
|
||||
const notScorable = [...formEditable].filter((path) => !backendEditable.has(path));
|
||||
if (unavailableInForm.length || notScorable.length) {
|
||||
if (unavailableInForm.length) console.error("required_fields, отсутствующие/только для чтения в форме:", unavailableInForm.join(", "));
|
||||
if (notScorable.length) console.error("редактируемые поля формы без backend scoring allowlist:", notScorable.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`карточка КИО: все ${expected.length} полей контракта на форме`);
|
||||
console.log(`required_fields: backend allowlist совпадает с ${formEditable.size} редактируемыми путями формы`);
|
||||
|
|
|
|||
39
frontend/scripts/precompress-assets.mjs
Normal file
39
frontend/scripts/precompress-assets.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { readdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const assetDirectory = new URL("../dist/assets/", import.meta.url);
|
||||
const compressedExtensions = /\.(?:js|css|json|svg)$/i;
|
||||
const minimumBytes = 1024;
|
||||
let compressedCount = 0;
|
||||
let sourceBytes = 0;
|
||||
let gzipBytes = 0;
|
||||
|
||||
async function compressAssets(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory);
|
||||
if (entry.isDirectory()) {
|
||||
await compressAssets(path);
|
||||
continue;
|
||||
}
|
||||
if (!compressedExtensions.test(entry.name)) continue;
|
||||
|
||||
const info = await stat(path);
|
||||
if (info.size < minimumBytes) continue;
|
||||
const source = await readFile(path);
|
||||
const compressed = await gzipAsync(source, { level: 9 });
|
||||
const compressedPath = new URL(`${entry.name}.gz`, directory);
|
||||
await writeFile(compressedPath, compressed);
|
||||
// Nginx gzip_static can safely pair the sidecar with its source file.
|
||||
await utimes(compressedPath, info.atime, info.mtime);
|
||||
compressedCount += 1;
|
||||
sourceBytes += source.byteLength;
|
||||
gzipBytes += compressed.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
await compressAssets(assetDirectory);
|
||||
console.log(
|
||||
`Precompressed ${compressedCount} assets: ${sourceBytes} -> ${gzipBytes} bytes`,
|
||||
);
|
||||
36
frontend/scripts/test-dds-history.mjs
Normal file
36
frontend/scripts/test-dds-history.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterSortHistory } from "../src/pages/dds/history.mjs";
|
||||
|
||||
const cards = [
|
||||
{ card_id: "36814001", scenario_id: "t01-1", title: "Медицинская помощь",
|
||||
received_at: "2026-09-24T14:55:00+03:00", managed_service: "01", recipient_services: ["01"] },
|
||||
{ card_id: "36814002", scenario_id: "t01-2", title: "Пожар в квартире",
|
||||
received_at: "2026-09-25T14:50:00+03:00", managed_service: "01", recipient_services: ["01", "02"] },
|
||||
{ card_id: "36814003", scenario_id: "t01-3", title: "Старая запись без снимка адресатов",
|
||||
ended_at: "2026-09-23T14:50:00+03:00", managed_service: null },
|
||||
];
|
||||
|
||||
const recipients02 = filterSortHistory(cards, {
|
||||
query: "", service: "all", notification: "02", descending: true,
|
||||
});
|
||||
assert.deepEqual(recipients02.map((item) => item.card_id), ["36814002"]);
|
||||
|
||||
const descending = filterSortHistory(cards, {
|
||||
query: "", service: "all", notification: "all", descending: true,
|
||||
});
|
||||
assert.deepEqual(descending.map((item) => item.card_id), ["36814002", "36814001", "36814003"]);
|
||||
|
||||
const ascending = filterSortHistory(cards, {
|
||||
query: "", service: "all", notification: "all", descending: false,
|
||||
});
|
||||
assert.deepEqual(ascending.map((item) => item.card_id), ["36814003", "36814001", "36814002"]);
|
||||
|
||||
const combined = filterSortHistory(cards, {
|
||||
query: "ПОЖАР", service: "01", notification: "02", descending: true,
|
||||
});
|
||||
assert.deepEqual(combined.map((item) => item.card_id), ["36814002"]);
|
||||
assert.deepEqual(cards.map((item) => item.card_id), ["36814001", "36814002", "36814003"],
|
||||
"filter/sort must not mutate the source archive list");
|
||||
|
||||
console.log("DDS archive recipient filters, combined search, sorting, and immutability passed.");
|
||||
110
frontend/scripts/test-ws-outbox.mjs
Normal file
110
frontend/scripts/test-ws-outbox.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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, 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 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");
|
||||
|
||||
console.log("WebSocket durable outbox replay/ack tests passed");
|
||||
Loading…
Reference in a new issue