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
132 changes: 132 additions & 0 deletions docs/testing/unit/orchestration/orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,3 +804,135 @@ describe('在飞互斥(#326)', () => {
orch.stop();
});
});

describe('状态推送与中止记账(#327)', () => {
function orchWith(opts: Record<string, unknown>): {
orch: ReturnType<typeof createOrchestrator>;
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<typeof createOrchestrator>[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();
});
});
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.59",
"version": "2.0.60",
"description": "对照式网页翻译浏览器扩展",
"private": true,
"type": "module",
Expand Down
39 changes: 38 additions & 1 deletion src/orchestration/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 批满额,最后一批为余数。
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -409,6 +439,8 @@ export function createOrchestrator(opts: OrchestratorOptions): TranslationOrches
// #326: 还原中止在飞批次(epoch 递增,在飞翻译放弃重试与渲染)
epoch++;
opts.restore?.();
// #327: 还原后推送空闲态(不是错误)
pushVisual('idle');
return { status: 'restored', admission };
}

Expand All @@ -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;
Expand Down
Loading