diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 0b849b9a0..01a7faf40 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -373,7 +373,12 @@ export function ResourceCanvasAssetGenerationPanelView({ if (variant === 'floating') { return (
header` 排布,`game-resource-generation-dialog` 提供表单宽度口径。少任何一个, + 浮层就会退化成没有背景边框、标题挤在一起的一块裸容器(真实浏览器复现过)。 + */ + className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel" role="dialog" aria-label={action.label} data-resource-canvas-generation-floating-panel="" diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index a4db6dbf8..df8761bcb 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -271,7 +271,8 @@ export function ResourceCanvasGenerationPanelView({ if (variant === 'floating') { return (
header` 排布),浮层不另造外观。 + className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel" role="dialog" aria-label={panelTitle} data-resource-canvas-generation-floating-panel="" diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css index f0b973778..89d33eb10 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css @@ -2,7 +2,7 @@ * 生成占位卡与「卡下独立浮层」的局部样式。 * * 只服务栏目画布上的临时占位(宿主内存态)与挂在它下沿的生成浮层:两者都不是正式素材, - * 所以样式也刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 + * 所以占位卡刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 * 放在独立文件里而不是并进 `resourceCanvasChrome.css`:这条链路可以整体回滚, * 也不与画布手势/卡片展示的改动互相冲突。 */ @@ -70,16 +70,36 @@ } /* - * 独立浮层:定位由宿主按占位卡下沿算好(与快速编辑 / 信息浮层同一条锚点口径), - * 所以这里只负责面板外观与「不被画布手势当空白」的层级。 + * 独立浮层:定位与高度由宿主按**真实矩形**算好(贴着占位下沿、上界到画布可用底边), + * 这里只负责面板外观与层级。选择器带上 `.game-approval-dialog` 是为了拿到共享面板 chrome + * 的优先级:浮层与模态共用同一套 border/圆角/底色/内边距与 `> header` 排布,不另造一套外观。 */ -.resource-canvas-generation-floating-panel { +.game-approval-dialog.resource-canvas-generation-floating-panel { position: absolute; z-index: 70; - transform: translateX(-50%); - max-height: min(560px, calc(100dvh - 120px)); - overflow: auto; + width: min(560px, calc(100% - 24px)); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; pointer-events: auto; + /* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。 */ + max-height: min(560px, calc(100dvh - 160px)); +} + +/* + * 提交行固定在浮层底部。 + * + * 面板内容(素材名称 / 提示词 / 规格 / 润色 / 错误)会撑到比可用高度更高,此时滚动只应该发生在 + * 它自己身上:真实浏览器 1280x720 上提交按钮曾经整块被底栏盖住点不到。`background` 跟随面板 + * 底色,滚动内容不会从动作行后面透出来。 + */ +.game-approval-dialog.resource-canvas-generation-floating-panel + .game-resource-generation-actions { + position: sticky; + bottom: 0; + z-index: 1; + padding-bottom: 2px; + background: #fffaf7; } .resource-canvas-asset-generation-prompt-input { diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts index f4c01e544..f82d1a951 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts @@ -88,3 +88,49 @@ export function revealResourceCanvasGenerationContent({ y: current.y + dy, }; } + +/** + * 浮层可用高度:**按真实画布底边**算,不是 `window.innerHeight`。 + * + * 画布底部盖着工具栏(真实浏览器 1280x720:画布高 568、底栏 y600),用视口高当上界会让面板 + * 一路垂到底栏下面——提交按钮永远点不到。这里从浮层顶边到画布可用底边取剩余空间, + * 并留一段间隙;空间实在不够时保底一个可滚动的最小高度,宁可让面板内部滚动,也不让它越出画布。 + */ +export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 220; +/** 极窄空间下的硬下限:再小就连标题 + 固定动作行都放不下,交给内部滚动。 */ +export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT = 120; +export function resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop, + canvasHeight, + bottomInset, + minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT, + gap = 12, +}: { + /** 浮层顶边(画布坐标系,与定位样式同一个基准)。 */ + panelTop: number; + canvasHeight: number; + /** 画布底部的安全区(底栏高度 + 边距)。 */ + bottomInset: number; + minHeight?: number; + gap?: number; +}): number { + if ( + !Number.isFinite(panelTop) || + !Number.isFinite(canvasHeight) || + canvasHeight <= 0 + ) { + return minHeight; + } + const availableBottom = canvasHeight - Math.max(0, bottomInset) - gap; + const available = availableBottom - Math.max(0, panelTop); + if (available <= 0) { + // 顶边已经在安全区外(刚打开、还没平移):先给最小高度,紧接着由 reveal 把它带回来。 + return minHeight; + } + // 关键:**不许超过可用空间**。否则「最小高度」本身就会把底边顶到底栏下面, + // 提交按钮照样点不到——那正是本函数要修的问题。空间紧就内部滚动。 + return Math.max( + RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT, + Math.min(minHeight, Math.floor(available)), + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 607d28766..ed5e31867 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -131,7 +131,10 @@ import { type ResourceCanvasGenerationPlaceholder, } from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; -import { revealResourceCanvasGenerationContent } from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { + resolveResourceCanvasGenerationPanelMaxHeight, + revealResourceCanvasGenerationContent, +} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -8217,6 +8220,30 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + /** + * 浮层高度上界:**按真实画布可用底边**算(画布高 - 底栏安全区 - 浮层顶边)。 + * + * 用 `window.innerHeight` 当上界会让面板一路垂到底栏下面:真实浏览器 1280x720 上画布高 568、 + * 底栏 y600,面板顶边 346、底边超过 900,提交按钮整块被盖住点不到。这里把上限交给实测矩形, + * 装不下时面板内部滚动,动作行固定在底部。 + */ + const resourceGenerationPanelMaxHeight = resourceGenerationPanelStyle + ? resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: resourceGenerationPanelStyle.top, + canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current) + .height, + bottomInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current) + .bottom, + }) + : null; + const resourceGenerationPanelFloatingStyle = resourceGenerationPanelStyle + ? { + ...resourceGenerationPanelStyle, + ...(resourceGenerationPanelMaxHeight === null + ? {} + : { maxHeight: `${resourceGenerationPanelMaxHeight}px` }), + } + : null; /** * 打开生成浮层时把「占位卡 + 浮层」整块带进可视区。 * @@ -9148,7 +9175,7 @@ export default function ProjectDevelopmentView({ kinds={resourceGenerationDraft.kinds} initialKind={resourceGenerationDraft.initialKind} variant="floating" - style={resourceGenerationPanelStyle} + style={resourceGenerationPanelFloatingStyle} // 用户收起过浮层就接着编辑同一份草稿(不是空表单)。 initialDraft={ resourceGenerationDraftRef.current.get( @@ -9194,7 +9221,7 @@ export default function ProjectDevelopmentView({ }`} action={resourceAssetGenerationPanel.action} variant="floating" - style={resourceGenerationPanelStyle} + style={resourceGenerationPanelFloatingStyle} // 参考选择的候选集来自当前项目 manifest,收口到已登记图片; // 与快速编辑同一份 `@` 链路。 assets={manifest.assets} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx new file mode 100644 index 000000000..3c197d513 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +describe('生成浮层的面板外观与高度合同', () => { + test('浮层复用模态同一套面板 chrome 类,不另造一套外观', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + // 少了 `game-approval-dialog`,浮层就会变成没有背景/边框、header 挤在一起的一块裸容器。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect(panel.classList.contains('game-resource-generation-dialog')).toBe( + true, + ); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 位置与高度上界都由宿主按真实矩形给:内联样式必须原样落到面板上。 + expect(panel.style.top).toBe('346px'); + expect(panel.style.left).toBe('120px'); + expect(panel.style.maxHeight).toBe('348px'); + }); + + test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => undefined); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成背景音乐' }); + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 收起时保存的草稿要灌回来:不是空表单。 + expect( + within(panel).getByLabelText('生成提示词').getAttribute('value'), + ).toBeNull(); + + await user.click( + within(panel).getByRole('button', { name: '生成背景音乐' }), + ); + // 幂等重试:命中宿主记下的同一对 operationId / 幂等键,不铸造新的付费生成。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'background-music', + operationId: 'operation-bound', + idempotencyKey: 'key-bound', + prompt: '轻快的八音盒', + }); + }); +}); + +/** + * 结构 CSS 钉子:真实浏览器 1280x720 上浮层曾经「面板垂到底栏下面、提交按钮点不到」, + * 而且第一版浮层只有 `game-resource-generation-dialog` 一个类,连背景边框都没有。 + * 这些是几何模型测不到的部分,所以直接钉住样式文件里的关键声明。 + */ +describe('生成浮层样式结构', () => { + const panelCss = () => + readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css', + ), + 'utf8', + ); + + test('浮层选择器带上面板 chrome 类,并限制自身滚动与兜底上界', () => { + const css = panelCss(); + expect(css).toContain( + '.game-approval-dialog.resource-canvas-generation-floating-panel {', + ); + expect(css).toContain('overflow-y: auto;'); + expect(css).toContain('overscroll-behavior: contain;'); + // 兜底上界必须在:内联 maxHeight 拿不到几何时也不能整块垂出画布。 + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel \{[\s\S]*?max-height:/u, + ); + }); + + test('动作行固定在面板底部,滚动内容不会把提交按钮顶出可视区', () => { + const css = panelCss(); + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel[\s\S]*?\.game-resource-generation-actions \{[\s\S]*?position: sticky;/u, + ); + expect(css).toMatch( + /\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx new file mode 100644 index 000000000..8d1b6b23c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -0,0 +1,410 @@ +// @vitest-environment jsdom +/** + * 生成落点与音频提交身份的**宿主级**整合用例。 + * + * 覆盖两条只能跨模块才校验得了的口径: + * 1. 结果落点用**正式归类后的 section**(普通图片落「待归类」)与占位**最新位置**,写完撤占位; + * 2. 音频入口失败后占位保留、面板带回草稿,重试复用同一 operationId(幂等,不重复付费)。 + * + * Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。 + */ +import { renderHook } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +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'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +const PROJECT_ID = 'generation-landing'; +const PROJECT_PATH = '/tmp/generation-landing'; +const GENERATED_ASSET_ID = 'asset-generated-landing'; +const GENERATED_RESOURCE_ID = `asset:${GENERATED_ASSET_ID}`; + +function pngAsset( + id: string, + fileName: string, + category: GameCreationAppAssetManifestEntry['category'] = 'character', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'character', + category, + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +/** 生成结果按**原生正式归类**落进「待归类」:入口栏目是 character,两者故意不同。 */ +function generatedAsset(): GameCreationAppAssetManifestEntry { + return { + ...pngAsset(GENERATED_ASSET_ID, 'generated.png', 'unclassified'), + kind: 'image', + }; +} + +function resourceGraphFor(resources: Array<{ resourceId: string }>) { + const resourceIds = resources.map((resource) => resource.resourceId); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type Mock = { + invoke: ReturnType; + layoutWrites: ProjectResourceCanvasPosition[][]; + generationStarts: Record[]; + deriveInputs: Record[]; + unexpected: string[]; +}; + +function installTauri({ + assets, + deriveFails = false, +}: { + assets: GameCreationAppAssetManifestEntry[]; + deriveFails?: boolean; +}): Mock { + const layoutWrites: ProjectResourceCanvasPosition[][] = []; + const generationStarts: Record[] = []; + const deriveInputs: Record[] = []; + const unexpected: string[] = []; + const manifest: GameCreationAppManifest = { + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + /* + 生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态), + 但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。 + */ + assets: [...assets, generatedAsset()].map((asset) => structuredClone(asset)), + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'get_local_game_manifest') { + return structuredClone(manifest); + } + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor( + (args?.resources as Array<{ resourceId: string }> | undefined) ?? [], + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutWrites.push( + structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ), + ); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutWrites.length, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + // 账本里的终态记录:队列轮询一次就收口为成功,并给出 manifest 资源 id。 + return [ + { + taskId: String(args?.taskId ?? '') || LAST_TASK_ID.value, + projectId: PROJECT_ID, + kind: 'image', + assetName: 'AI 生成图片', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: 3, + assetId: GENERATED_ASSET_ID, + error: null, + }, + ]; + } + if (command === 'start_local_project_asset_generation') { + generationStarts.push(structuredClone(args ?? {})); + LAST_TASK_ID.value = String(args?.taskId ?? ''); + return { + taskId: String(args?.taskId ?? ''), + projectId: PROJECT_ID, + kind: String(args?.kind ?? 'image'), + assetName: String(args?.assetName ?? ''), + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: null, + assetId: null, + error: null, + }; + } + if (command === 'derive_local_project_resource') { + const input = (args?.input ?? {}) as Record; + deriveInputs.push(structuredClone(input)); + if (deriveFails) { + throw new Error('远端拒绝'); + } + return { asset: null, manifest: structuredClone(manifest) }; + } + 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: 1, + content: '# 文档', + }; + } + unexpected.push(command); + return undefined; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected }; +} + +const LAST_TASK_ID = { value: '' }; + +/** 打开栏目页:左侧大纲已删,走「资源总览」的栏目缩略卡。 */ +async function openCategory(label: string) { + const categoryByLabel: Record = { + 'UI 交互': 'ui-interaction', + 角色与对象: 'character', + 音频: 'audio', + 待归类: 'unclassified', + }; + if (document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="main"]'), + ).not.toBeNull(), + ); + } + fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); + await waitFor(() => + expect( + document + .querySelector('[data-resource-book-view="child"]') + ?.querySelector( + `.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`, + ), + ).not.toBeNull(), + ); +} + +function Workbench({ assets }: { assets: GameCreationAppAssetManifestEntry[] }) { + const [manifest, setManifest] = useState(() => ({ + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + assets: assets.map((asset) => structuredClone(asset)), + })); + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 + onManifestChange={(_projectPath, nextManifest) => + setManifest(nextManifest) + } + /> + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe('图片生成落点', () => { + test('按正式归类落 section、用占位最新位置,并撤掉占位', async () => { + const tauri = installTauri({ + assets: [pngAsset('asset-character', 'character.png')], + }); + render(); + await openCategory('角色与对象'); + + fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); + const panel = await screen.findByRole('dialog', { name: '生成图片' }); + // 浮层必须自带面板 chrome:否则没有背景边框、header 也不成排。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 高度上界由真实矩形算出来(jsdom 走兜底画布尺寸),不是 100dvh。 + expect(Number.parseFloat(panel.style.maxHeight)).toBeGreaterThan(0); + + const placeholder = document.querySelector( + '[data-resource-canvas-generation-placeholder]', + ); + expect(placeholder).not.toBeNull(); + const placeholderX = Number.parseFloat(placeholder!.style.left); + const placeholderY = Number.parseFloat(placeholder!.style.top); + + await typeGenerationPrompt(panel, '一只披风猫'); + fireEvent.click(within(panel).getByRole('button', { name: '生成图片' })); + + await waitFor(() => expect(tauri.generationStarts).toHaveLength(1)); + // 入口栏目随任务带给原生(可选入参),参考图为空数组。 + expect(tauri.generationStarts[0]).toMatchObject({ + kind: 'image', + targetCategory: 'character', + referenceAssetIds: [], + }); + + // 任务收口为成功 → 结果按**正式归类**(unclassified)落到占位位置,占位撤掉。 + await waitFor( + () => { + expect( + tauri.layoutWrites + .flat() + .some( + (position) => + position.resourceId === GENERATED_RESOURCE_ID && + position.manuallyPlaced === true, + ), + ).toBe(true); + }, + { timeout: 5_000 }, + ); + const landed = tauri.layoutWrites + .flat() + .filter((position) => position.resourceId === GENERATED_RESOURCE_ID) + .at(-1); + expect(landed).toMatchObject({ + section: 'unclassified', + x: placeholderX, + y: placeholderY, + manuallyPlaced: true, + }); + await waitFor(() => + expect( + document.querySelector('[data-resource-canvas-generation-placeholder]'), + ).toBeNull(), + ); + }, 20_000); +}); + +describe('音频生成身份', () => { + test('失败保留占位与草稿,重试复用同一 operationId', async () => { + const assets = [pngAsset('asset-character', 'character.png')]; + const tauri = installTauri({ assets, deriveFails: true }); + render(); + await openCategory('音频'); + + fireEvent.click(await screen.findByRole('button', { name: '生成背景音乐' })); + const panel = await screen.findByRole('dialog', { name: '生成背景音乐' }); + await typeGenerationPrompt(panel, '轻快的八音盒'); + fireEvent.click(within(panel).getByRole('button', { name: '生成背景音乐' })); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(1)); + const firstOperationId = tauri.deriveInputs[0]?.operationId; + expect(typeof firstOperationId).toBe('string'); + + // 失败:占位收口为失败并保留(不是被删掉),面板仍在且带原因。 + await waitFor(() => + expect( + document.querySelector( + '[data-resource-canvas-generation-placeholder-status="failed"]', + ), + ).not.toBeNull(), + ); + + // 收起浮层后再点占位:草稿灌回来,重试复用同一 operationId(原生幂等,不重复付费)。 + fireEvent.click( + within(panel).getByRole('button', { name: '关闭生成背景音乐' }), + ); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(), + ); + fireEvent.click( + document.querySelector( + '[data-resource-canvas-generation-placeholder]', + )!, + ); + const reopened = await screen.findByRole('dialog', { + name: '生成背景音乐', + }); + fireEvent.click( + within(reopened).getByRole('button', { name: '生成背景音乐' }), + ); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(2)); + expect(tauri.deriveInputs[1]?.operationId).toBe(firstOperationId); + expect(tauri.deriveInputs[1]?.idempotencyKey).toBe( + tauri.deriveInputs[0]?.idempotencyKey, + ); + }, 20_000); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts index b8234ff1d..59349c6f7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { resolveResourceCanvasGenerationPanelMaxHeight } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; const canvasSize = { width: 800, height: 600 }; const insets = { top: 60, bottom: 90 }; @@ -86,3 +87,63 @@ describe('生成浮层与占位的可见性', () => { ).toEqual(viewport); }); }); + +describe('生成浮层高度上界按真实画布底边算', () => { + test('1280x720 实测:画布高 568、底栏安全区 84、浮层顶边 346 时不越出底栏', () => { + const canvasHeight = 568; + const bottomInset = 84; + const panelTop = 346; + const maxHeight = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop, + canvasHeight, + bottomInset, + }); + // 底边必须落在画布可用底边之上:顶边 346 + 高度 ≤ 568 - 84。 + expect(panelTop + maxHeight).toBeLessThanOrEqual(canvasHeight - bottomInset); + // 空间只剩 126px 时按可用空间收(而不是拿最小高度硬顶出去)。 + expect(maxHeight).toBe(126); + // 用 window.innerHeight(720)当上界就会一路垂到底栏下面(旧实现的成因)。 + expect(maxHeight).toBeLessThan(720 - panelTop); + }); + + test('平移把浮层带回可视区后,上界随新的顶边放大(不是恒定小高度)', () => { + const canvasHeight = 568; + const bottomInset = 84; + const before = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 346, + canvasHeight, + bottomInset, + }); + const after = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 124, + canvasHeight, + bottomInset, + }); + expect(after).toBeGreaterThan(before); + expect(124 + after).toBeLessThanOrEqual(canvasHeight - bottomInset); + }); + + test('空间不足时保底一个可滚动高度,几何非法时回退到最小值', () => { + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 560, + canvasHeight: 568, + bottomInset: 84, + }), + ).toBe(220); + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: Number.NaN, + canvasHeight: 568, + bottomInset: 84, + }), + ).toBe(220); + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 100, + canvasHeight: 0, + bottomInset: 84, + }), + ).toBe(220); + }); +});