Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions docs/testing/unit/orchestration/orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,137 @@ describe('状态推送与中止记账(#327)', () => {
orch.stop();
});
});

describe('观察器启停时机(#328)', () => {
test('未触发翻译时观察器始终未被启动', async () => {
const start = vi.fn();
const stop = vi.fn();
const orch = createOrchestrator({
send: vi.fn(),
hasTranslated: () => false,
onObserverStart: start,
onObserverStop: stop,
});
orch.start();

// 只构造编排、不触发 toggle:观察器启停钩子零调用
expect(start).not.toHaveBeenCalled();
expect(stop).not.toHaveBeenCalled();
orch.stop();
});

test('整页翻译成功后观察器被启动恰好一次', async () => {
const start = vi.fn();
const orch = createOrchestrator({
send: vi.fn(async () => ({ ok: true, data: { translations: ['译'] } })),
hasTranslated: () => false,
onObserverStart: start,
});
orch.start();

const result = await orch.togglePage(items(1), 'en', 'zh-CN');

expect(result.status).toBe('translated');
expect(start).toHaveBeenCalledTimes(1);
orch.stop();
});

test('整页翻译失败后观察器未被启动', async () => {
const start = vi.fn();
const orch = createOrchestrator({
send: vi.fn(async () => ({
ok: false,
category: 'transient',
error: 'HTTP 503',
retryable: true,
})),
hasTranslated: () => false,
onObserverStart: start,
});
orch.start();

const result = await orch.togglePage(items(1), 'en', 'zh-CN');

expect(result.status).toBe('error');
expect(start).not.toHaveBeenCalled();
orch.stop();
});

test('翻译在飞时被还原后观察器未被启动', async () => {
let resolveSend!: (v: unknown) => void;
const send = vi.fn(() => new Promise((r) => (resolveSend = r)));
const start = vi.fn();
const stop = vi.fn();
let translated = false;
const orch = createOrchestrator({
send,
hasTranslated: () => translated,
restore: () => {},
onObserverStart: start,
onObserverStop: stop,
});
orch.start();

const first = orch.togglePage(items(1), 'en', 'zh-CN');
translated = true;
await orch.togglePage(items(1), 'en', 'zh-CN'); // 在飞还原
resolveSend({ ok: true, data: { translations: ['译'] } });
const firstResult = await first;

expect(firstResult.status).toBe('aborted');
expect(start).not.toHaveBeenCalled();
expect(stop).toHaveBeenCalledTimes(1);
orch.stop();
});

test('还原后观察器被停止,重复还原停止幂等(钩子每次调用、注册表保证幂等)', async () => {
const stop = vi.fn();
let translated = true;
const orch = createOrchestrator({
send: vi.fn(),
hasTranslated: () => translated,
restore: () => {
translated = false;
},
onObserverStop: stop,
});
orch.start();

await orch.togglePage(items(1), 'en', 'zh-CN');
expect(stop).toHaveBeenCalledTimes(1);

// 重复还原(例如快捷键连按):停止钩子再次调用,
// 幂等性由生命周期注册表保证(ensure(false) 为空操作)
translated = true;
await orch.togglePage(items(1), 'en', 'zh-CN');
expect(stop).toHaveBeenCalledTimes(2);
orch.stop();
});

test('成功 → 停止 → 再成功的完整周期:启停各一次', async () => {
const start = vi.fn();
const stop = vi.fn();
let translated = false;
const orch = createOrchestrator({
send: vi.fn(async () => ({ ok: true, data: { translations: ['译'] } })),
hasTranslated: () => translated,
restore: () => {
translated = false;
},
onObserverStart: start,
onObserverStop: stop,
});
orch.start();

await orch.togglePage(items(1), 'en', 'zh-CN');
expect(start).toHaveBeenCalledTimes(1);

translated = true;
await orch.togglePage(items(1), 'en', 'zh-CN');
expect(stop).toHaveBeenCalledTimes(1);

await orch.togglePage(items(1), 'en', 'zh-CN');
expect(start).toHaveBeenCalledTimes(2);
orch.stop();
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parallel-translation",
"version": "2.0.60",
"version": "2.0.61",
"description": "对照式网页翻译浏览器扩展",
"private": true,
"type": "module",
Expand Down
12 changes: 12 additions & 0 deletions src/orchestration/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ export interface OrchestratorOptions {
* 已完成状态 —— 调用方经 onBatchResult 统计渲染成败后在此报告。
*/
allRenderRejected?: () => boolean;
/**
* 增量补翻观察器启动钩子(#328):只在整页翻译成功后调用 ——
* 调用方接线到生命周期注册表(启停幂等,未启动为空操作)。
*/
onObserverStart?: () => void;
/** 增量补翻观察器停止钩子(#328):还原时调用,幂等。 */
onObserverStop?: () => void;
}

/**
Expand Down Expand Up @@ -441,6 +448,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches
opts.restore?.();
// #327: 还原后推送空闲态(不是错误)
pushVisual('idle');
// #328: 还原时停止增量补翻观察器(幂等 —— 重复还原为空操作)
opts.onObserverStop?.();
return { status: 'restored', admission };
}

Expand All @@ -461,6 +470,9 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches
pushVisual(
status === 'translated' ? 'done' : status === 'error' ? 'error' : 'idle',
);
// #328: 观察器启停时机 —— 只在整页翻译成功后启动恰好一次;
// 失败 / 中止(在飞还原)不启动
if (status === 'translated') opts.onObserverStart?.();
return { status, admission, summary };
} finally {
toggleInFlight = false;
Expand Down
Loading