Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
k88936 a11cdf13d7 画布生成面板重开草稿与重试身份收敛到 canonical content
生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[]
重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch
directCodexContentToLegacyContentDto 只在任务落账的出站边界派生
账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现
token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用
无法解析的资源引用合成 unknown 占位而不再静默丢弃
出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用
directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定
同步决策记录与 canonical content 里程碑落地结果
2026-09-21 22:56:32 +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';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
import {
generationPromptText,
typeGenerationPrompt,
} from './resourceGenerationPromptTestUtils';
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: 'icon-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();
await typeGenerationPrompt(panel, '像素月光厨房的统一视觉规范');
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'icon-spec',
content: [{ type: 'input_text', text: '像素月光厨房的统一视觉规范' }],
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' }),
);
await typeGenerationPrompt(document.body, '横屏单屏界面');
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'ui-design',
content: [{ type: 'input_text', text: '横屏单屏界面' }],
assetName: 'AI 生成 UI 设计图',
aspectRatio: '9:16',
imageSize: '2K',
}),
);
});
test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', async () => {
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);
await typeGenerationPrompt(document.body, '披风猫骑士');
fireEvent.click(submit);
// 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
// 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。
const first = onSubmit.mock.calls[0]?.[0] as {
content: DirectCodexUserContentPart[];
assetName: string;
aspectRatio: string;
imageSize: string;
};
render(
<ResourceCanvasAssetGenerationPanelView
action={action}
draft={{
content: first.content,
assetName: first.assetName,
aspectRatio: first.aspectRatio,
imageSize: first.imageSize,
}}
error="图片比例不受支持:4:3"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
expect(screen.getByRole('alert').textContent).toContain(
'图片比例不受支持:4:3',
);
// 草稿会重新灌回 `@` 引用输入区;它由编辑器异步落到 DOM,等一次。
await waitFor(() =>
expect(generationPromptText(document.body)).toContain('披风猫骑士'),
);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(onSubmit).toHaveBeenCalledTimes(2);
expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]);
});
});