fix: resume interrupted translations without losing saved progress
This commit is contained in:
1 parent
a8902deaf7
commit
e14184435c
6 files changed
+316
-39
No files matched your search
+17
-2
@@ -222,8 +222,11 @@ translate russian strings:
|
|||||||
записывается в `localStorage` (`pg.translation.run`: что переводится, где
|
записывается в `localStorage` (`pg.translation.run`: что переводится, где
|
||||||
остановились, какое задание отправлено в переводчик). После обновления страницы
|
остановились, какое задание отправлено в переводчик). После обновления страницы
|
||||||
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
|
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
|
||||||
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не
|
дальше, а карточка и бейджи снова обновляются. После ошибки или остановки
|
||||||
подхватывается;
|
карточка и панель редактора предлагают «Возобновить»: сохранённые строки
|
||||||
|
остаются на месте, непереведённые (включая прошлые ошибки) повторяются.
|
||||||
|
Запись старше 30 минут тоже доступна для возобновления, но сама не запускается.
|
||||||
|
Состояние удаляется после успешного завершения или кнопкой «Убрать»;
|
||||||
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
||||||
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
||||||
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
||||||
@@ -265,6 +268,18 @@ translate russian strings:
|
|||||||
цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` —
|
цикл перевода, `MachineTranslate.tsx` — панель, `TranslatorSetup.tsx` —
|
||||||
страница настройки); проверки в `frontend/tests/translator-run.test.mjs`.
|
страница настройки); проверки в `frontend/tests/translator-run.test.mjs`.
|
||||||
|
|
||||||
|
Сообщение `Model output was truncated or filtered` приходит от local-translator,
|
||||||
|
когда LM Studio возвращает `finish_reason=length` (исчерпан лимит ответа) либо
|
||||||
|
`content_filter`. По одному этому тексту отличить причины нельзя: для диагностики
|
||||||
|
нужны метрики вызова переводчика. Пачка ограничивается отдельно по числу строк
|
||||||
|
и по символам; лимит выходных токенов задаётся в конфигурации провайдера
|
||||||
|
local-translator. Повтор после такой ошибки использует вдвое меньшую пачку
|
||||||
|
(минимум одну строку). Успешный повтор убирает устаревшее сообщение ошибки модели.
|
||||||
|
Сбой получения строк или сохранения в библиотеку показывается отдельно с этапом
|
||||||
|
и HTTP-кодом, если сервер не вернул текст ошибки. Готовые результаты задания
|
||||||
|
и ключи уже сохранённых строк остаются в контрольной точке: возобновление не
|
||||||
|
отправляет готовое задание заново и не перезаписывает эти строки.
|
||||||
|
|
||||||
Ограничения: перевод идёт, пока открыта страница редактора; контекст
|
Ограничения: перевод идёт, пока открыта страница редактора; контекст
|
||||||
(кто говорит, соседние реплики) модели пока не передаётся, только текст строки;
|
(кто говорит, соседние реплики) модели пока не передаётся, только текст строки;
|
||||||
Safari может запрещать https-странице обращаться к локальному http.
|
Safari может запрещать https-странице обращаться к локальному http.
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ export function MachineTranslate({ session, versionId, lang, title, view, follow
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
{mine && task?.resumable && !run.busy && (
|
||||||
|
<button className="btn" onClick={() => void run.resume()} disabled={preparing}><Icon name="play" size={18} />Возобновить</button>
|
||||||
|
)}
|
||||||
{running ? (
|
{running ? (
|
||||||
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
|
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export interface RunOptions {
|
|||||||
/** where an interrupted run stood: the counters and how many lines to skip */
|
/** where an interrupted run stood: the counters and how many lines to skip */
|
||||||
initial?: { total: number; done: number; failed: number; skip: number };
|
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 */
|
/** 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 */
|
/** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */
|
||||||
onCheckpoint?: (cp: Checkpoint) => void;
|
onCheckpoint?: (cp: Checkpoint) => void;
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,8 @@ export interface Checkpoint {
|
|||||||
total: number;
|
total: number;
|
||||||
done: number;
|
done: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
|
/** Results already committed from this job; do not overwrite them on recovery. */
|
||||||
|
savedKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Progress {
|
export interface Progress {
|
||||||
@@ -97,11 +99,13 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
|
|||||||
try {
|
try {
|
||||||
job = await opts.client.job(job.id, opts.signal);
|
job = await opts.client.job(job.id, opts.signal);
|
||||||
errors = 0;
|
errors = 0;
|
||||||
await onPoll?.(job);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (opts.signal.aborted) return stop();
|
if (opts.signal.aborted) return stop();
|
||||||
if (++errors >= 4) throw e;
|
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;
|
return job;
|
||||||
}
|
}
|
||||||
@@ -113,20 +117,25 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
const emit = () => opts.onProgress({ ...progress });
|
const emit = () => opts.onProgress({ ...progress });
|
||||||
let skip = start?.skip ?? 0;
|
let skip = start?.skip ?? 0;
|
||||||
let resume = opts.resume;
|
let resume = opts.resume;
|
||||||
const checkpoint = (jobId: string | null, keys: string[]) =>
|
const checkpoint = (jobId: string | null, keys: string[], savedKeys: string[] = []) =>
|
||||||
opts.onCheckpoint?.({ jobId, keys, skip, total: progress.total, done: progress.done, failed: progress.failed });
|
opts.onCheckpoint?.({ jobId, keys, savedKeys, skip, total: progress.total, done: progress.done, failed: progress.failed });
|
||||||
|
|
||||||
while (!opts.signal.aborted) {
|
while (!opts.signal.aborted) {
|
||||||
let items: Source[];
|
let items: Source[];
|
||||||
let job: Job;
|
let job: Job;
|
||||||
|
let committed: string[] = [];
|
||||||
if (resume) {
|
if (resume) {
|
||||||
// the page was already sent before the page was reloaded: pick its result up
|
// the page was already sent before the page was reloaded: pick its result up
|
||||||
const taken = resume;
|
const taken = resume;
|
||||||
resume = undefined;
|
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
|
// the translator has forgotten it: the lines are still untranslated and come with the next page
|
||||||
if (!known) continue;
|
if (!known) continue;
|
||||||
job = known;
|
job = known;
|
||||||
|
committed = taken.savedKeys ?? [];
|
||||||
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
|
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
|
||||||
opts.onPage?.(items);
|
opts.onPage?.(items);
|
||||||
} else {
|
} 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
|
// 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.
|
// 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
|
// lines whose tags sit inside words were sent without them: they need a look afterwards
|
||||||
const plain = new Set<number>();
|
const plain = new Set<number>();
|
||||||
items.forEach((it, i) => {
|
items.forEach((it, i) => {
|
||||||
@@ -161,7 +170,6 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
const index = at(it.id);
|
const index = at(it.id);
|
||||||
const src = items[index];
|
const src = items[index];
|
||||||
if (!saved.has(index) && it.status === "completed" && it.translation?.trim() && src) {
|
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 });
|
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") {
|
} else if (it.error && it.status !== "completed") {
|
||||||
// the translator is retrying this line: say why, so a slow page is not a silent one
|
// 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;
|
progress.model = current.model ?? progress.model;
|
||||||
|
if (!progress.failed && current.items.every((item) => item.status === "completed")) progress.lastError = "";
|
||||||
if (ready.length) {
|
if (ready.length) {
|
||||||
await opts.save(ready);
|
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;
|
progress.done += ready.length;
|
||||||
|
checkpoint(job.id, items.map((item) => item.key), [...saved].map((i) => items[i].key));
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
@@ -199,7 +211,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
emit();
|
emit();
|
||||||
checkpoint(null, []);
|
checkpoint(null, []);
|
||||||
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
||||||
if (job.status === "cancelled") break;
|
if (job.status === "cancelled") return "stopped";
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
return opts.signal.aborted ? "stopped" : "finished";
|
return opts.signal.aborted ? "stopped" : "finished";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { runTranslation, type Checkpoint, type Progress, type Result, type Sourc
|
|||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
const SAVED_KEY = "pg.translation.run";
|
const SAVED_KEY = "pg.translation.run";
|
||||||
const LOCK = "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;
|
const FRESH_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
export type RunState = "untranslated" | "outdated";
|
export type RunState = "untranslated" | "outdated";
|
||||||
@@ -47,6 +47,9 @@ interface SavedRun {
|
|||||||
before: { total: number; done: number; failed: number };
|
before: { total: number; done: number; failed: number };
|
||||||
checkpoint: Checkpoint | null;
|
checkpoint: Checkpoint | null;
|
||||||
at: number;
|
at: number;
|
||||||
|
status?: TranslationTask["status"];
|
||||||
|
progress?: Progress;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationTask {
|
export interface TranslationTask {
|
||||||
@@ -62,6 +65,7 @@ export interface TranslationTask {
|
|||||||
/** the first line of that page: moves when the run moves on */
|
/** the first line of that page: moves when the run moves on */
|
||||||
cursor: string;
|
cursor: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
resumable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the open editor needs to show a page of results at once. */
|
/** 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 */
|
/** `false` when another run (here or in another tab) already holds the translator */
|
||||||
start: (spec: RunSpec) => Promise<boolean>;
|
start: (spec: RunSpec) => Promise<boolean>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
|
resume: () => Promise<boolean>;
|
||||||
dismiss: () => void;
|
dismiss: () => void;
|
||||||
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
@@ -87,10 +92,16 @@ interface RunApi {
|
|||||||
const RunContext = createContext<RunApi | null>(null);
|
const RunContext = createContext<RunApi | null>(null);
|
||||||
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: 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 {
|
function loadSaved(): SavedRun | null {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,6 +137,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
const listeners = useRef(new Set<(event: FillEvent) => void>());
|
const listeners = useRef(new Set<(event: FillEvent) => void>());
|
||||||
const sequence = useRef(0);
|
const sequence = useRef(0);
|
||||||
const busyRef = useRef(false);
|
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>)) => {
|
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));
|
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;
|
control.current = ctl;
|
||||||
const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
|
const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
|
||||||
const resumed = saved?.checkpoint;
|
const resumed = saved?.checkpoint;
|
||||||
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
|
const initialProgress = { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
|
||||||
progress: { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
|
failed: before.failed + (resumed?.failed ?? 0) };
|
||||||
failed: before.failed + (resumed?.failed ?? 0) },
|
|
||||||
keys: [], cursor: "", error: "" });
|
|
||||||
|
|
||||||
const info: RunInfo = {
|
const info: RunInfo = {
|
||||||
projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind,
|
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) })}`;
|
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
|
||||||
|
|
||||||
const work = async () => {
|
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
|
// 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 };
|
const base = { ...before };
|
||||||
let last: Progress = EMPTY;
|
let last: Progress = initialProgress;
|
||||||
try {
|
try {
|
||||||
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
|
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
|
||||||
const state = spec.states[phase];
|
const state = spec.states[phase];
|
||||||
@@ -164,14 +180,20 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
const outcome = await runTranslation({
|
const outcome = await runTranslation({
|
||||||
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
|
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,
|
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,
|
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys, savedKeys: again.savedKeys } : undefined,
|
||||||
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
|
fetchPage: async (skip, limit) => {
|
||||||
onCheckpoint: (checkpoint) => store({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now() }),
|
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 ?? "" }),
|
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
|
||||||
save: async (results) => {
|
save: async (results) => {
|
||||||
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: r.state ?? "machine" }]));
|
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}`,
|
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 });
|
spec.kind === "strings" ? { strings: changes } : { lines: changes });
|
||||||
|
} catch (error) { throw new Error(`Не удалось сохранить перевод: ${failureMessage(error)}`); }
|
||||||
const done = new Set(results.map((r) => r.key));
|
const done = new Set(results.map((r) => r.key));
|
||||||
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
|
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
|
||||||
for (const listener of listeners.current) {
|
for (const listener of listeners.current) {
|
||||||
@@ -181,16 +203,26 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
onProgress: (p) => {
|
onProgress: (p) => {
|
||||||
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
|
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
|
||||||
patch(id, { progress: last });
|
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 });
|
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) {
|
} 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 {
|
} finally {
|
||||||
store(null);
|
|
||||||
busyRef.current = false;
|
busyRef.current = false;
|
||||||
control.current = null;
|
control.current = null;
|
||||||
}
|
}
|
||||||
@@ -204,19 +236,45 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
setTask((t) => (t && t.id === id ? null : t));
|
setTask((t) => (t && t.id === id ? null : t));
|
||||||
}
|
}
|
||||||
return got;
|
return got;
|
||||||
}, [patch]);
|
}, [patch, remember]);
|
||||||
|
|
||||||
const start = useCallback((spec: RunSpec) => launch(spec), [launch]);
|
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.
|
// A reload ends the page's loop, but the translator goes on with its page: continue from the saved point.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = loadSaved();
|
const saved = loadSaved();
|
||||||
if (!saved) return;
|
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;
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
// the page that was reloaded may still be letting go of its lock: look once more shortly
|
// the page that was reloaded may still be letting go of its lock: look once more shortly
|
||||||
void resume().then((got) => {
|
void reload().then((got) => {
|
||||||
if (!got && !busyRef.current) retry = setTimeout(() => void resume(), 2000);
|
if (!got && !busyRef.current) retry = setTimeout(() => void reload(), 2000);
|
||||||
});
|
});
|
||||||
return () => clearTimeout(retry);
|
return () => clearTimeout(retry);
|
||||||
}, [launch]);
|
}, [launch]);
|
||||||
@@ -226,7 +284,11 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
|
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) => {
|
const subscribe = useCallback((listener: (event: FillEvent) => void) => {
|
||||||
listeners.current.add(listener);
|
listeners.current.add(listener);
|
||||||
@@ -243,7 +305,7 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener("beforeunload", warn);
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
}, [busy]);
|
}, [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>;
|
return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,13 +323,13 @@ export function statusText(task: TranslationTask): string {
|
|||||||
case "stopping": return "Останавливаю…";
|
case "stopping": return "Останавливаю…";
|
||||||
case "stopped": return `Остановлено. ${count}`;
|
case "stopped": return `Остановлено. ${count}`;
|
||||||
case "done": return `Готово. ${count}`;
|
case "done": return `Готово. ${count}`;
|
||||||
default: return "Перевод прервался";
|
default: return `Перевод прервался. ${count}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The card of the run in the floating window. */
|
/** The card of the run in the floating window. */
|
||||||
export function TranslationProgress() {
|
export function TranslationProgress() {
|
||||||
const { task, stop, dismiss } = useTranslationRun();
|
const { task, stop, resume, dismiss } = useTranslationRun();
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
const { progress: p } = task;
|
const { progress: p } = task;
|
||||||
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
|
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>}
|
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</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}
|
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
|
||||||
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
|
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
|
||||||
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
|
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<p className="small" role="status">{text}</p>
|
<p className="small" role="status">{text}</p>
|
||||||
{task.error && <ErrorNote>{task.error}</ErrorNote>}
|
{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">
|
<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>}
|
{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" : ""}`}
|
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
|
||||||
className="small">Открыть редактор</Link>
|
className="small">Открыть редактор</Link>
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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 () => {
|
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 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
|
// `job` is also used while waiting, but the resumed job fails at once, so nothing waits
|
||||||
const created = [];
|
const created = [];
|
||||||
const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } };
|
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 () => {
|
test("checkpoints say which page is out and when it is handled", async () => {
|
||||||
const marks = [];
|
const marks = [];
|
||||||
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
|
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 () => {
|
test("a slow page is saved line by line while it is still running", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user