生成浮层面板 chrome 与画布安全高度修复

浮层复用模态的面板类:边框圆角底色内边距与 header 排布不再缺失
高度上界按真实画布可用底边算(画布高减退底栏安全区与顶边),空间紧时收紧并内部滚动
提交动作行固定在浮层底部,避免长表单把提交按钮顶到底栏下面
新增浮层高度模型测试、结构 CSS 钉子与浮层 chrome 断言
新增宿主级整合用例:图片按正式归类落点并用占位最新位置、音频失败保留与幂等重试
This commit is contained in:
2026-09-17 19:07:54 +08:00
parent 2c687b01a4
commit 58992330aa
8 changed files with 726 additions and 12 deletions
@@ -373,7 +373,12 @@ export function ResourceCanvasAssetGenerationPanelView({
if (variant === 'floating') {
return (
<section
className="game-resource-generation-dialog resource-canvas-generation-floating-panel"
/*
与模态共用同一套面板 chrome`game-approval-dialog` 提供边框/圆角/底色/内边距与
`> 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=""
@@ -271,7 +271,8 @@ export function ResourceCanvasGenerationPanelView({
if (variant === 'floating') {
return (
<section
className="game-resource-generation-dialog resource-canvas-generation-floating-panel"
// 与模态共用面板 chrome(边框/圆角/底色/内边距 + `> 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=""
@@ -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 {
@@ -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)),
);
}
@@ -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}
@@ -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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
variant="floating"
style={{ left: 120, top: 346, maxHeight: '348px' }}
assets={[]}
projectPath="/tmp/project"
onSubmit={() => 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(
<ResourceCanvasGenerationPanelView
kinds={['background-music']}
initialKind="background-music"
variant="floating"
style={{ left: 40, top: 200, maxHeight: '300px' }}
request={{
operationId: 'operation-bound',
idempotencyKey: 'key-bound',
prompt: '轻快的八音盒',
}}
initialDraft={{
kind: 'background-music',
prompt: '轻快的八音盒',
assetName: '新背景音乐',
}}
onSubmit={onSubmit}
onClose={() => 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,
);
});
});
@@ -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<typeof vi.fn>;
layoutWrites: ProjectResourceCanvasPosition[][];
generationStarts: Record<string, unknown>[];
deriveInputs: Record<string, unknown>[];
unexpected: string[];
};
function installTauri({
assets,
deriveFails = false,
}: {
assets: GameCreationAppAssetManifestEntry[];
deriveFails?: boolean;
}): Mock {
const layoutWrites: ProjectResourceCanvasPosition[][] = [];
const generationStarts: Record<string, unknown>[] = [];
const deriveInputs: Record<string, unknown>[] = [];
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<string, unknown>) => {
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<string, unknown>;
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<string, string> = {
'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<GameCreationAppManifest>(() => ({
...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'),
assets: assets.map((asset) => structuredClone(asset)),
}));
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={PROJECT_PATH}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
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(<Workbench assets={[pngAsset('asset-character', 'character.png')]} />);
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<HTMLElement>(
'[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(<Workbench assets={assets} />);
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<HTMLElement>(
'[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<HTMLElement>(
'[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);
});
@@ -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.innerHeight720)当上界就会一路垂到底栏下面(旧实现的成因)。
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);
});
});