Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
T
suzmii 13b28ebbc7 生成占位与卡下独立浮层,修复引用选择被焦点陷阱阻断
工具点击先在当前栏目创建临时占位卡,生成浮层挂在占位下沿;图片、音效、背景音乐入口一致
占位按项目与草稿 ID 归属宿主临时状态:可拖动、提交绑定任务 ID、成功结果落到占位最新位置
删除占位只隐藏展示,关闭浮层不冒充取消后台任务;失败保留占位与输入引用供重试
生成面板改为非模态浮层:ThemedModal 焦点陷阱曾阻断 portal 到 body 的引用选择器,点选不生效
引用选择器定位加顶边钳制,搜索收窄与键盘选择可用;不再越出视口
参考候选与陈旧引用校验排除 SVG(原生解码不支持),其余 image 类型不额外收窄
任务创建处对参考资产 ID 去重;补浮层真实组件整合、占位模型与拖动、参考模型回归测试
2026-09-17 18:22:39 +08:00

538 lines
19 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);
// 没有选择参考图时就是空列表:原生侧按「没有参考」处理,前端不编造引用。
expect(task.referenceAssetIds).toEqual([]);
});
test('参考图资产 ID 去重后随任务保存,重开项目恢复出来的历史任务不带参考选择', () => {
const task = createResourceCanvasAssetGenerationTask({
taskId: 'task-ref',
action: uiPrototypeAction,
prompt: '主界面',
assetName: 'UI',
aspectRatio: '16:9',
imageSize: '1K',
referenceAssetIds: ['asset-a', ' asset-b ', 'asset-a', ''],
outputPath: null,
projectId: 'project-1',
nowMillis: 10,
});
expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b']);
const restored = applyLocalProjectAssetGenerationRecords(
[],
[record('task-ref', 'completed')],
);
expect(restored).toHaveLength(1);
expect(restored[0]?.referenceAssetIds).toEqual([]);
});
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('派发把参考图资产 ID 原样交给原生侧', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
api.advance('task-ref', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
await queue.submit({
...localTask('task-ref', 10),
referenceAssetIds: ['asset-a', 'asset-b'],
});
const startCall = harness.invoke.mock.calls.find(
([command]) => command === 'start_local_project_asset_generation',
);
expect(startCall?.[1]).toMatchObject({
taskId: 'task-ref',
// 只传当前 manifest 的资产 ID:不传本地路径,也不传历史远端 ID。
referenceAssetIds: ['asset-a', 'asset-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);
});
});