14a0286f70
- 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 格式(未触碰无关文件)
475 lines
17 KiB
TypeScript
475 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 { 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]);
|
||
});
|
||
});
|