Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
suzmii 14a0286f70 fix(agc): 生成面板点击即关闭、面板内不再出现阶段文案,只有即时失败才把草稿带回来
- ResourceCanvasAssetGenerationPanelView:删掉 `phaseDetail` 通道与整套在途状态(`submitting` /
  禁用输入 / 「后台运行并关闭」),主按钮文案恒为动作名;提交时**同步**调用 onSubmit 并 onClose,
  面板不等 IPC、不等排队、不等生成。新增 `draft` / `error` 两个入参:只有「后端从未受理」的即时
  失败重开时,宿主才把草稿与原因带回来(`role="alert"` 可见、改完即可重试)
- index.tsx:`submitResourceAssetGeneration` 改为同步返回(不再 await 终局),记录本次提交上下文
  (taskId / action / 草稿 / 是否本来就该立刻派发),提交后自动开任务面板并给一次提示条;
  收尾回调里区分两类失败:`record === null` 且本来就该立刻派发 → 重开提交面板并带回草稿;
  受理之后才失败 → 不重开面板,只在任务面板收口为失败 + 提示条
- index.tsx:删掉只为旧「在途阶段文案」服务的 `resourceAssetGenerationPanelTaskIdRef` 与成功后的
  自动收面板逻辑(面板此时已关闭)
- 测试:`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 的图片类用例改写为「点击即关闭 +
  面板 DOM 里从不出现 排队中。/正在生成。/提交中…」+「即时失败重开带回草稿与原因」;音频面板用例不变
- 测试:`resourceCanvasBottomToolbar.test.tsx` 的失败重试用例按新语义改写(点击即关闭、重开时显示原因)
- 测试:AppSurface 新增三条(同步关闭且面板无阶段文案、后端未受理时重开并保留草稿、受理后失败不重开
  只在任务面板收口),并把既有生成任务 e2e 的提交步骤改为「点击即关闭」
- 顺带按仓库规范修正以上文件的 eslint import sort 与 prettier 格式(未触碰无关文件)
2026-09-14 18:35:11 +08:00

475 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import {
projectHasIconSpecReference,
resolveResourceCanvasBottomToolAvailability,
resolveResourceCanvasBottomTools,
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
resourceCanvasAssetGenerationOutputPath,
type ResourceCanvasAssetToolAction,
type ResourceCanvasBottomTool,
resourceCanvasBottomToolActions,
} from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView';
afterEach(cleanup);
function toolLabels(tools: readonly ResourceCanvasBottomTool[]) {
return tools.map((tool) => tool.label);
}
function assetActionOf(
category: 'ui-interaction' | 'character' | 'scene' | 'audio',
label: string,
): ResourceCanvasAssetToolAction {
const tools = resolveResourceCanvasBottomTools(category);
const action = tools
.flatMap((tool) => [...resourceCanvasBottomToolActions(tool)])
.find((candidate) => candidate.label === label);
if (!action || action.route !== 'asset') {
throw new Error(`${category} 栏目缺少图片类入口 ${label}`);
}
return action;
}
function menuLabels(category: 'ui-interaction' | 'character' | 'scene') {
const tool = resolveResourceCanvasBottomTools(category).find(
(candidate) => candidate.id === 'generate-spec',
);
return tool?.menu?.map((item) => item.label) ?? [];
}
describe('resourceCanvasBottomToolbarModel', () => {
test('四个栏目各自呈现用户拍板的工具集,顺序与矩阵一致', () => {
expect(
toolLabels(resolveResourceCanvasBottomTools('ui-interaction')),
).toEqual([
'生成图片',
'生成规范',
'生成图标素材',
'生成 UI 设计图',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('character'))).toEqual([
'生成图片',
'生成规范',
'生成角色形象',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('scene'))).toEqual([
'生成图片',
'生成规范',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('audio'))).toEqual([
'生成背景音乐',
'生成音效',
'上传',
]);
});
test('二级菜单按栏目分流,且不含生成视频 / 宣发素材 / 生成游戏场景', () => {
expect(menuLabels('ui-interaction')).toEqual(['图标规范', '自定义规范']);
expect(menuLabels('character')).toEqual(['角色规范', '自定义规范']);
expect(menuLabels('scene')).toEqual(['自定义规范']);
const everyAction = (
['ui-interaction', 'character', 'scene', 'audio'] as const
).flatMap((category) =>
resolveResourceCanvasBottomTools(category).flatMap((tool) => [
...resourceCanvasBottomToolActions(tool),
]),
);
for (const forbidden of [
'生成视频',
'宣发素材',
'生成游戏场景',
'选择工具',
'抓手工具',
]) {
expect(everyAction.map((action) => action.label)).not.toContain(
forbidden,
);
}
});
test('不渲染工具栏的栏目一律返回空数组', () => {
for (const category of [
'document',
'unclassified',
'version',
'all',
null,
undefined,
] as const) {
expect(resolveResourceCanvasBottomTools(category)).toEqual([]);
}
});
test('比例与尺寸选项来自网页端纯模型并裁到本地 IPC 白名单', () => {
const mainStationRatios =
EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2].aspectRatios;
// 选项不是另抄的一份常量:网页端纯模型的每一项都还在,只是裁掉了本地通道拒绝的 4:3。
expect(mainStationRatios).toEqual(
expect.arrayContaining([...RESOURCE_CANVAS_ASSET_ASPECT_RATIOS]),
);
expect(RESOURCE_CANVAS_ASSET_ASPECT_RATIOS).not.toContain('4:3');
expect(RESOURCE_CANVAS_ASSET_ASPECT_RATIOS).toEqual([
'1:1',
'3:2',
'2:3',
'9:16',
'16:9',
]);
expect(RESOURCE_CANVAS_ASSET_IMAGE_SIZES).toEqual(['0.5K', '1K', '2K']);
expect(assetActionOf('ui-interaction', '生成图片')).toMatchObject({
aspectRatio: '1:1',
imageSize: '1K',
adjustableDimensions: true,
});
// 网页端 UI 设计图面板的默认档是 16:9 · 1K,工具栏沿用同一默认值。
expect(assetActionOf('ui-interaction', '生成 UI 设计图')).toMatchObject({
aspectRatio: '16:9',
imageSize: '1K',
});
});
test('入口可用性给可执行的原因,而不是静默不可用', () => {
const iconSpritesheet = assetActionOf('ui-interaction', '生成图标素材');
expect(
resolveResourceCanvasBottomToolAvailability(iconSpritesheet, {
hasRuntimeInvoke: false,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: true,
}).blockedReason,
).toContain('客户端');
expect(
resolveResourceCanvasBottomToolAvailability(iconSpritesheet, {
hasRuntimeInvoke: true,
projectPath: ' ',
projectId: 'project-1',
hasIconSpecReference: true,
}).blockedReason,
).toContain('项目尚未就绪');
const blocked = resolveResourceCanvasBottomToolAvailability(
iconSpritesheet,
{
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: false,
},
);
expect(blocked.available).toBe(false);
expect(blocked.blockedReason).toContain('assets/art-spec.png');
expect(blocked.blockedReason).toContain('图标规范');
expect(
resolveResourceCanvasBottomToolAvailability(
assetActionOf('ui-interaction', '生成图片'),
{
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: false,
},
),
).toEqual({ available: true, blockedReason: null });
});
test('权威规范图判据与 Rust 同口径:精确落点 + icon-spec + 画布来源', () => {
const canvasIconSpec = {
kind: 'icon-spec',
localPath: RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
mediaType: 'image/png',
source: { kind: 'canvas' },
};
expect(projectHasIconSpecReference([canvasIconSpec])).toBe(true);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, localPath: 'assets/icon-spec.png' },
]),
).toBe(false);
expect(
projectHasIconSpecReference([{ ...canvasIconSpec, kind: 'spec' }]),
).toBe(false);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, source: { kind: 'generated' } },
]),
).toBe(false);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, mediaType: 'application/octet-stream' },
]),
).toBe(false);
});
test('图标规范入口在缺前置时写进权威落点,已有前置时不再覆盖', () => {
const iconSpec = assetActionOf('ui-interaction', '图标规范');
expect(resourceCanvasAssetGenerationOutputPath(iconSpec, false)).toBe(
RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
);
expect(resourceCanvasAssetGenerationOutputPath(iconSpec, true)).toBeNull();
expect(
resourceCanvasAssetGenerationOutputPath(
assetActionOf('ui-interaction', '生成图标素材'),
false,
),
).toBeNull();
expect(
resourceCanvasAssetGenerationOutputPath(
assetActionOf('character', '生成角色形象'),
false,
),
).toBeNull();
});
});
describe('ResourceCanvasBottomToolbarView', () => {
function renderToolbar(overrides: {
category: 'ui-interaction' | 'character' | 'audio';
blockedLabels?: readonly string[];
uploadBlockedReason?: string | null;
onSelectAction?: (action: unknown) => void;
onUploadFiles?: (files: readonly File[]) => void;
}) {
const tools = resolveResourceCanvasBottomTools(overrides.category);
const onSelectAction = overrides.onSelectAction ?? vi.fn();
const onUploadFiles = overrides.onUploadFiles ?? vi.fn();
render(
<ResourceCanvasBottomToolbarView
category={overrides.category}
tools={tools}
resolveBlockedReason={(action) =>
(overrides.blockedLabels ?? []).includes(action.label)
? '需要先完成并登记图标规范(assets/art-spec.png'
: null
}
uploadBlockedReason={overrides.uploadBlockedReason ?? null}
onSelectAction={onSelectAction}
onUploadFiles={onUploadFiles}
/>,
);
return { onSelectAction, onUploadFiles };
}
test('按栏目渲染工具,二级菜单只在展开时出现', () => {
renderToolbar({ category: 'ui-interaction' });
expect(
document.querySelector('[data-resource-bottom-toolbar]'),
).not.toBeNull();
expect(
document
.querySelector('[data-resource-bottom-toolbar]')
?.getAttribute('data-resource-bottom-toolbar'),
).toBe('ui-interaction');
for (const label of [
'生成图片',
'生成规范',
'生成图标素材',
'生成 UI 设计图',
]) {
expect(screen.getByRole('button', { name: label })).not.toBeNull();
}
expect(screen.queryByRole('menuitem', { name: '图标规范' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '生成规范' }));
expect(screen.getByRole('menuitem', { name: '图标规范' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '自定义规范' })).not.toBeNull();
});
test('不可用的入口保持可点击,只给原因说明', () => {
const { onSelectAction } = renderToolbar({
category: 'ui-interaction',
blockedLabels: ['生成图标素材'],
});
const blockedButton = screen.getByRole('button', { name: '生成图标素材' });
expect((blockedButton as HTMLButtonElement).disabled).toBe(false);
expect(blockedButton.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(blockedButton);
expect(onSelectAction).not.toHaveBeenCalled();
const notice = screen.getByRole('alert');
expect(notice.textContent).toContain('assets/art-spec.png');
// 原因说明可以关掉,关掉后不再占位。
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(screen.queryByRole('alert')).toBeNull();
});
test('二级菜单项命中同一套可用性判据,可用时把动作交给宿主', () => {
const { onSelectAction } = renderToolbar({ category: 'character' });
fireEvent.click(screen.getByRole('button', { name: '生成规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
expect(onSelectAction).toHaveBeenCalledWith(
expect.objectContaining({
route: 'asset',
label: '角色规范',
assetKind: 'spec',
}),
);
// 选中后菜单收起,不会残留一层挡住画布。
expect(screen.queryByRole('menuitem', { name: '角色规范' })).toBeNull();
});
test('音频栏目只呈现背景音乐与音效,并把选择交给宿主', () => {
const { onSelectAction } = renderToolbar({ category: 'audio' });
fireEvent.click(screen.getByRole('button', { name: '生成音效' }));
expect(onSelectAction).toHaveBeenCalledWith(
expect.objectContaining({ route: 'audio', audioKind: 'sound-effect' }),
);
expect(screen.queryByRole('button', { name: '生成视频' })).toBeNull();
});
test('上传走宿主回调,桥不可用时同样只给原因', () => {
const { onUploadFiles } = renderToolbar({ category: 'audio' });
const input = screen.getByLabelText('上传素材文件') as HTMLInputElement;
const file = new File(['x'], 'bgm.mp3', { type: 'audio/mpeg' });
fireEvent.change(input, { target: { files: [file] } });
expect(onUploadFiles).toHaveBeenCalledWith([file]);
cleanup();
renderToolbar({
category: 'audio',
uploadBlockedReason: '该能力需要在客户端内执行',
});
fireEvent.click(screen.getByRole('button', { name: '上传' }));
expect(screen.getByRole('alert').textContent).toContain('客户端');
});
});
describe('ResourceCanvasAssetGenerationPanelView', () => {
test('固定档入口只展示当前规格,提交载荷逐字正确', async () => {
const onSubmit = vi.fn(async () => undefined);
render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('ui-interaction', '图标规范')}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '图标规范' });
expect(panel.textContent).toContain('1:1·1K');
expect(
screen.queryByRole('button', { name: '图标规范比例 1:1' }),
).toBeNull();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '像素月光厨房的统一视觉规范' },
});
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'icon-spec',
prompt: '像素月光厨房的统一视觉规范',
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
}),
);
});
test('可调档入口复用网页端比例选项,切换后进入载荷', async () => {
const onSubmit = vi.fn(async () => undefined);
render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('ui-interaction', '生成 UI 设计图')}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
// 本地通道拒绝 4:3,面板不得把它渲染成可点选项。
expect(
screen.queryByRole('button', { name: '生成 UI 设计图比例 4:3' }),
).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '生成 UI 设计图比例 9:16' }),
);
fireEvent.click(
screen.getByRole('button', { name: '生成 UI 设计图尺寸 2K' }),
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '横屏单屏界面' },
});
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'ui-prototype',
prompt: '横屏单屏界面',
assetName: 'AI 生成 UI 设计图',
aspectRatio: '9:16',
imageSize: '2K',
}),
);
});
test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => {
const onSubmit = vi.fn();
const onClose = vi.fn();
const action = assetActionOf('character', '生成角色形象');
const { unmount } = render(
<ResourceCanvasAssetGenerationPanelView
action={action}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
const submit = screen.getByRole('button', { name: '生成角色形象' });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '披风猫骑士' },
});
fireEvent.click(submit);
// 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
// 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。
const first = onSubmit.mock.calls[0]?.[0] as {
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
};
render(
<ResourceCanvasAssetGenerationPanelView
action={action}
draft={{
prompt: first.prompt,
assetName: first.assetName,
aspectRatio: first.aspectRatio,
imageSize: first.imageSize,
}}
error="图片比例不受支持:4:3"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
expect(screen.getByRole('alert').textContent).toContain(
'图片比例不受支持:4:3',
);
expect(
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
).toBe('披风猫骑士');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(onSubmit).toHaveBeenCalledTimes(2);
expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]);
});
});