From d87bf22dcb479120c88aa87fd4a2b4e0e9047b65 Mon Sep 17 00:00:00 2001 From: Teeeeeeeerry Date: Tue, 1 Sep 2026 01:19:58 +1000 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E9=A3=9E=E4=BA=92=E6=96=A5=E8=BF=81?= =?UTF-8?q?=E5=85=A5=E7=BC=96=E6=8E=92=E6=A8=A1=E5=9D=97=20(fix=20#326)?= 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 | 69 +++++++++++++++++++ package.json | 2 +- src/orchestration/orchestrator.ts | 31 +++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/docs/testing/unit/orchestration/orchestrator.test.ts b/docs/testing/unit/orchestration/orchestrator.test.ts index 2cd2add..0eafb71 100644 --- a/docs/testing/unit/orchestration/orchestrator.test.ts +++ b/docs/testing/unit/orchestration/orchestrator.test.ts @@ -735,3 +735,72 @@ describe('整页开关入口(#325)', () => { orch.stop(); }); }); + +describe('在飞互斥(#326)', () => { + test('在飞期间二次触发且页面尚无译文:假消息层只收到一轮请求,第二次返回忙碌', async () => { + let resolveSend!: (v: unknown) => void; + const send = vi.fn(() => new Promise((r) => (resolveSend = r))); + const orch = createOrchestrator({ + send, + hasTranslated: () => false, + }); + orch.start(); + + const first = orch.togglePage(items(1), 'en', 'zh-CN'); + const second = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(send).toHaveBeenCalledTimes(1); + expect(second.status).toBe('busy'); + + resolveSend({ ok: true, data: { translations: ['译'] } }); + const firstResult = await first; + expect(firstResult.status).toBe('translated'); + orch.stop(); + }); + + test('在飞期间页面已有译文时再次触发:放行还原且在飞批次被中止', async () => { + let resolveSend!: (v: unknown) => void; + const send = vi.fn(() => new Promise((r) => (resolveSend = r))); + const restore = vi.fn(); + let translated = false; // 首批渲染完成后置 true(模拟内容脚本渲染) + const orch = createOrchestrator({ + send, + hasTranslated: () => translated, + restore, + }); + orch.start(); + + const first = orch.togglePage(items(1), 'en', 'zh-CN'); + // 首批在飞期间译文已落 DOM(渲染回调置位)→ 再次触发放行还原 + translated = true; + const second = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(second.status).toBe('restored'); + expect(restore).toHaveBeenCalledTimes(1); + + // 在飞批次被中止:首轮返回 aborted,不产生译文 + resolveSend({ ok: true, data: { translations: ['译'] } }); + const firstResult = await first; + expect(firstResult.status).toBe('aborted'); + orch.stop(); + }); + + test('忙碌状态不被调用方当作错误:正常返回而非抛错', async () => { + let resolveSend!: (v: unknown) => void; + const send = vi.fn(() => new Promise((r) => (resolveSend = r))); + const orch = createOrchestrator({ send, hasTranslated: () => false }); + orch.start(); + + const first = orch.togglePage(items(1), 'en', 'zh-CN'); + const second = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(second.status).toBe('busy'); + expect(second.summary).toBeUndefined(); + // 忙碌结果不携带错误信息(区别于 error 状态) + expect(() => second).not.toThrow(); + + resolveSend({ ok: true, data: { translations: ['译'] } }); + await first; + orch.stop(); + }); +}); diff --git a/package.json b/package.json index bfcdaca..11c376b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.0.58", + "version": "2.0.59", "description": "对照式网页翻译浏览器扩展", "private": true, "type": "module", diff --git a/src/orchestration/orchestrator.ts b/src/orchestration/orchestrator.ts index f4446eb..451ebde 100644 --- a/src/orchestration/orchestrator.ts +++ b/src/orchestration/orchestrator.ts @@ -178,6 +178,7 @@ export type PageToggleStatus = | 'restored' | 'disabled' | 'blocked' + | 'busy' | 'aborted' | 'error' | 'no-elements'; @@ -245,6 +246,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches // #262: 还原纪元在模块内 —— abort() 递增,在飞翻译据此放弃 // 尝试、重试与渲染;新翻译快照新纪元,不受旧批次干扰 let epoch = 0; + // #326: 整页开关入口在飞互斥 —— 在飞期间页面尚无译文时忽略新触发 + let toggleInFlight = false; // #265: 设置变更订阅(start 订阅 / stop 退订) let unsubscribeSettings: (() => void) | null = null; @@ -385,6 +388,13 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches async togglePage(items, from, to): Promise { if (!started) throw new Error('[PT] 编排未启动'); + // #326: 在飞互斥 —— 在飞期间页面尚无译文时忽略新触发并返回 + // 忙碌状态(忙碌不是错误,调用方不应当作失败);已有译文则 + // 放行还原(下方还原分支中止在飞批次) + if (toggleInFlight && !opts.hasTranslated?.()) { + return { status: 'busy', admission: 'allowed' }; + } + // 准入判定先行(#311):拦截时零请求、不执行任何动作 const admission = admissionFrom(opts); if (admission !== 'allowed') { @@ -396,6 +406,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches // #325: 翻译态查询经注入 —— 页面已有译文则还原,否则翻译 if (opts.hasTranslated?.()) { + // #326: 还原中止在飞批次(epoch 递增,在飞翻译放弃重试与渲染) + epoch++; opts.restore?.(); return { status: 'restored', admission }; } @@ -404,13 +416,18 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches return { status: 'no-elements', admission }; } - const summary = await translatePageImpl(items, from, to); - const status: PageToggleStatus = summary.aborted - ? 'aborted' - : summary.allFailed - ? 'error' - : 'translated'; - return { status, admission, summary }; + toggleInFlight = true; + try { + const summary = await translatePageImpl(items, from, to); + const status: PageToggleStatus = summary.aborted + ? 'aborted' + : summary.allFailed + ? 'error' + : 'translated'; + return { status, admission, summary }; + } finally { + toggleInFlight = false; + } }, async translateText(text, from, to): Promise {