bb7cbae118
- Rust 新增 asset_generation_tasks.rs:图片类生成拆成「提交即返回任务记录 + 后台 spawn 生成」, 生成本身仍转调既有 generate_platform_art_asset_with_options_at(不复制生成 / 幂等 / 登记逻辑) - 任务账本落项目内 .agent/runtime/asset-generation-tasks/tasks.json(复用 agent runtime sidecar 原语), 阶段文案 phaseDetail 由后端拥有,读取时把上次运行留下的非终态记录收口为「中断失败」,账本上限 50 条 - Rust 新增 IPC start_local_project_asset_generation / list_local_project_asset_generations 并在 main.rs 注册; 同步命令 generate_local_project_asset 保留不动 - 新增前端纯模型 resourceCanvasAssetGenerationTaskModel.ts:任务状态机、在途判据、账本恢复、耗时文案 - 新增 resourceCanvasAssetGenerationQueue.ts:本地排队驱动器,第二条不发提交 IPC、前一条终态后自动补发, 状态与阶段一律以后端账本为准,账本查不到该任务时按上限停止等待 - 两块生成浮层在提交期间放开 × / 遮罩 / Esc,次按钮在途时改为「后台运行并关闭」(关闭不等于取消请求), 图片类面板的在途文案改为渲染后端 phaseDetail - 新增非模态「生成任务」面板(不铺遮罩 / 不做焦点陷阱 / 不进模态遮挡判据),显示状态、后端阶段、 已耗时与素材名,已完成条目复用 pendingResourceFocusRef 聚焦链定位素材卡 - index.tsx 宿主接线:任务列表状态与 ref、提交回调不再读面板状态、按项目恢复任务账本、 工具栏「生成任务」入口按钮与面板渲染点 - check-config.mjs:把已无前端调用方的同步命令 generate_local_project_asset 登记进 native-only 白名单 - 测试:新增「生成面板提交期间可关闭」「本地排队驱动器」「生成任务面板」三组用例; appSurface 生成载荷断言改为 start_local_project_asset_generation + list 轮询桩 - 文档:入口矩阵补 §4a 与验证命令、V3 验收 S11a 判据、decision-log 记录本次决策
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
createResourceCanvasAssetGenerationQueue,
|
|
mergeResourceCanvasAssetGenerationTasksWithRecords,
|
|
type ResourceCanvasAssetGenerationQueueDeps,
|
|
type ResourceCanvasAssetGenerationSettlement,
|
|
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue';
|
|
import {
|
|
createResourceCanvasAssetGenerationTask,
|
|
nextResourceCanvasAssetGenerationDispatch,
|
|
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
|
|
type LocalProjectAssetGenerationTaskRecord,
|
|
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,
|
|
});
|
|
});
|
|
});
|