14a0286f70
- 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 格式(未触碰无关文件)
490 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|