diff --git a/docs/testing/unit/orchestration/orchestrator.test.ts b/docs/testing/unit/orchestration/orchestrator.test.ts index 0eafb71..3c5c06f 100644 --- a/docs/testing/unit/orchestration/orchestrator.test.ts +++ b/docs/testing/unit/orchestration/orchestrator.test.ts @@ -804,3 +804,135 @@ describe('在飞互斥(#326)', () => { orch.stop(); }); }); + +describe('状态推送与中止记账(#327)', () => { + function orchWith(opts: Record): { + orch: ReturnType; + pushes: string[]; + } { + const pushes: string[] = []; + const orch = createOrchestrator({ + send: vi.fn(async () => ({ ok: true, data: { translations: ['译'] } })), + hasTranslated: () => false, + pushStatus: (s) => pushes.push(s), + ...opts, + } as Parameters[0]); + orch.start(); + return { orch, pushes }; + } + + test('翻译在飞时执行还原:结果标记为已中止、推送空闲而非错误', async () => { + let resolveSend!: (v: unknown) => void; + const send = vi.fn(() => new Promise((r) => (resolveSend = r))); + const restore = vi.fn(); + const pushes: string[] = []; + let translated = false; + const orch = createOrchestrator({ + send, + restore, + hasTranslated: () => translated, + pushStatus: (s) => pushes.push(s), + }); + orch.start(); + + const first = orch.togglePage(items(1), 'en', 'zh-CN'); + translated = true; // 在飞期间首批已渲染 + const second = await orch.togglePage(items(1), 'en', 'zh-CN'); + expect(second.status).toBe('restored'); + + resolveSend({ ok: true, data: { translations: ['译'] } }); + const firstResult = await first; + + // 首轮被中止:不产生错误,推送空闲而非错误 + expect(firstResult.status).toBe('aborted'); + expect(pushes).toEqual(['loading', 'idle', 'idle']); + expect(pushes).not.toContain('error'); + orch.stop(); + }); + + test('还原恰好发生在最后一批返回与整体返回之间:同样报告为已中止', async () => { + let resolveSend!: (v: unknown) => void; + const send = vi.fn(() => new Promise((r) => (resolveSend = r))); + const restore = vi.fn(); + const pushes: string[] = []; + const orch = createOrchestrator({ + send, + restore, + hasTranslated: () => false, + pushStatus: (s) => pushes.push(s), + }); + orch.start(); + + const first = orch.togglePage(items(1), 'en', 'zh-CN'); + // 批次已 resolve(最后一批返回),但整体返回前用户还原 + resolveSend({ ok: true, data: { translations: ['译'] } }); + orch.abort(); + restore(); + + const firstResult = await first; + expect(firstResult.status).toBe('aborted'); + expect(pushes).toEqual(['loading', 'idle']); + orch.stop(); + }); + + test('引擎全部失败:推送错误状态', async () => { + const send = vi.fn(async () => ({ + ok: false, + category: 'invalid-key', + error: 'API key 无效', + retryable: false, + })); + const pushes: string[] = []; + const orch = createOrchestrator({ + send, + hasTranslated: () => false, + pushStatus: (s) => pushes.push(s), + }); + orch.start(); + + const result = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(result.status).toBe('error'); + expect(pushes).toEqual(['loading', 'error']); + orch.stop(); + }); + + test('引擎返回结果但全部渲染被拒:不推送已完成状态', async () => { + const { orch, pushes } = orchWith({ allRenderRejected: () => true }); + + const result = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(result.status).toBe('error'); + expect(pushes).toEqual(['loading', 'error']); + orch.stop(); + }); + + test('状态推送为注入回调:模块不直接操作 UI(假回调可完整测试)', async () => { + const { orch, pushes } = orchWith({}); + + const result = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(result.status).toBe('translated'); + expect(pushes).toEqual(['loading', 'done']); + orch.stop(); + }); + + test('子框架不推送状态,但照常执行翻译', async () => { + const send = vi.fn(async () => ({ ok: true, data: { translations: ['译'] } })); + const pushes: string[] = []; + const orch = createOrchestrator({ + send, + hasTranslated: () => false, + isMainFrame: () => false, + pushStatus: (s) => pushes.push(s), + }); + orch.start(); + + const result = await orch.togglePage(items(1), 'en', 'zh-CN'); + + expect(result.status).toBe('translated'); + expect(send).toHaveBeenCalledTimes(1); // 子框架照常翻译 + expect(pushes).toEqual([]); // 但不推送状态 + orch.stop(); + }); +}); diff --git a/package.json b/package.json index 11c376b..f97195b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.0.59", + "version": "2.0.60", "description": "对照式网页翻译浏览器扩展", "private": true, "type": "module", diff --git a/src/orchestration/orchestrator.ts b/src/orchestration/orchestrator.ts index 451ebde..9b66340 100644 --- a/src/orchestration/orchestrator.ts +++ b/src/orchestration/orchestrator.ts @@ -221,8 +221,31 @@ export interface OrchestratorOptions { hasTranslated?: () => boolean; /** 还原动作(#325):开关入口在页面已有译文时调用(调用方实现 DOM 还原)。 */ restore?: () => void; + /** + * 状态推送(#327):悬浮球等视觉状态由模块单向推送 —— 模块不直接 + * 操作 UI。推送值见 PageToggleVisual;主框架标志为 false 时不推送。 + */ + pushStatus?: (status: PageToggleVisual) => void; + /** + * 主框架标志(#327):子框架不推送状态、不产生提示,但照常执行翻译。 + */ + isMainFrame?: () => boolean; + /** + * 渲染结果查询(#327):引擎返回结果但全部渲染被拒时,状态机不推送 + * 已完成状态 —— 调用方经 onBatchResult 统计渲染成败后在此报告。 + */ + allRenderRejected?: () => boolean; } +/** + * 整页开关的视觉状态(#327)—— 经 pushStatus 注入回调推送: + * - idle:空闲(还原完成 / 中止后回到空闲,不是错误) + * - loading:翻译在飞 + * - done:已完成 + * - error:全部引擎失败 / 全部渲染被拒 + */ +export type PageToggleVisual = 'idle' | 'loading' | 'done' | 'error'; + /** * 按批次大小切分翻译项(#245)。 * 与 content 现有切片逻辑一致:前 N-1 批满额,最后一批为余数。 @@ -359,6 +382,13 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches }; }; + // #327: 状态推送 —— 仅主框架推送(子框架不推送状态、不产生提示, + // 但照常执行翻译) + const pushVisual = (status: PageToggleVisual): void => { + if (opts.isMainFrame?.() === false) return; + opts.pushStatus?.(status); + }; + return { start(): void { started = true; @@ -409,6 +439,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches // #326: 还原中止在飞批次(epoch 递增,在飞翻译放弃重试与渲染) epoch++; opts.restore?.(); + // #327: 还原后推送空闲态(不是错误) + pushVisual('idle'); return { status: 'restored', admission }; } @@ -417,13 +449,18 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches } toggleInFlight = true; + pushVisual('loading'); try { const summary = await translatePageImpl(items, from, to); const status: PageToggleStatus = summary.aborted ? 'aborted' - : summary.allFailed + : summary.allFailed || opts.allRenderRejected?.() ? 'error' : 'translated'; + // #327: 中止(还原)→ 空闲态;失败 → 错误态;成功 → 完成态 + pushVisual( + status === 'translated' ? 'done' : status === 'error' ? 'error' : 'idle', + ); return { status, admission, summary }; } finally { toggleInFlight = false;