Files
pgames-manager/frontend/tests/translator-provider.test.mjs
T

153 lines
7.4 KiB
JavaScript

import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "test" } });
// Bundled React's act() uses its browser fallback; avoid leaving native MessagePorts open.
globalThis.MessageChannel = class {
port1 = { onmessage: null };
port2 = { postMessage: () => setImmediate(() => this.port1.onmessage?.()) };
};
const storage = new Map();
globalThis.localStorage = {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key),
};
const compiled = await build({ stdin: { contents: `
export {createElement, act} from 'react';
export {createRoot} from 'react-dom/client';
export {MemoryRouter} from 'react-router-dom';
export * from './translator/runner';
export {TranslatorClient} from './translator/client';`,
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { createElement: h, act, createRoot, MemoryRouter, TranslationRunProvider, TranslationProgress,
useTranslationRun, TranslatorClient } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
Error.stackTraceLimit = 2;
const respond = (data, status = 200) => new Response(JSON.stringify(data), { status });
const savedKey = "pg.translation.run";
function fixture({ failSave = false, broken = false, hold = false } = {}) {
storage.clear();
const rows = [{ key: "a", source: "Hello" }, { key: "b", source: "Bye" }];
const saved = new Map(), jobs = new Map(), sizes = [];
let firstSave = failSave;
globalThis.fetch = async (raw, init = {}) => {
const url = new URL(raw, "http://library");
if (url.pathname.endsWith("/entries")) {
const open = rows.filter((row) => !saved.has(row.key));
return respond({ total: open.length, items: open.slice(Number(url.searchParams.get("offset"))) });
}
if (url.pathname === "/api/versions/v/i18n/ru") {
if (firstSave) { firstSave = false; return respond({}, 503); }
const body = JSON.parse(init.body);
for (const [key, value] of Object.entries(body.lines)) saved.set(key, value.t);
return respond({});
}
if (url.pathname.endsWith("/jobs") && init.method === "POST") {
const body = JSON.parse(init.body);
sizes.push(body.batch_size);
const failed = broken && sizes.length === 1;
const running = hold && sizes.length === 1;
const job = { id: String(jobs.size), status: running ? "running" : failed ? "completed_with_errors" : "completed", model: "tomie-translator", error: null,
items: body.items.map((item, i) => ({ id: item.id, source: item.text,
status: running && i > 0 ? "pending" : failed ? "failed" : "completed", translation: failed || (running && i > 0) ? null : `RU ${item.text}`,
error: failed ? "Model output was truncated or filtered; reduce batch size or increase max_tokens" : null })) };
jobs.set(job.id, job);
return respond(job);
}
if (url.pathname.endsWith("/cancel")) return respond({ ...jobs.get(url.pathname.split("/").at(-2)), status: "cancelled" });
if (url.pathname.startsWith("/jobs/")) return respond(jobs.get(url.pathname.split("/").at(-1)));
throw new Error(`Unexpected request ${url.pathname}`);
};
let api, root;
const container = document.createElement("div");
document.body.append(container);
const Probe = () => { api = useTranslationRun(); return h(TranslationProgress); };
const mount = async () => {
root = createRoot(container);
await act(async () => root.render(h(MemoryRouter, null, h(TranslationRunProvider, null, h(Probe)))));
};
const settle = async (check) => {
const deadline = Date.now() + 3000;
while (!check()) {
if (Date.now() > deadline) throw new Error("Translation did not settle");
await act(async () => new Promise((resolve) => setTimeout(resolve, 5)));
}
};
return { saved, sizes, container, mount, settle, get api() { return api; },
unmount: async () => { await act(async () => root.unmount()); },
start: async () => { await act(async () => api.start({ client: new TranslatorClient("http://translator"),
projectId: "p", versionId: "v", lang: "ru", title: "MIST", kind: "lines", module: "", q: "", states: ["untranslated"], batch: 6 })); },
};
}
test("a failed save survives reload and Resume recovers the existing job", async () => {
const f = fixture({ failSave: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.api.task?.status === "failed");
assert.match(f.container.textContent, /Не удалось сохранить перевод: HTTP 503/);
assert.match(f.container.textContent, /Возобновить/);
assert.ok(storage.has(savedKey));
await f.unmount();
await f.mount();
assert.equal(f.api.task.status, "failed");
await act(async () => f.api.resume());
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.saved.size, 2);
assert.equal(f.api.task.progress.done, 2);
assert.deepEqual(f.sizes, [6], "a completed translator job is not submitted again");
assert.equal(storage.has(savedKey), false);
} finally { await f.unmount(); }
});
test("Stop retains completed rows and Resume sends only the remaining work", async () => {
const f = fixture({ hold: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.sizes.length === 1);
await act(async () => f.api.stop());
await f.settle(() => f.api.task?.status === "stopped");
assert.equal(f.saved.size, 1);
assert.equal(f.api.task.progress.done, 1);
assert.ok(storage.has(savedKey));
await act(async () => f.api.resume());
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.saved.size, 2);
assert.equal(f.api.task.progress.done, 2);
assert.equal(f.api.task.progress.total, 2);
assert.equal(storage.has(savedKey), false);
} finally { await f.unmount(); }
});
test("Resume retries failed rows with a smaller batch, including after an old checkpoint", async () => {
const f = fixture({ broken: true });
await f.mount();
try {
await f.start();
await f.settle(() => f.api.task?.status === "done");
assert.equal(f.api.task.progress.failed, 2);
assert.equal(f.api.task.resumable, true);
const snapshot = JSON.parse(storage.get(savedKey));
snapshot.at = Date.now() - 86400000;
storage.set(savedKey, JSON.stringify(snapshot));
await f.unmount();
await f.mount();
assert.equal(f.api.task.status, "stopped");
const button = [...f.container.querySelectorAll("button")].find((button) => button.textContent.includes("Возобновить"));
await act(async () => button.dispatchEvent(new window.Event("click", { bubbles: true })));
await f.settle(() => f.api.task?.status === "done");
assert.deepEqual(f.sizes, [6, 3]);
assert.equal(f.api.task.progress.done, 2);
assert.equal(f.api.task.progress.failed, 0);
assert.equal(f.saved.size, 2);
} finally { await f.unmount(); }
});