Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
suzmii 14a0286f70 fix(agc): 生成面板点击即关闭、面板内不再出现阶段文案,只有即时失败才把草稿带回来
- ResourceCanvasAssetGenerationPanelView:删掉 `phaseDetail` 通道与整套在途状态(`submitting` /
  禁用输入 / 「后台运行并关闭」),主按钮文案恒为动作名;提交时**同步**调用 onSubmit 并 onClose,
  面板不等 IPC、不等排队、不等生成。新增 `draft` / `error` 两个入参:只有「后端从未受理」的即时
  失败重开时,宿主才把草稿与原因带回来(`role="alert"` 可见、改完即可重试)
- index.tsx:`submitResourceAssetGeneration` 改为同步返回(不再 await 终局),记录本次提交上下文
  (taskId / action / 草稿 / 是否本来就该立刻派发),提交后自动开任务面板并给一次提示条;
  收尾回调里区分两类失败:`record === null` 且本来就该立刻派发 → 重开提交面板并带回草稿;
  受理之后才失败 → 不重开面板,只在任务面板收口为失败 + 提示条
- index.tsx:删掉只为旧「在途阶段文案」服务的 `resourceAssetGenerationPanelTaskIdRef` 与成功后的
  自动收面板逻辑(面板此时已关闭)
- 测试:`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 的图片类用例改写为「点击即关闭 +
  面板 DOM 里从不出现 排队中。/正在生成。/提交中…」+「即时失败重开带回草稿与原因」;音频面板用例不变
- 测试:`resourceCanvasBottomToolbar.test.tsx` 的失败重试用例按新语义改写(点击即关闭、重开时显示原因)
- 测试:AppSurface 新增三条(同步关闭且面板无阶段文案、后端未受理时重开并保留草稿、受理后失败不重开
  只在任务面板收口),并把既有生成任务 e2e 的提交步骤改为「点击即关闭」
- 顺带按仓库规范修正以上文件的 eslint import sort 与 prettier 格式(未触碰无关文件)
2026-09-14 18:35:11 +08:00

490 lines
18 KiB
TypeScript

import { describe, expect, test, vi } from 'vitest';
import {
createResourceCanvasAssetGenerationQueue,
mergeResourceCanvasAssetGenerationTasksWithRecords,
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT,
type ResourceCanvasAssetGenerationQueueDeps,
type ResourceCanvasAssetGenerationSettlement,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue';
import {
applyLocalProjectAssetGenerationRecords,
createResourceCanvasAssetGenerationTask,
type LocalProjectAssetGenerationTaskRecord,
nextResourceCanvasAssetGenerationDispatch,
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
resourceCanvasAssetGenerationElapsedLabel,
resourceCanvasAssetGenerationKindLabel,
type ResourceCanvasAssetGenerationTask,
sortResourceCanvasAssetGenerationTasks,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
function localTask(taskId: string, createdAtMillis: number) {
return createResourceCanvasAssetGenerationTask({
taskId,
action: uiPrototypeAction,
prompt: `提示词 ${taskId}`,
assetName: `素材 ${taskId}`,
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: createdAtMillis,
});
}
function record(
taskId: string,
status: string,
extra: Partial<LocalProjectAssetGenerationTaskRecord> = {},
): LocalProjectAssetGenerationTaskRecord {
return {
taskId,
projectId: 'project-1',
kind: 'ui-prototype',
assetName: `素材 ${taskId}`,
status,
phaseDetail: status === 'running' ? '正在生成。' : '排队中。',
createdAtMillis: 1_000,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
...extra,
};
}
describe('生成任务模型', () => {
test('新提交的任务先按本地排队呈现,阶段文案不是后端记录', () => {
const task = localTask('task-a', 10);
expect(task.dispatched).toBe(false);
expect(task.status).toBe('queued');
expect(task.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
expect(task.restored).toBe(false);
});
test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => {
const running = {
...localTask('task-a', 10),
dispatched: true,
status: 'running' as const,
};
const queued = localTask('task-b', 20);
expect(nextResourceCanvasAssetGenerationDispatch([running, queued])).toBe(
null,
);
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'failed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
]),
).toBeNull();
});
test('已派发但还没终态的任务同样挡住队列(不发第二条)', () => {
const dispatched = { ...localTask('task-a', 10), dispatched: true };
expect(
nextResourceCanvasAssetGenerationDispatch([
dispatched,
localTask('task-b', 20),
]),
).toBeNull();
});
test('后端记录刷新本地任务:状态、阶段、时间戳与资源 id 都以后端为准', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10)],
[
record('task-a', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-7',
startedAtMillis: 1_100,
finishedAtMillis: 1_900,
}),
],
);
expect(merged[0]?.status).toBe('completed');
expect(merged[0]?.phaseDetail).toBe('生成已完成。');
expect(merged[0]?.assetId).toBe('asset-7');
expect(merged[0]?.finishedAtMillis).toBe(1_900);
expect(merged[0]?.restored).toBe(false);
});
test('重开项目后按账本恢复历史任务(本地没有草稿也能显示)', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[],
[
record('task-old', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 2_000,
}),
],
);
expect(merged).toHaveLength(1);
expect(merged[0]?.taskId).toBe('task-old');
expect(merged[0]?.restored).toBe(true);
expect(merged[0]?.actionLabel).toBe('生成 UI 设计图');
expect(merged[0]?.status).toBe('completed');
});
test('后端没给过的状态按失败收口,展示顺序最新在前', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10), localTask('task-b', 20)],
[record('task-a', 'weird-status'), record('task-b', 'running')],
);
expect(merged.find((task) => task.taskId === 'task-a')?.status).toBe(
'failed',
);
expect(
sortResourceCanvasAssetGenerationTasks(merged).map((task) => task.taskId),
).toEqual(['task-b', 'task-a']);
});
test('kind 文案从工具栏模型派生,未知 kind 不编造', () => {
expect(resourceCanvasAssetGenerationKindLabel('ui-prototype')).toBe(
'生成 UI 设计图',
);
expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片');
expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull();
});
test('已耗时文案按分秒呈现', () => {
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe(
'1 分 12 秒',
);
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒');
});
});
type Harness = {
deps: ResourceCanvasAssetGenerationQueueDeps;
invoke: ReturnType<typeof vi.fn>;
tasks: () => readonly ResourceCanvasAssetGenerationTask[];
settlements: ResourceCanvasAssetGenerationSettlement[];
/** 只统计生成提交命令;轮询用的读接口不计入「第二条不许发 IPC」。 */
startCallCount: () => number;
advance: (taskId: string, status: 'completed' | 'failed') => void;
forget: (taskId: string) => void;
startError: Map<string, Error>;
};
/**
* 假后端 + 由轮询次数驱动的确定性脚本。
*
* `wait` 在一次轮询之后被调用,所以「第 N 次轮询之后做什么」就是脚本的粒度,不需要计时器。
*/
function createHarness(waitScript?: (pollCount: number, api: Harness) => void) {
const records = new Map<string, LocalProjectAssetGenerationTaskRecord>();
const startError = new Map<string, Error>();
let tasks: ResourceCanvasAssetGenerationTask[] = [];
const settlements: ResourceCanvasAssetGenerationSettlement[] = [];
let pollCount = 0;
const harness: Harness = {
deps: undefined as unknown as ResourceCanvasAssetGenerationQueueDeps,
invoke: undefined as unknown as ReturnType<typeof vi.fn>,
tasks: () => tasks,
settlements,
startCallCount: () =>
harness.invoke.mock.calls.filter(
([command]) => command === 'start_local_project_asset_generation',
).length,
advance: (taskId, status) => {
records.set(
taskId,
record(taskId, status, {
phaseDetail:
status === 'completed' ? '生成已完成。' : '生成失败:远端拒绝',
assetId: status === 'completed' ? `asset-${taskId}` : null,
error: status === 'completed' ? null : '远端拒绝',
finishedAtMillis: 5_000,
}),
);
},
startError,
forget: (taskId) => {
records.delete(taskId);
},
};
harness.invoke = vi.fn(
async (command: string, args: Record<string, unknown>) => {
if (command === 'start_local_project_asset_generation') {
const taskId = String(args.taskId);
const failure = startError.get(taskId);
if (failure) {
throw failure;
}
const created = record(taskId, 'running', { startedAtMillis: 1_200 });
records.set(taskId, created);
return created;
}
if (command === 'list_local_project_asset_generations') {
pollCount += 1;
return [...records.values()];
}
throw new Error(`未预期的命令:${command}`);
},
);
harness.deps = {
invoke: harness.invoke as ResourceCanvasAssetGenerationQueueDeps['invoke'],
projectPath: () => '/tmp/project',
listTasks: () => tasks,
replaceTask: (task) => {
const index = tasks.findIndex((item) => item.taskId === task.taskId);
tasks =
index >= 0
? tasks.map((item) => (item.taskId === task.taskId ? task : item))
: [...tasks, task];
},
onSettled: (settlement) => {
settlements.push(settlement);
},
wait: async () => {
await Promise.resolve();
waitScript?.(pollCount, harness);
},
nowMillis: () => 9_999,
};
return harness;
}
describe('本地排队驱动器', () => {
test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => {
let assertedMidFlight = false;
const harness = createHarness((pollCount, api) => {
if (pollCount === 2 && !assertedMidFlight) {
assertedMidFlight = true;
// 第一条还在途:第二条必须停在本地队列里。
expect(api.startCallCount()).toBe(1);
const second = api.tasks().find((task) => task.taskId === 'task-b');
expect(second?.status).toBe('queued');
expect(second?.dispatched).toBe(false);
expect(second?.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
api.advance('task-a', 'completed');
}
if (pollCount === 4) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await first;
await second;
expect(assertedMidFlight).toBe(true);
// 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。
expect(harness.startCallCount()).toBe(2);
expect(
harness.settlements.map((item) => [item.taskId, item.status]),
).toEqual([
['task-a', 'completed'],
['task-b', 'completed'],
]);
expect(harness.tasks().map((task) => [task.taskId, task.status])).toEqual([
['task-a', 'completed'],
['task-b', 'completed'],
]);
});
test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条已经派发,此刻收口为失败。
api.advance('task-a', 'failed');
}
if (pollCount === 3) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('远端拒绝');
await expect(second).resolves.toBeUndefined();
expect(harness.startCallCount()).toBe(2);
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe('生成失败:远端拒绝');
expect(failed?.error).toBe('远端拒绝');
expect(failed?.finishedAtMillis).toBe(5_000);
expect(
harness.tasks().find((task) => task.taskId === 'task-b')?.assetId,
).toBe('asset-task-b');
});
test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条的记录被账本淘汰 / 项目被换掉:从这一轮起它再也查不到。
harness.startError.set('task-a', new Error('never-used'));
api.forget('task-a');
}
if (pollCount === 20) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('生成任务账本里已找不到这条任务');
await second;
expect(harness.startCallCount()).toBe(2);
});
test('生成提交 IPC 抛错时任务收口为失败,队列不会卡死', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 2) {
api.advance('task-b', 'completed');
}
});
harness.startError.set(
'task-a',
new Error('项目权限策略拒绝执行:canvas.asset_generate'),
);
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('项目权限策略拒绝执行');
await second;
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe(
'生成失败:项目权限策略拒绝执行:canvas.asset_generate',
);
// 提交失败的这一条没有终态记录也要能收口,否则第二条永远补发不出去。
expect(harness.startCallCount()).toBe(2);
expect(harness.settlements[0]).toMatchObject({
taskId: 'task-a',
status: 'failed',
record: null,
});
});
});
describe('非预期 IPC 形状 / 失败下的健壮性', () => {
test('账本读回 undefined / 非数组 / 垃圾条目时不抛错,本地任务列表保持原样', () => {
const tasks = [localTask('task-a', 10)];
const unexpectedValues: unknown[] = [
undefined,
null,
'not-an-array',
42,
{ tasks: [] },
[null, 7, 'x', {}, { taskId: '' }],
];
for (const unexpected of unexpectedValues) {
let merged: ResourceCanvasAssetGenerationTask[] = [];
expect(() => {
merged = applyLocalProjectAssetGenerationRecords(
tasks,
unexpected as readonly LocalProjectAssetGenerationTaskRecord[],
);
}).not.toThrow();
expect(merged).toHaveLength(1);
expect(merged[0]?.taskId).toBe('task-a');
expect(merged[0]?.status).toBe('queued');
}
});
test('轮询返回 undefined(旧壳 / 命令未注册)时不产生未处理拒绝,按上限收口为失败', async () => {
const harness = createHarness();
harness.invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'start_local_project_asset_generation') {
return record(String(args?.taskId), 'running');
}
if (command === 'list_local_project_asset_generations') {
// 旧壳 / 命令未注册:返回 undefined 而不是数组。
return undefined;
}
throw new Error(`未预期的命令:${command}`);
},
);
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const settled = queue.submit(localTask('task-a', 10));
await expect(settled).rejects.toThrow('生成任务状态读取失败');
// 本地任务不被清空,只是按上限收口为失败并带上原因。
const local = harness.tasks().find((item) => item.taskId === 'task-a');
expect(local?.status).toBe('failed');
expect(local?.phaseDetail).toContain('生成任务状态读取失败');
expect(local?.phaseDetail).toContain('不是数组');
expect(harness.settlements[0]).toMatchObject({
taskId: 'task-a',
status: 'failed',
record: null,
});
});
test('轮询 IPC 直接 reject 时同样被接住,收口为失败后继续放行排队任务', async () => {
const harness = createHarness();
let listCalls = 0;
harness.invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'start_local_project_asset_generation') {
return record(String(args?.taskId), 'running');
}
if (command === 'list_local_project_asset_generations') {
listCalls += 1;
if (
listCalls <=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
throw new Error('项目权限策略拒绝执行:asset.read');
}
return [record('task-b', 'completed', { assetId: 'asset-task-b' })];
}
throw new Error(`未预期的命令:${command}`);
},
);
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow(
'生成任务状态读取失败,已停止等待:项目权限策略拒绝执行:asset.read',
);
await expect(second).resolves.toBeUndefined();
expect(harness.startCallCount()).toBe(2);
});
});