From e00a13dc9aa2b8f78799719ce36c60b5a211aba5 Mon Sep 17 00:00:00 2001 From: Teeeeeeeerry Date: Tue, 1 Sep 2026 01:24:58 +1000 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=AF=9F=E5=99=A8=E5=90=AF=E5=81=9C?= =?UTF-8?q?=E6=97=B6=E6=9C=BA=E8=BF=81=E5=85=A5=20(fix=20#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增量补翻观察器的启停时机此前在内容脚本。迁入状态机:只在整页 翻译成功后启动恰好一次、还原时停止、失败与在飞还原不启动; 启停经注入钩子表达,沿用生命周期注册表幂等语义。 Co-Authored-By: zhexuancai-uts <261878103+zhexuancai-uts@users.noreply.github.com> --- .../unit/orchestration/orchestrator.test.ts | 134 ++++++++++++++++++ package.json | 2 +- src/orchestration/orchestrator.ts | 12 ++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/docs/testing/unit/orchestration/orchestrator.test.ts b/docs/testing/unit/orchestration/orchestrator.test.ts index 3c5c06f..d59f1f8 100644 --- a/docs/testing/unit/orchestration/orchestrator.test.ts +++ b/docs/testing/unit/orchestration/orchestrator.test.ts @@ -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(); + }); +}); diff --git a/package.json b/package.json index f97195b..794434a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.0.60", + "version": "2.0.61", "description": "对照式网页翻译浏览器扩展", "private": true, "type": "module", diff --git a/src/orchestration/orchestrator.ts b/src/orchestration/orchestrator.ts index 9b66340..3f325be 100644 --- a/src/orchestration/orchestrator.ts +++ b/src/orchestration/orchestrator.ts @@ -235,6 +235,13 @@ export interface OrchestratorOptions { * 已完成状态 —— 调用方经 onBatchResult 统计渲染成败后在此报告。 */ allRenderRejected?: () => boolean; + /** + * 增量补翻观察器启动钩子(#328):只在整页翻译成功后调用 —— + * 调用方接线到生命周期注册表(启停幂等,未启动为空操作)。 + */ + onObserverStart?: () => void; + /** 增量补翻观察器停止钩子(#328):还原时调用,幂等。 */ + onObserverStop?: () => void; } /** @@ -441,6 +448,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches opts.restore?.(); // #327: 还原后推送空闲态(不是错误) pushVisual('idle'); + // #328: 还原时停止增量补翻观察器(幂等 —— 重复还原为空操作) + opts.onObserverStop?.(); return { status: 'restored', admission }; } @@ -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;