生成浮层面板 chrome 与画布安全高度修复
浮层复用模态的面板类:边框圆角底色内边距与 header 排布不再缺失 高度上界按真实画布可用底边算(画布高减退底栏安全区与顶边),空间紧时收紧并内部滚动 提交动作行固定在浮层底部,避免长表单把提交按钮顶到底栏下面 新增浮层高度模型测试、结构 CSS 钉子与浮层 chrome 断言 新增宿主级整合用例:图片按正式归类落点并用占位最新位置、音频失败保留与幂等重试
This commit is contained in:
+6
-1
@@ -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=""
|
||||
|
||||
+2
-1
@@ -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=""
|
||||
|
||||
+27
-7
@@ -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 {
|
||||
|
||||
+46
@@ -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}
|
||||
|
||||
+144
@@ -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.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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user