Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View 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: проверены.");
}

View file

@ -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} редактируемыми путями формы`);

View 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`,
);

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

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