// @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( (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( 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( 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( , ); 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( , ); 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]); }); });