diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx new file mode 100644 index 000000000..a2371b674 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx @@ -0,0 +1,461 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import { + act, + cleanup, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, +} from './appSurface/harness'; + +/** + * 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。 + * + * 覆盖三件事——它们都在宿主里(占位、提交身份、落点 effect),单测模型或组件看不到: + * 1. 音频 / 背景音乐入口:点工具立刻出占位,提交后那张占位进入 `submitted`; + * 2. 失败后用同一份请求重试:复用**同一个 operationId / 幂等键**,不会变成新的付费生成; + * 3. 成功后结果卡落到占位**最新位置**、占位被撤掉(结果接管它的位置)。 + * + * 原生命令全部走本文件的假实现,不触发任何真实 Provider 调用。 + */ + +const PROJECT_ID = 'generation-host-project'; +const PROJECT_PATH = '/tmp/generation-host-project'; +const NEW_ASSET_ID = 'asset-bgm-1'; +const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; + +type AssetFixture = GameCreationAppAssetManifestEntry; +type LayoutWrite = { + projectPath: string; + mode: string; + positions: ProjectResourceCanvasPosition[]; +}; + +function imageAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function bgmAsset(id: string): AssetFixture { + return { + id, + kind: 'background-music', + category: 'audio', + mediaType: 'audio/mpeg', + localPath: 'assets/bgm.mp3', + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function seedBgmAsset(id: string): AssetFixture { + return { ...bgmAsset(id), localPath: `assets/${id}.mp3` }; +} + +function manifestFor( + projectId: string, + assets: AssetFixture[], +): GameCreationAppManifest { + return { + ...createGameCreationAppManifest(projectId, `${projectId} 项目`), + assets: assets.map((asset) => structuredClone(asset)), + }; +} + +/** + * 只铺这条链路真正用到的本地命令;未知命令返回 `undefined` 并记账(不抛错), + * 免得画布里别的入口多调一个命令就把这组用例整体带红。 + */ +function installHostTauri(options: { + assets: AssetFixture[]; + deriveResults: Array< + | { ok: true; assetId: string; hold?: boolean } + | { ok: false; message: string } + >; +}) { + const layoutWrites: LayoutWrite[] = []; + const deriveCalls: Array> = []; + const unexpectedCommands: string[] = []; + let revision = 0; + let releaseHeldDerive: (() => void) | null = null; + const positions: ProjectResourceCanvasPosition[] = []; + + const invoke = vi.fn(async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 1 }; + } + if (command === 'read_local_project_resource_graph') { + return { + nodes: [], + taskFlowIds: [], + producerAssignments: [], + dependencyDepths: options.assets.map((asset) => ({ + resourceId: `asset:${asset.id}`, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: structuredClone(positions), + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const next = structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ); + positions.length = 0; + positions.push(...next); + revision += 1; + layoutWrites.push({ + projectPath: String(args?.projectPath ?? ''), + mode: String(args?.mode ?? ''), + positions: next, + }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: next, + updatedAt: revision, + }, + }; + } + if (command === 'derive_local_project_resource') { + const input = args?.input as Record; + deriveCalls.push(input); + const planned = options.deriveResults.shift(); + if (!planned || !planned.ok) { + throw new Error(planned?.message ?? '测试:没有预置生成结果'); + } + // `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。 + if (planned.hold) { + await new Promise((resolve) => { + releaseHeldDerive = resolve; + }); + } + const asset = bgmAsset(planned.assetId); + return { + asset, + manifest: manifestFor(PROJECT_ID, [...options.assets, asset]), + committedProjectRevision: 2, + operationId: input.operationId, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + return []; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 2, + content: '#', + }; + } + unexpectedCommands.push(command); + return undefined; + }); + + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { + invoke, + layoutWrites, + deriveCalls, + unexpectedCommands, + releaseHeldDerive: () => releaseHeldDerive?.(), + }; +} + +function HostWorkbench({ assets }: { assets: AssetFixture[] }) { + const [manifest, setManifest] = React.useState(() => + manifestFor(PROJECT_ID, assets), + ); + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function placeholderElement(draftId: string) { + return document.querySelector( + `[data-resource-canvas-generation-placeholder="${draftId}"]`, + ); +} + +function allPlaceholders() { + return Array.from( + document.querySelectorAll( + '[data-resource-canvas-generation-placeholder]', + ), + ); +} + +/** + * 音频入口只在「音频」栏目页出现(`RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY.audio`), + * 所以先打开音频栏目、再点「生成背景音乐」:占位立刻出现在该栏目里。 + */ +async function openBgmEntry() { + const opener = screen + .getAllByRole('button') + .find((button) => /^打开音频/.test(button.textContent?.trim() ?? '')); + if (!opener) { + throw new Error( + `没找到音频栏目入口,现有入口:${screen + .getAllByRole('button') + .map((button) => button.textContent?.trim()) + .filter((text) => text?.startsWith('打开')) + .join(' / ')}`, + ); + } + fireEvent.click(opener); + await settle(); + // 切到「按类型」:落点 effect 走的是**当前排序模式**那一侧的布局 hook, + // 类型侧的 sidecar 读完之后才 ready(依赖侧还要等关系图就绪)。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '生成背景音乐' })); + await settle(); + const placeholder = allPlaceholders()[0]; + if (!placeholder) { + throw new Error('点音频入口后没有出现占位卡'); + } + const draftId = + placeholder.dataset.resourceCanvasGenerationPlaceholder ?? ''; + const prompt = document.querySelector( + 'textarea', + ) as HTMLTextAreaElement; + if (prompt) { + fireEvent.change(prompt, { target: { value: '一段平静的夜晚钢琴曲' } }); + } + return { draftId, placeholder }; +} + +/** + * 占位卡在画布世界坐标里的落点。宿主不是把它写进 DOM 数据集,而是由外层包装的 + * transform 决定位置——按 ManualLayout 用例的同一口径从 style 里取数。 + */ +function placeholderWorldPoint(element: HTMLElement) { + const styled = element.closest('[style*="translate"]'); + const source = styled?.style.transform ?? ''; + const [x, y] = Array.from( + source.matchAll(/-?\d+(?:\.\d+)?/g), + (match) => Number(match[0]), + ); + return { x, y }; +} + +function cardElement(resourceId: string) { + return document.querySelector( + `.game-resource-card[data-resource-card-id="${resourceId}"]`, + ); +} + +/** + * 占位的**局部落点**由占位模型给定:这一栏已有素材(种子卡在原点)时,占位排到它下方 + * 一行(`placeResourceCanvasGenerationPlaceholder`:x 贴左边、y = 最高下沿 + 间距)。 + * + * 尺寸从种子卡画出来的盒子里读,避免在用例里再抄一份卡片尺寸常量。 + */ +function placeholderLocalPoint(placeholder: HTMLElement) { + const seed = cardElement('asset:seed-bgm'); + if (!seed) { + throw new Error('没找到音频栏目里的种子卡'); + } + const wrapper = seed.closest('[style*="translate"]'); + const style = wrapper?.getAttribute('style') ?? ''; + const height = Number( + style.match(/height:\s*(-?\d+(?:\.\d+)?)px/)?.[1] ?? Number.NaN, + ); + if (!Number.isFinite(height)) { + throw new Error(`种子卡没有可读的高度:${style}`); + } + expect(placeholder).not.toBeNull(); + return { x: 0, y: height + 16 }; +} + +/** + * 面板里的提交按钮:它与底部工具栏的工具按钮**同名**(都是「生成背景音乐」), + * 所以要排掉工具栏那一支,否则点的是工具本身(再点一次入口,不会提交)。 + */ +function panelSubmitButton(label: string) { + const submit = screen + .getAllByRole('button', { name: label }) + .find((button) => !button.closest('.game-resource-bottom-toolbar')); + if (!submit) { + throw new Error(`没找到面板里的提交按钮「${label}」`); + } + return submit; +} + +afterEach(() => { + cleanup(); + delete ( + window as unknown as { __TAURI__?: unknown } + ).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('画布生成入口的宿主生命周期', () => { + test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [{ ok: true, assetId: NEW_ASSET_ID, hold: true }], + }); + render(); + await settle(); + + const { draftId, placeholder } = await openBgmEntry(); + const placeholderPoint = placeholderLocalPoint(placeholder); + // 点入口只造占位:还没提交,也没发起任何生成。 + expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe( + 'draft', + ); + expect(tauri.deriveCalls).toEqual([]); + + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + + // 提交一次:走无源生成(create)链路;后台还在跑,占位收口为 submitted。 + expect(tauri.deriveCalls).toHaveLength(1); + expect(tauri.deriveCalls[0]).toMatchObject({ + editKind: 'background-music', + generationMode: 'create', + expectedProjectId: PROJECT_ID, + }); + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('submitted'), + ); + + // 放行后台任务:结果入库。 + await act(async () => { + tauri.releaseHeldDerive(); + await Promise.resolve(); + }); + + // 结果入库后:新卡落在占位坐标上(占位在空栏目里落在原点),占位被撤掉。 + await waitFor(() => { + expect( + tauri.layoutWrites + .filter((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ) + .map( + (write) => { + const landed = write.positions.find( + (position) => position.resourceId === NEW_RESOURCE_ID, + )!; + return `${write.mode}:${landed.section}@${landed.x},${landed.y}${ + landed.manuallyPlaced ? 'M' : 'A' + }`; + }, + ) + .join(' || '), + ).toContain( + `type:audio@${placeholderPoint.x},${placeholderPoint.y}M`, + ); + }); + await waitFor(() => expect(allPlaceholders()).toHaveLength(0)); + }); + + test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [ + { ok: false, message: 'result-unknown: 测试网络中断' }, + { ok: true, assetId: NEW_ASSET_ID }, + ], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + expect(tauri.deriveCalls).toHaveLength(1); + // 失败:占位留着(输入与操作身份都还在),状态收口为 failed。 + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 面板上的原请求重试:同一次生成,不该变成第二次付费请求。 + const retry = screen + .getAllByRole('button') + .find((button) => button.textContent?.includes('重试')); + expect(retry).toBeDefined(); + fireEvent.click(retry!); + await settle(); + + await waitFor(() => expect(tauri.deriveCalls).toHaveLength(2)); + expect(tauri.deriveCalls[1]).toMatchObject({ + operationId: tauri.deriveCalls[0]!.operationId, + idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey, + prompt: tauri.deriveCalls[0]!.prompt, + }); + }); +});