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 { 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; tasks: () => readonly ResourceCanvasAssetGenerationTask[]; settlements: ResourceCanvasAssetGenerationSettlement[]; /** 只统计生成提交命令;轮询用的读接口不计入「第二条不许发 IPC」。 */ startCallCount: () => number; advance: (taskId: string, status: 'completed' | 'failed') => void; forget: (taskId: string) => void; startError: Map; }; /** * 假后端 + 由轮询次数驱动的确定性脚本。 * * `wait` 在一次轮询之后被调用,所以「第 N 次轮询之后做什么」就是脚本的粒度,不需要计时器。 */ function createHarness(waitScript?: (pollCount: number, api: Harness) => void) { const records = new Map(); const startError = new Map(); let tasks: ResourceCanvasAssetGenerationTask[] = []; const settlements: ResourceCanvasAssetGenerationSettlement[] = []; let pollCount = 0; const harness: Harness = { deps: undefined as unknown as ResourceCanvasAssetGenerationQueueDeps, invoke: undefined as unknown as ReturnType, 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) => { 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) => { 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) => { 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); }); });