/** @vitest-environment jsdom */ import userEvent from '@testing-library/user-event'; 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, within, } from './appSurface/harness'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; /** * 画布生成入口的**宿主生命周期**验收(真实 `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[]; /** * 后台生成账本(`start_local_project_asset_generation`)的收口方式。 * * 不传时任务停在**运行中**,由用例调 `completeAssetGeneration` 收口为完成(用来观察占位的 * `submitted` 中间态与结果落点);`'failed'` 让队列在第一次轮询就看到一条失败记录,用来观察 * 失败态与重试。 */ assetGenerationRecord?: 'failed'; /** * 提交那一刻就被拒绝(后端从未受理):`start_local_project_asset_generation` 抛这个原因。 * * 用例可以中途改 `assetGenerationStartError.current`(改成 `undefined` 表示这次真受理了), * 所以「未受理」与「已受理」两条路能在同一条用例里对照。 */ assetGenerationStartError?: string; /** * 让**第一次** `start_local_project_asset_generation` 挂在半空(受理前)。 * * 用来复现「两条音频提交重叠」:第一条还在等受理时提交第二条,第二条会排在本地队列里。 * 用例用返回的 `releaseHeldAssetGenerationStart()` 决定第一条什么时候收到结果。 */ holdFirstAssetGenerationStart?: boolean; }) { const layoutWrites: LayoutWrite[] = []; const deriveCalls: Array> = []; const assetGenerationStarts: Array> = []; const audioTaskRecords = new Map>(); /** 后台任务登记进清单的素材:完成收口时补进来,`get_local_game_manifest` 才读得到。 */ const registeredAssets: AssetFixture[] = []; const unexpectedCommands: string[] = []; let revision = 0; const assetGenerationStartError = { current: options.assetGenerationStartError, }; let releaseHeldStart: (() => 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') { // 关系图按**当前清单**(`resources` 入参)派生:后台生成落进清单的素材也要在图上。 const resources = (args?.resources as Array<{ resourceId: string }> | undefined) ?? []; return { nodes: [], taskFlowIds: [], producerAssignments: [], dependencyDepths: resources.map((resource) => ({ resourceId: resource.resourceId, dependencyDepth: 0, })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], producerMappingTruncated: false, }; } if (command === 'get_local_game_manifest') { return manifestFor(PROJECT_ID, [ ...options.assets, ...registeredAssets, ]); } 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') { /* 音频与图片类都走后台任务账本:这条同步派生通道不该再被生成入口触发。真被调到就大声 失败,而不是静默返回一个像模像样的结果。 */ deriveCalls.push((args?.input as Record) ?? {}); throw new Error('生成入口不应再走同步派生通道'); } if (command === 'list_pending_local_project_resource_edits') { return []; } if (command === 'start_local_project_asset_generation') { const startArgs = args ?? {}; const isFirstStart = assetGenerationStarts.length === 0; assetGenerationStarts.push(structuredClone(startArgs)); if (options.holdFirstAssetGenerationStart && isFirstStart) { await new Promise((resolve) => { releaseHeldStart = resolve; }); } if (assetGenerationStartError.current) { throw new Error(assetGenerationStartError.current); } const task = { taskId: String(startArgs.taskId ?? ''), projectId: PROJECT_ID, kind: String(startArgs.kind ?? ''), assetName: String(startArgs.assetName ?? ''), referenceAssetIds: Array.isArray(startArgs.referenceAssetIds) ? startArgs.referenceAssetIds : [], status: 'running', phaseDetail: '已受理', createdAtMillis: 1, startedAtMillis: 1, finishedAtMillis: null, assetId: null, error: null, }; audioTaskRecords.set(task.taskId, task); return task; } if (command === 'list_local_project_asset_generations') { const records = [...audioTaskRecords.values()]; if (options.assetGenerationRecord !== 'failed') { return records; } // 「后端已受理、之后才失败」这一路:面板不再被带回来,原因留在占位那一侧。 return records.map((task) => ({ ...task, status: 'failed', phaseDetail: '生成失败:测试拒绝', finishedAtMillis: 2, error: '测试:生成失败', })); } 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, assetGenerationStarts, assetGenerationStartError, releaseHeldAssetGenerationStart: () => releaseHeldStart?.(), unexpectedCommands, /** * 后台任务收口为完成:账本里那条记录变成 `completed` + `assetId`,素材同时进清单。 * * 真实链路里这两件事都由 Rust 后台任务写(先登记 manifest 再写终态),这里只复刻结果。 * 队列下一次轮询(默认 2 秒一次)就会读到终态并把结果落到占位上。 */ completeAssetGeneration: (asset: AssetFixture) => { for (const [taskId, task] of audioTaskRecords) { audioTaskRecords.set(taskId, { ...task, status: 'completed', phaseDetail: '生成已完成。', finishedAtMillis: 2, assetId: asset.id, error: null, }); } registeredAssets.push(asset); }, }; } 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)} /> ); } /** * 带「从清单里删掉某个素材」按钮的宿主:失效参考的判据必须在**素材真的不在清单里**之后成立, * 所以用例要能在渲染过程中推进一次 manifest(与用户在素材管理里删除同一条路径)。 */ function HostWorkbenchWithReferenceRemoval({ assets, referenceAssetId, }: { assets: AssetFixture[]; referenceAssetId: string; }) { 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; } /** 当前挂着的生成浮层(音频与图片类共用同一个数据属性)。 */ function floatingPanel() { return document.querySelector( '[data-resource-canvas-generation-floating-panel]', ); } function floatingPanelPrompt() { return ( floatingPanel()?.querySelector('textarea') ?? null ); } /** 浮层里的提交按钮:不按文案挑,音频失败态会换成「使用原请求重试」。 */ function floatingPanelSubmit() { const submit = floatingPanel()?.querySelector( 'button[type="submit"]', ); if (!submit) { throw new Error('没找到浮层里的提交按钮'); } return submit; } /** 浮层里存在某文案的按钮吗(工具栏上的同名按钮要排掉)。 */ function floatingPanelHasButton(label: string) { const panel = floatingPanel(); if (!panel) { return false; } return Array.from(panel.querySelectorAll('button')).some( (button) => button.textContent?.trim() === label, ); } /** 底部工具栏上的工具按钮(与浮层里的同名提交按钮区分开)。 */ function toolbarToolButton(label: string) { const button = screen .getAllByRole('button', { name: label }) .find((candidate) => candidate.closest('.game-resource-bottom-toolbar')); if (!button) { throw new Error(`没找到工具栏上的工具「${label}」`); } return button; } /** 打开某个资源栏目:点总览卡入口、切到「按类型」,布局 hook 就绪后工具栏才出现。 */ async function openResourceCategory(entryLabel: string) { const opener = screen .getAllByRole('button') .find( (button) => /^打开/.test(button.textContent?.trim() ?? '') && button.textContent?.includes(entryLabel), ); if (!opener) { throw new Error(`没找到栏目入口「${entryLabel}」`); } fireEvent.click(opener); await settle(); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await settle(); } /** 点击音频栏目的某条工具:占位与浮层都由这一个动作产生。 */ async function openAudioTool(toolLabel: string) { fireEvent.click(toolbarToolButton(toolLabel)); await settle(); } afterEach(() => { cleanup(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; vi.restoreAllMocks(); }); describe('画布生成入口的宿主生命周期', () => { test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], }); render(); await settle(); const { draftId, placeholder } = await openBgmEntry(); const placeholderPoint = placeholderLocalPoint(placeholder); // 点入口只造占位:还没提交,也没发起任何生成。 expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe( 'draft', ); expect(tauri.assetGenerationStarts).toEqual([]); fireEvent.click(panelSubmitButton('生成背景音乐')); await settle(); /* 提交一次:音频与图片类走**同一条命令**(`start_local_project_asset_generation`),载荷只有 「任务 id = operation id」+ 幂等键,没有图片类那套比例 / 尺寸 / 参考 / 落点参数。 */ expect(tauri.assetGenerationStarts).toHaveLength(1); const musicStart = tauri.assetGenerationStarts[0]!; expect(musicStart).toMatchObject({ projectPath: PROJECT_PATH, projectId: PROJECT_ID, kind: 'background-music', prompt: '一段平静的夜晚钢琴曲', assetName: '新背景音乐', }); expect(Object.keys(musicStart).sort()).toEqual([ 'assetName', 'idempotencyKey', 'kind', 'projectId', 'projectPath', 'prompt', 'taskId', ]); expect(String(musicStart.taskId)).toMatch(/^[0-9a-f-]{36}$/); expect(String(musicStart.idempotencyKey)).toMatch(/^[0-9a-f-]{36}$/); // 音频不再走同步派生通道。 expect(tauri.deriveCalls).toEqual([]); // 后台还在跑:占位收口为 submitted。 await waitFor(() => expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('submitted'), ); // 后台任务收口:账本记完成并登记素材(真实链路里由 Rust 任务写这两件事)。 await act(async () => { tauri.completeAssetGeneration(bgmAsset(NEW_ASSET_ID)); 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`); }, { timeout: 5_000 }, ); await waitFor(() => expect(allPlaceholders()).toHaveLength(0)); }, 20_000); test('未受理的即时失败:面板连原草稿与原请求身份自动带回来', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], assetGenerationStartError: '项目权限策略拒绝执行:asset.register', }); render(); await settle(); const { draftId } = await openBgmEntry(); fireEvent.click(panelSubmitButton('生成背景音乐')); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); /* 后端**从未受理**:这次输入没有被任务接走,所以占位留在画布上,面板连同原草稿与原请求 身份一起被带回来——用户改完可以直接重试,而不会丢掉刚写的东西。 */ await waitFor(() => expect(floatingPanel()).not.toBeNull()); expect(floatingPanel()?.textContent ?? '').toContain( '项目权限策略拒绝执行:asset.register', ); expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'); // 同一张占位再提交:同一对 operation / 幂等键——这是同一次生成的重试,不是第二次付费请求。 tauri.assetGenerationStartError.current = undefined; fireEvent.click(floatingPanelSubmit()); await settle(); await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2)); expect(tauri.assetGenerationStarts[1]).toMatchObject({ taskId: tauri.assetGenerationStarts[0]!.taskId, idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey, kind: 'background-music', prompt: '一段平静的夜晚钢琴曲', }); // 这次后端受理了:面板同步关闭且**不再**被带回来(重开只属于「从未受理」)。 await waitFor(() => expect(floatingPanel()).toBeNull()); }, 20_000); test('两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], assetGenerationStartError: '项目权限策略拒绝执行:asset.register', holdFirstAssetGenerationStart: true, }); render(); await settle(); // 第一条:背景音乐。派发挂在「受理」上,队列还没拿到它的终局。 const { draftId } = await openBgmEntry(); fireEvent.click(panelSubmitButton('生成背景音乐')); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); // 第二条:音效(另一张占位)。第一条还在途,所以它排在本地队列里,还没有发 IPC。 await openAudioTool('生成音效'); fireEvent.change( floatingPanel()?.querySelector('textarea') as HTMLTextAreaElement, { target: { value: '木门缓慢推开的吱呀声' } }, ); fireEvent.click(panelSubmitButton('生成音效')); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); /* 放开第一条:它这次从未被受理。提交上下文按**任务 id** 各自留存,所以后来的第二条不许把 第一条的恢复上下文挤掉——面板必须连原草稿回到第一张占位。 */ await act(async () => { tauri.releaseHeldAssetGenerationStart(); await Promise.resolve(); }); await waitFor(() => expect(floatingPanel()).not.toBeNull()); expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'); }, 20_000); test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], assetGenerationRecord: 'failed', }); render(); await settle(); const { draftId } = await openBgmEntry(); fireEvent.click(panelSubmitButton('生成背景音乐')); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); // 失败:占位留着(输入与任务身份都还在),状态收口为 failed。 await waitFor(() => expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'), ); // 提交即关闭:面板不留在屏幕上等结果。 expect(floatingPanel()).toBeNull(); // 点占位把面板带回来:失败原因是**那张占位**的,重试仍是同一次生成。 fireEvent.click(placeholderElement(draftId)!); await settle(); expect(floatingPanel()?.textContent ?? '').toContain('测试:生成失败'); fireEvent.click(floatingPanelSubmit()); await settle(); await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2)); expect(tauri.assetGenerationStarts[1]).toMatchObject({ taskId: tauri.assetGenerationStarts[0]!.taskId, idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey, kind: 'background-music', prompt: tauri.assetGenerationStarts[0]!.prompt, }); }, 20_000); }); /** * 浮层**按占位隔离**的宿主回归。 * * 浮层是非模态的:面板开着时用户仍能点另一条工具、另一张占位卡。这组用例钉住三件在宿主里才能 * 看到的事: * 1. 换到另一张占位必须换一份面板状态(类型、输入、失败原因都不跟着走),而**未提交草稿必须留下**; * 2. 失败请求的操作身份只属于它自己那张占位:在别的占位上提交是一次新请求,回到原占位才是重试; * 3. 参考素材被删掉后重试:失效参考必须显式呈现并挡住提交,不许静默丢参考、也不许再发一次请求。 */ describe('生成浮层按占位隔离(宿主回归)', () => { test('同类不同草稿:切到另一条音频工具是新面板,切回来原草稿还在', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], }); render(); await settle(); const { draftId } = await openBgmEntry(); expect(floatingPanelHasButton('生成背景音乐')).toBe(true); expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); // 面板开着直接点另一条音频工具:这是另一张占位,必须是另一份面板状态。 await openAudioTool('生成音效'); expect(floatingPanelHasButton('生成音效')).toBe(true); expect(floatingPanelHasButton('生成背景音乐')).toBe(false); expect(floatingPanelPrompt()?.value ?? '').toBe(''); // 全程只点入口与输入,没有任何生成请求被发起。 expect(tauri.deriveCalls).toEqual([]); // 切回第一张占位:未提交草稿不能因为面板卸载而丢。 fireEvent.click(placeholderElement(draftId)!); await settle(); expect(floatingPanelHasButton('生成背景音乐')).toBe(true); expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); }); test('失败态切换:失败原因与任务身份都不跟着换占位', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], assetGenerationRecord: 'failed', }); render(); await settle(); const { draftId } = await openBgmEntry(); fireEvent.click(floatingPanelSubmit()); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); // 提交即关面板:失败由占位收口,原因留在那张占位上。 await waitFor(() => expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'), ); expect(floatingPanel()).toBeNull(); fireEvent.click(placeholderElement(draftId)!); await settle(); expect(floatingPanel()?.textContent ?? '').toContain('测试:生成失败'); // 切到音效:上一条的失败原因不得跟着它走,输入也不该被锁。 await openAudioTool('生成音效'); expect(floatingPanel()?.textContent ?? '').not.toContain('测试:生成失败'); expect(floatingPanelPrompt()?.disabled).toBe(false); expect(floatingPanelPrompt()?.value ?? '').toBe(''); // 在音效面板提交:这是**新的**请求,不能复用背景音乐那条失败请求的任务身份。 await typeGenerationPrompt(floatingPanel()!, '一段清脆的铃声'); fireEvent.click(floatingPanelSubmit()); await settle(); await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2)); expect(tauri.assetGenerationStarts[1]!.taskId).not.toBe( tauri.assetGenerationStarts[0]!.taskId, ); expect(tauri.assetGenerationStarts[1]).toMatchObject({ kind: 'sound-effect', }); // 回到原占位重试:必须复用原任务的操作身份与幂等键(同一 operation 账本)。 fireEvent.click(placeholderElement(draftId)!); await settle(); fireEvent.click(floatingPanelSubmit()); await settle(); await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(3)); expect(tauri.assetGenerationStarts[2]).toMatchObject({ taskId: tauri.assetGenerationStarts[0]!.taskId, idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey, prompt: tauri.assetGenerationStarts[0]!.prompt, }); }); test('失效参考:保留原 ID 并挡住重试,不静默丢参考也不发新请求', async () => { const user = userEvent.setup(); const tauri = installHostTauri({ assets: [imageAsset('ref-img', 'ref.png')], assetGenerationRecord: 'failed', }); render( , ); await settle(); await openResourceCategory('角色'); fireEvent.click(toolbarToolButton('生成图片')); await settle(); const draftId = allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; expect(draftId).not.toBe(''); // 真选一张参考图:候选来自当前清单。 await typeGenerationPrompt(floatingPanel()!, '画一只猫'); const panel = floatingPanel()!; await user.click( within(panel).getByRole('button', { name: '插入素材引用' }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); await user.click(within(picker).getByRole('option', { name: /ref/ })); await user.click(within(picker).getByRole('button', { name: '插入引用' })); await waitFor(() => expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), ); fireEvent.click(floatingPanelSubmit()); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); expect(tauri.assetGenerationStarts[0]!.referenceAssetIds).toEqual([ 'ref-img', ]); await waitFor(() => expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'), ); // 参考素材被删掉之后重试:失效参考必须留在草稿里并被显式报出来。 fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); await settle(); fireEvent.click(placeholderElement(draftId)!); await settle(); const problem = floatingPanel()?.querySelector( '[data-resource-canvas-generation-reference-problem]', ); expect(problem?.textContent ?? '').toContain('已不在当前项目'); const submit = floatingPanelSubmit(); expect(submit.disabled).toBe(true); fireEvent.click(submit); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); }); test('已提交请求的重试:删掉失效参考再提交零新增请求,恢复原参考后才允许重试', async () => { const user = userEvent.setup(); const tauri = installHostTauri({ assets: [imageAsset('ref-img', 'ref.png')], assetGenerationRecord: 'failed', }); render( , ); await settle(); await openResourceCategory('角色'); fireEvent.click(toolbarToolButton('生成图片')); await settle(); const draftId = allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; await typeGenerationPrompt(floatingPanel()!, '画一只猫'); const panel = floatingPanel()!; await user.click( within(panel).getByRole('button', { name: '插入素材引用' }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); await user.click(within(picker).getByRole('option', { name: /ref/ })); await user.click(within(picker).getByRole('button', { name: '插入引用' })); await waitFor(() => expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), ); fireEvent.click(floatingPanelSubmit()); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); const originalPrompt = tauri.assetGenerationStarts[0]!.prompt; await waitFor(() => expect( placeholderElement(draftId)?.dataset .resourceCanvasGenerationPlaceholderStatus, ).toBe('failed'), ); // 素材被删掉后重开重试面板:草稿里的失效参考被显式保留并挡住提交。 fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); await settle(); fireEvent.click(placeholderElement(draftId)!); await settle(); expect( floatingPanel()?.querySelector( '[data-resource-canvas-generation-reference-problem]', ), ).not.toBeNull(); /* 用户直接在正文里删掉 `@引用`:参考集合变空、失效参考的问题随之消失,但**输入已经不是 原请求的那份**——原生按指纹当新请求处理,也就是一次新的付费生成。这里必须挡住, 而不是让「旧 draft 悄悄变成新收费」。 */ await typeGenerationPrompt(floatingPanel()!, '画一只猫'); expect( floatingPanel()?.querySelector( '[data-resource-canvas-generation-bound-request-changed]', ), ).not.toBeNull(); const blockedSubmit = floatingPanelSubmit(); expect(blockedSubmit.disabled).toBe(true); fireEvent.click(blockedSubmit); // 绕过按钮禁用直接提交表单也一样:处理函数自己再挡一次。 fireEvent.submit(floatingPanel()!.querySelector('form')!); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(1); // 恢复原参考(同一 id)+ 重开面板拿回冻结草稿:这才是原请求的重试。 fireEvent.click(screen.getByRole('button', { name: '恢复参考素材' })); await settle(); fireEvent.click(screen.getByRole('button', { name: '取消' })); await settle(); fireEvent.click(placeholderElement(draftId)!); await settle(); const retrySubmit = floatingPanelSubmit(); expect(retrySubmit.disabled).toBe(false); fireEvent.click(retrySubmit); await settle(); expect(tauri.assetGenerationStarts).toHaveLength(2); expect(tauri.assetGenerationStarts[1]!.referenceAssetIds).toEqual([ 'ref-img', ]); expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt); }); }); /** * 验收现场那条「拖动未生成的资源卡片会触发点击事件」(至少背景音乐与音效会)。 * * 浏览器在 pointerdown 与 pointerup 落在同一个节点上时**一定会补一次 `click`**——拖完卡片 * 松手,紧跟着的那次 click 会把卡片自己的点击语义再执行一遍。占位卡的点击语义就是开合生成浮层, * 所以表现为「拖一下就把浮层弹开 / 收起」。这里从真实宿主上钉住两件事:拖动收尾的 click 被吃掉, * 而一次真正的点击仍然照常开合(抑制只活过一次手势)。 */ describe('拖动生成占位卡不触发卡片的点击(宿主回归)', () => { test('拖动收尾的那次 click 被吃掉,普通点击仍然照常开合浮层', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')], }); render(); await settle(); const { draftId } = await openBgmEntry(); // 点入口时浮层是开着的(占位与浮层由同一个动作产生)。 expect(floatingPanel()).not.toBeNull(); const placeholder = placeholderElement(draftId)!; fireEvent.pointerDown(placeholder, { pointerId: 7, button: 0, isPrimary: true, clientX: 200, clientY: 200, }); fireEvent.pointerMove(placeholder, { pointerId: 7, clientX: 260, clientY: 230, }); /* * 真实鼠标的拖动会横跨很多轮宏任务(pointermove 一次次派发):这里让时钟真的走一轮再松手。 * 抑制的计时起点必须在松手那一刻;若实现改回「越过阈值时就起 0ms 计时器」,这一轮之后抑制 * 就已经被清掉,紧随其后的那次 click 会把浮层关掉,下面的断言立刻红。 */ await settle(); fireEvent.pointerUp(placeholder, { pointerId: 7, clientX: 260, clientY: 230, }); /* * 真实浏览器在 pointerup 之后会自动释放指针捕获并补一个 `lostpointercapture`(卡片把它接到 * 取消手势上)。它必须**不能**把这次拖动的点击抑制一起清掉——否则补上来的 click 又会漏过去。 */ fireEvent.lostPointerCapture(placeholder, { pointerId: 7 }); // 浏览器补的那一次 click:抑制没生效时这里会立刻把浮层关掉。 fireEvent.click(placeholder); await settle(); expect(floatingPanel()).not.toBeNull(); // 对照:没移动的点击是「点击」,仍然开合浮层(抑制不是把点击永久关掉)。 fireEvent.click(placeholder); await settle(); expect(floatingPanel()).toBeNull(); // 拖动本身照常生效(拖动只改坐标,不取消这次生成)。 expect(placeholderElement(draftId)).not.toBeNull(); expect(tauri.assetGenerationStarts).toEqual([]); }, 20_000); });