18252e24b8
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
合并 origin/master 的 AGC 画布参考与项目能力更新 解决 canvas 生成恢复与任务模型冲突并继续使用共享 GameCreationAppAssetKind 保留 run_id、generationKind 与外部协议字符串的独立边界
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
// @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 type { ChatReference } from '../src/features/project-workspace/resourceReferences';
|
||
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 {
|
||
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',
|
||
prompt: '像素月光厨房的统一视觉规范',
|
||
assetName: '图标规范',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
references: [],
|
||
}),
|
||
);
|
||
});
|
||
|
||
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',
|
||
prompt: '横屏单屏界面',
|
||
assetName: 'AI 生成 UI 设计图',
|
||
aspectRatio: '9:16',
|
||
imageSize: '2K',
|
||
references: [],
|
||
}),
|
||
);
|
||
});
|
||
|
||
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 {
|
||
prompt: string;
|
||
assetName: string;
|
||
aspectRatio: string;
|
||
imageSize: string;
|
||
references: ChatReference[];
|
||
};
|
||
render(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={action}
|
||
draft={{
|
||
prompt: first.prompt,
|
||
assetName: first.assetName,
|
||
aspectRatio: first.aspectRatio,
|
||
imageSize: first.imageSize,
|
||
references: first.references,
|
||
}}
|
||
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]);
|
||
});
|
||
});
|