fix: resume interrupted translations without losing saved progress

This commit is contained in:
mrleo1nid committed 2026-10-27 04:41:17 +03:00
1 parent a8902deaf7
commit e14184435c
6 files changed
+321 -44

No files matched your search

@@ -175,6 +175,9 @@ export function MachineTranslate({ session, versionId, lang, title, view, follow
</p>
<div className="actions">
{mine && task?.resumable && !run.busy && (
<button className="btn" onClick={() => void run.resume()} disabled={preparing}><Icon name="play" size={18} />Возобновить</button>
)}
{running ? (
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
) : (
+20 -8
View File
@@ -47,7 +47,7 @@ export interface RunOptions {
/** where an interrupted run stood: the counters and how many lines to skip */
initial?: { total: number; done: number; failed: number; skip: number };
/** the job that was being translated when the run was interrupted, and the lines it carries */
resume?: { jobId: string; keys: string[] };
resume?: { jobId: string; keys: string[]; savedKeys?: string[] };
/** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */
onCheckpoint?: (cp: Checkpoint) => void;
}
@@ -59,6 +59,8 @@ export interface Checkpoint {
total: number;
done: number;
failed: number;
/** Results already committed from this job; do not overwrite them on recovery. */
savedKeys?: string[];
}
export interface Progress {
@@ -97,11 +99,13 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
try {
job = await opts.client.job(job.id, opts.signal);
errors = 0;
await onPoll?.(job);
} catch (e) {
if (opts.signal.aborted) return stop();
if (++errors >= 4) throw e;
continue;
}
// A library save failure must keep the checkpoint intact, not consume a polling retry.
await onPoll?.(job);
}
return job;
}
@@ -113,20 +117,25 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const emit = () => opts.onProgress({ ...progress });
let skip = start?.skip ?? 0;
let resume = opts.resume;
const checkpoint = (jobId: string | null, keys: string[]) =>
opts.onCheckpoint?.({ jobId, keys, skip, total: progress.total, done: progress.done, failed: progress.failed });
const checkpoint = (jobId: string | null, keys: string[], savedKeys: string[] = []) =>
opts.onCheckpoint?.({ jobId, keys, savedKeys, skip, total: progress.total, done: progress.done, failed: progress.failed });
while (!opts.signal.aborted) {
let items: Source[];
let job: Job;
let committed: string[] = [];
if (resume) {
// the page was already sent before the page was reloaded: pick its result up
const taken = resume;
resume = undefined;
const known = await opts.client.job(taken.jobId, opts.signal).catch(() => null);
const known = await opts.client.job(taken.jobId, opts.signal).catch((error) => {
if (error?.status === 404) return null;
throw error;
});
// the translator has forgotten it: the lines are still untranslated and come with the next page
if (!known) continue;
job = known;
committed = taken.savedKeys ?? [];
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
opts.onPage?.(items);
} else {
@@ -147,7 +156,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
}
// Lines are saved as the translator finishes them, not when the whole page is over: a slow
// model then shows progress at once, and a stop or a crash loses almost nothing.
const saved = new Set<number>();
const saved = new Set<number>(items.flatMap((item, i) => committed.includes(item.key) ? [i] : []));
// lines whose tags sit inside words were sent without them: they need a look afterwards
const plain = new Set<number>();
items.forEach((it, i) => {
@@ -161,7 +170,6 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const index = at(it.id);
const src = items[index];
if (!saved.has(index) && it.status === "completed" && it.translation?.trim() && src) {
saved.add(index);
ready.push(fuzzy || plain.has(index) ? { key: src.key, text: it.translation, state: "fuzzy" } : { key: src.key, text: it.translation });
} else if (it.error && it.status !== "completed") {
// the translator is retrying this line: say why, so a slow page is not a silent one
@@ -169,9 +177,13 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
}
}
progress.model = current.model ?? progress.model;
if (!progress.failed && current.items.every((item) => item.status === "completed")) progress.lastError = "";
if (ready.length) {
await opts.save(ready);
const keys = new Set(ready.map((item) => item.key));
items.forEach((item, i) => { if (keys.has(item.key)) saved.add(i); });
progress.done += ready.length;
checkpoint(job.id, items.map((item) => item.key), [...saved].map((i) => items[i].key));
}
emit();
};
@@ -199,7 +211,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
emit();
checkpoint(null, []);
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
if (job.status === "cancelled") break;
if (job.status === "cancelled") return "stopped";
}
emit();
return opts.signal.aborted ? "stopped" : "finished";
+97 -32
View File
@@ -14,7 +14,7 @@ import { runTranslation, type Checkpoint, type Progress, type Result, type Sourc
const PAGE = 40;
const SAVED_KEY = "pg.translation.run";
const LOCK = "pg.translation.run";
/** a run last heard of longer ago than this is not picked up again */
/** Older runs remain available for manual resumption, without starting by themselves. */
const FRESH_MS = 30 * 60 * 1000;
export type RunState = "untranslated" | "outdated";
@@ -47,6 +47,9 @@ interface SavedRun {
before: { total: number; done: number; failed: number };
checkpoint: Checkpoint | null;
at: number;
status?: TranslationTask["status"];
progress?: Progress;
error?: string;
}
export interface TranslationTask {
@@ -62,6 +65,7 @@ export interface TranslationTask {
/** the first line of that page: moves when the run moves on */
cursor: string;
error: string;
resumable?: boolean;
}
/** What the open editor needs to show a page of results at once. */
@@ -80,6 +84,7 @@ interface RunApi {
/** `false` when another run (here or in another tab) already holds the translator */
start: (spec: RunSpec) => Promise<boolean>;
stop: () => void;
resume: () => Promise<boolean>;
dismiss: () => void;
subscribe: (listener: (event: FillEvent) => void) => () => void;
}
@@ -87,10 +92,16 @@ interface RunApi {
const RunContext = createContext<RunApi | null>(null);
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
function failureMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error ?? "");
const status = (error as { status?: number } | null)?.status;
return message || (status ? `HTTP ${status}` : "Неизвестная ошибка");
}
function loadSaved(): SavedRun | null {
try {
const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null;
return saved && saved.info && Date.now() - saved.at < FRESH_MS ? saved : null;
return saved && saved.info ? saved : null;
} catch {
return null;
}
@@ -126,6 +137,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
const listeners = useRef(new Set<(event: FillEvent) => void>());
const sequence = useRef(0);
const busyRef = useRef(false);
const recovery = useRef<SavedRun | null>(null);
const remember = useCallback((saved: SavedRun | null) => {
recovery.current = saved;
store(saved);
}, []);
const patch = useCallback((id: number, change: Partial<TranslationTask> | ((t: TranslationTask) => Partial<TranslationTask>)) => {
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
@@ -139,10 +155,8 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
control.current = ctl;
const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
const resumed = saved?.checkpoint;
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
progress: { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
failed: before.failed + (resumed?.failed ?? 0) },
keys: [], cursor: "", error: "" });
const initialProgress = { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
failed: before.failed + (resumed?.failed ?? 0) };
const info: RunInfo = {
projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind,
@@ -153,10 +167,12 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
const work = async () => {
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
progress: initialProgress, keys: [], cursor: "", error: "" });
// written only once this tab owns the translator, so it never overwrites another tab's record
store({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now() });
remember({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now(), status: "running" });
const base = { ...before };
let last: Progress = EMPTY;
let last: Progress = initialProgress;
try {
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
const state = spec.states[phase];
@@ -164,14 +180,20 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
const outcome = await runTranslation({
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
initial: again ? { total: again.total, done: again.done, failed: again.failed, skip: again.skip } : undefined,
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys } : undefined,
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
onCheckpoint: (checkpoint) => store({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now() }),
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys, savedKeys: again.savedKeys } : undefined,
fetchPage: async (skip, limit) => {
try { return await api.get<{ total: number; items: Source[] }>(url(state, skip, limit)); }
catch (error) { throw new Error(`Не удалось получить строки для перевода: ${failureMessage(error)}`); }
},
onCheckpoint: (checkpoint) => remember({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now(), status: "running" }),
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
save: async (results) => {
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: r.state ?? "machine" }]));
const stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`,
spec.kind === "strings" ? { strings: changes } : { lines: changes });
let stats: Partial<LangStats>;
try {
stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`,
spec.kind === "strings" ? { strings: changes } : { lines: changes });
} catch (error) { throw new Error(`Не удалось сохранить перевод: ${failureMessage(error)}`); }
const done = new Set(results.map((r) => r.key));
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
for (const listener of listeners.current) {
@@ -181,16 +203,26 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
onProgress: (p) => {
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
patch(id, { progress: last });
if (recovery.current) remember({ ...recovery.current, progress: last, at: Date.now() });
},
});
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
if (outcome === "stopped") break;
if (outcome === "stopped") {
patch(id, { status: "stopped", keys: [], resumable: true });
if (recovery.current) remember({ ...recovery.current, status: "stopped", progress: last });
return;
}
}
patch(id, { status: ctl.signal.aborted ? "stopped" : "done", keys: [] });
const status = ctl.signal.aborted ? "stopped" : "done";
const resumable = status === "stopped" || last.failed > 0;
patch(id, { status, keys: [], resumable });
if (resumable && recovery.current) remember({ ...recovery.current, status, progress: last });
else remember(null);
} catch (e) {
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
const error = failureMessage(e);
patch(id, { status: "failed", error, keys: [], resumable: true });
if (recovery.current) remember({ ...recovery.current, status: "failed", error, progress: last });
} finally {
store(null);
busyRef.current = false;
control.current = null;
}
@@ -204,19 +236,45 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
setTask((t) => (t && t.id === id ? null : t));
}
return got;
}, [patch]);
}, [patch, remember]);
const start = useCallback((spec: RunSpec) => launch(spec), [launch]);
const resume = useCallback(async () => {
const saved = recovery.current;
if (!saved || busyRef.current) return false;
const p = saved.progress ?? { ...EMPTY, done: saved.before.done + (saved.checkpoint?.done ?? 0),
total: saved.before.total + (saved.checkpoint?.total ?? 0) };
// Revisit failed rows from every phase. Already saved rows no longer match the filters.
const batch = /output was truncated|content_filter|finish_reason.*length/i.test(`${p.lastError} ${saved.error ?? ""}`)
? Math.max(1, Math.floor(saved.info.batch / 2)) : saved.info.batch;
const next: SavedRun = { ...saved, info: { ...saved.info, batch }, phase: 0,
before: { total: p.done, done: p.done, failed: 0 },
checkpoint: { jobId: saved.checkpoint?.jobId ?? null, keys: saved.checkpoint?.keys ?? [],
savedKeys: saved.checkpoint?.savedKeys, skip: 0, total: Math.max(0, p.total - p.done), done: 0, failed: 0 } };
const started = await launch({ ...next.info, client: new TranslatorClient(next.base) }, next);
if (!started) setTask((task) => task ? { ...task, error: "Другой перевод уже идёт в другой вкладке. Дождитесь его или остановите." } : task);
return started;
}, [launch]);
// A reload ends the page's loop, but the translator goes on with its page: continue from the saved point.
useEffect(() => {
const saved = loadSaved();
if (!saved) return;
const resume = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved);
recovery.current = saved;
if ((saved.status && saved.status !== "running") || Date.now() - saved.at >= FRESH_MS) {
setTask({ id: ++sequence.current, versionId: saved.info.versionId, lang: saved.info.lang, title: saved.info.title,
kind: saved.info.kind, status: saved.status === "failed" ? "failed" : "stopped", resumable: true,
progress: saved.progress ?? { ...EMPTY, total: saved.before.total + (saved.checkpoint?.total ?? 0),
done: saved.before.done + (saved.checkpoint?.done ?? 0), failed: saved.before.failed + (saved.checkpoint?.failed ?? 0) },
keys: [], cursor: "", error: saved.error ?? "" });
return;
}
const reload = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved);
let retry: ReturnType<typeof setTimeout> | undefined;
// the page that was reloaded may still be letting go of its lock: look once more shortly
void resume().then((got) => {
if (!got && !busyRef.current) retry = setTimeout(() => void resume(), 2000);
void reload().then((got) => {
if (!got && !busyRef.current) retry = setTimeout(() => void reload(), 2000);
});
return () => clearTimeout(retry);
}, [launch]);
@@ -226,7 +284,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
}, []);
const dismiss = useCallback(() => setTask((t) => (t && (t.status === "running" || t.status === "stopping") ? t : null)), []);
const dismiss = useCallback(() => {
if (busyRef.current) return;
remember(null);
setTask(null);
}, [remember]);
const subscribe = useCallback((listener: (event: FillEvent) => void) => {
listeners.current.add(listener);
@@ -243,7 +305,7 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener("beforeunload", warn);
}, [busy]);
const value = useMemo(() => ({ task, busy: !!busy, start, stop, dismiss, subscribe }), [task, busy, start, stop, dismiss, subscribe]);
const value = useMemo(() => ({ task, busy: !!busy, start, stop, resume, dismiss, subscribe }), [task, busy, start, stop, resume, dismiss, subscribe]);
return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
}
@@ -261,13 +323,13 @@ export function statusText(task: TranslationTask): string {
case "stopping": return "Останавливаю…";
case "stopped": return `Остановлено. ${count}`;
case "done": return `Готово. ${count}`;
default: return "Перевод прервался";
default: return `Перевод прервался. ${count}`;
}
}
/** The card of the run in the floating window. */
export function TranslationProgress() {
const { task, stop, dismiss } = useTranslationRun();
const { task, stop, resume, dismiss } = useTranslationRun();
if (!task) return null;
const { progress: p } = task;
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
@@ -282,16 +344,19 @@ export function TranslationProgress() {
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
</div>
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</div>
{task.status !== "failed" && (
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
</div>
)}
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
</div>
<p className="small" role="status">{text}</p>
{task.error && <ErrorNote>{task.error}</ErrorNote>}
{p.lastError && <p className="muted small">{p.failed > 0 ? "Последняя ошибка" : "Модель повторяет попытку"}: {p.lastError}</p>}
{p.lastError && <p className="muted small">{active && !p.failed ? "Модель повторяет попытку" : "Последняя ошибка модели"}: {p.lastError}</p>}
{/output was truncated|content_filter/i.test(p.lastError || task.error) && (
<p className="muted small">Ответ модели обрезан лимитом токенов или отклонён фильтром переводчика.
При возобновлении размер пачки будет уменьшен. Готовые переводы сохранены.</p>
)}
<div className="actions">
{!active && task.resumable && <button className="btn btn-sm" onClick={() => void resume()}><Icon name="play" size={16} />Возобновить</button>}
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
className="small">Открыть редактор</Link>
+152
View File
@@ -0,0 +1,152 @@
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(); }
});
+32 -2
View File
@@ -160,7 +160,7 @@ test("a run that was interrupted picks up the page the translator finished meanw
test("a job the translator no longer knows is skipped and its lines come back with the list", async () => {
const server = fakeServer(["a", "b", "c"]);
const translator = { ...fakeTranslator(), async job() { throw new Error("404"); } };
const translator = { ...fakeTranslator(), async job() { throw Object.assign(new Error("404"), { status: 404 }); } };
// `job` is also used while waiting, but the resumed job fails at once, so nothing waits
const created = [];
const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } };
@@ -172,7 +172,37 @@ test("a job the translator no longer knows is skipped and its lines come back wi
test("checkpoints say which page is out and when it is handled", async () => {
const marks = [];
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
assert.deepEqual(marks, [["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["done", 0, 0]]);
assert.deepEqual(marks, [["sent", 2, 0], ["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["sent", 1, 0], ["done", 0, 0]]);
});
test("a failed save keeps its results available for resuming instead of silently skipping them", async () => {
const translator = fakeTranslator();
let checkpoint;
const server = fakeServer(["a", "b"]);
await assert.rejects(run(translator, server, {
save: async () => { throw new Error("save offline"); },
onCheckpoint: (cp) => { checkpoint = cp; },
}), /save offline/);
assert.equal(checkpoint.jobId, "j0");
assert.equal(checkpoint.done, 0);
await run(translator, server, { initial: checkpoint, resume: checkpoint });
assert.deepEqual(server.saved, { k0: "RU a", k1: "RU b" });
assert.equal(translator.created.length, 1);
});
test("resuming a partially saved job does not overwrite its committed rows or count them twice", async () => {
const translator = fakeTranslator();
const server = fakeServer(["a", "b"]);
const job = await translator.createJob("p", [{ id: "0", text: "a" }, { id: "1", text: "b" }]);
server.saved.k0 = "Human edit";
const seen = [];
await run(translator, server, {
initial: { total: 2, done: 1, failed: 0, skip: 0 },
resume: { jobId: job.id, keys: ["k0", "k1"], savedKeys: ["k0"] },
onProgress: (p) => seen.push(p),
});
assert.deepEqual(server.saved, { k0: "Human edit", k1: "RU b" });
assert.equal(seen.at(-1).done, 2);
});
test("a slow page is saved line by line while it is still running", async () => {