// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { type ChatReference, directCodexContentToPromptText, legacyContentDtoToContent, resourceLabelResolver, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; import { ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import { RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES, RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC, resourceCanvasAssetGenerationAcceptsReferences, resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, resourceCanvasAssetGenerationReferenceIssue, resourceCanvasAssetGenerationUserReferenceLimit, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; import { resolveResourceCanvasBottomTools, type ResourceCanvasAssetToolAction, resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; afterEach(() => { cleanup(); }); function action( overrides: Partial & Pick, ): ResourceCanvasAssetToolAction { return { route: 'asset', audioKind: null, assetName: `素材 ${overrides.label}`, promptPlaceholder: '描述要生成什么', adjustableDimensions: true, aspectRatio: '1:1', imageSize: '1K', requiresIconSpecReference: false, writesIconSpecReference: false, ...overrides, }; } const imageAction = action({ id: 'generate-image', label: '生成图片', assetKind: 'image', }); const iconSpecAction = action({ id: 'generate-spec-icon', label: '图标规范', assetKind: 'icon-spec', adjustableDimensions: false, writesIconSpecReference: true, }); const uiPrototypeAction = action({ id: 'generate-ui-prototype', label: '生成 UI 设计图', assetKind: 'ui-design', requiresIconSpecReference: true, }); const spritesheetAction = action({ id: 'generate-icon-spritesheet', label: '生成图标素材', assetKind: 'icon-spritesheet', requiresIconSpecReference: true, }); function asset( id: string, mediaType: string, localPath: string, kind = 'image', ): GameCreationAppAssetManifestEntry { return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; } function resourceReference( resourceId: string, label: string, mediaType = 'image/png', ): ChatReference { return { type: 'resource', resourceId, kind: 'image', mediaType, label, category: 'scene', tags: [], source: 'asset-picker', }; } /** 面板提交载荷里的正文:canonical content 按清单展开 `@显示名`。 */ function submittedText( input: ResourceCanvasAssetGenerationSubmitInput | undefined, assets: readonly GameCreationAppAssetManifestEntry[], ): string { return directCodexContentToPromptText( input?.content ?? [], resourceLabelResolver(assets), ); } describe('参考图模型', () => { test('只有图集不接受用户参考,图标规范与普通生成一致', () => { expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe( true, ); expect(resourceCanvasAssetGenerationAcceptsReferences(iconSpecAction)).toBe( true, ); expect( resourceCanvasAssetGenerationAcceptsReferences(uiPrototypeAction), ).toBe(true); expect( resourceCanvasAssetGenerationAcceptsReferences(spritesheetAction), ).toBe(false); }); test('用户参考上限:普通生成五张,自带规范前置的入口四张,图集零张', () => { expect(resourceCanvasAssetGenerationUserReferenceLimit(imageAction)).toBe( RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, ); expect( resourceCanvasAssetGenerationUserReferenceLimit(iconSpecAction), ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES); expect( resourceCanvasAssetGenerationUserReferenceLimit(uiPrototypeAction), ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC); expect( resourceCanvasAssetGenerationUserReferenceLimit(spritesheetAction), ).toBe(0); // 规范前置入口的四张用户参考 + 一张权威规范图,正好是总上限。 expect( RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES); }); test('候选集只留当前项目已登记的图片', () => { const candidates = resourceCanvasAssetGenerationReferenceAssets([ asset('asset-image', 'image/png', 'assets/a.png'), asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), // 原生按 `image::load_from_memory` 解码,SVG 读不出来:进了候选就是一次必然失败的付费提交。 asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), asset('asset-svg-alias', 'image/svg', 'assets/b.svg'), asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), asset('asset-remote-only', 'image/png', ' '), ]); expect(candidates.map((item) => item.id)).toEqual(['asset-image']); }); test('陈旧引用判据把矢量图与缺失文件分开报,不做静默过滤', () => { const assets = [ asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), asset('asset-no-file', 'image/png', ' '), ]; expect( resourceCanvasAssetGenerationReferenceIssue({ references: [resourceReference('asset-svg', '矢量图')], assets, }), ).toContain('矢量图'); expect( resourceCanvasAssetGenerationReferenceIssue({ references: [resourceReference('asset-no-file', '没落盘')], assets, }), ).toContain('没有本地文件'); expect( resourceCanvasAssetGenerationReferenceIssue({ references: [resourceReference('asset-missing', '已删除')], assets, }), ).toContain('已不在当前项目'); expect( resourceCanvasAssetGenerationReferenceIssue({ references: [resourceReference('asset-image', '正常图片')], assets: [asset('asset-image', 'image/webp', 'assets/a.webp')], }), ).toBeNull(); }); test('参考 ID 只取资源引用、按选择顺序去重', () => { expect( resourceCanvasAssetGenerationReferenceIds([ resourceReference('asset-a', '素材 A'), { type: 'runtime-region', label: '运行区域', resourceIds: ['asset-x'], source: 'runtime-picker', }, resourceReference('asset-b', '素材 B'), resourceReference('asset-a', '素材 A 重名'), resourceReference(' ', '空身份'), ]), ).toEqual(['asset-a', 'asset-b']); }); test('超限给出可执行原因而不是静默截断', () => { expect( resourceCanvasAssetGenerationReferenceError({ action: imageAction, referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, }), ).toBeNull(); expect( resourceCanvasAssetGenerationReferenceError({ action: imageAction, referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES + 1, }), ).toContain('最多 5 张'); expect( resourceCanvasAssetGenerationReferenceError({ action: uiPrototypeAction, referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, }), ).toContain('最多 4 张'); // 图集没有参考选择器:不存在「超限」这条用户可见反馈。 expect( resourceCanvasAssetGenerationReferenceError({ action: spritesheetAction, referenceCount: 3, }), ).toBeNull(); }); }); describe('生成面板的参考接线', () => { test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); expect(panel.textContent).toContain('参考图 1/5'); // 引用输入区与聊天 / 快速编辑同一份组件:可访问名就是「生成提示词」。 expect(screen.getByLabelText('生成提示词')).not.toBeNull(); await user.click(within(panel).getByRole('button', { name: '生成图片' })); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0]; expect(submitted?.kind).toBe('image'); // 提交载荷是那一份 canonical content:正文逐字,引用仍是稳定 resourceId。 // token 前后各留一个空白:引用在末尾时读回的正文也带着那个分隔空白。 expect(submittedText(submitted, [imageAsset])).toBe('画一只猫 @素材-a '); expect(submitted?.content).toContainEqual({ type: 'agc_resource_reference', resourceId: 'asset-a', }); }); /** * 图标规范(`icon-spec`)是验收现场被问到的那一条:「生成图标规范时没法 @」。 * * 面板里那段注释曾写成「图集与图标规范这两类入口不给选择器」,与判据不一致,容易被读成 * 「图标规范被有意排除」。这里把它钉成可执行的事实:图标规范与普通生成一致地呈现 `@` * 与参考计数,并且提交时真的把引用带出去。 */ test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '图标规范' }); expect(panel.textContent).toContain('参考图 1/5'); expect( within(panel).getByRole('button', { name: '插入素材引用' }), ).not.toBeNull(); await user.click(within(panel).getByRole('button', { name: '图标规范' })); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0]; expect(submitted?.kind).toBe('icon-spec'); expect(submitted?.content).toContainEqual({ type: 'agc_resource_reference', resourceId: 'asset-a', }); }); test('图标规范的 @ 真的能开出素材选择器(不是只画了一枚按钮)', async () => { const user = userEvent.setup(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); render( undefined} />, ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect( await screen.findByRole('dialog', { name: '选择素材' }), ).not.toBeNull(); }); /** * 反向守卫:`@` 的有无只由**一条**判据决定,不是每条入口各自决定。 * * 遍历底部工具栏里真实存在的生成入口逐条渲染面板,断言「面板里有没有 @」与 * `resourceCanvasAssetGenerationAcceptsReferences` 完全一致;并钉住唯一的例外是图集。 * 这条用例的价值在增量:以后新增一条入口忘了接 `@`,或者有人把某条入口按类型硬编码成 * 没有选择器,这里立刻会红。 */ test('底部工具栏每条生成入口的 @ 与判据完全一致,唯一例外是图集', () => { const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const actions = ( ['ui-interaction', 'character', 'scene', 'audio'] as const ).flatMap((category) => resolveResourceCanvasBottomTools(category).flatMap((tool) => resourceCanvasBottomToolActions(tool), ), ); const generationActions = actions.filter( (candidate) => candidate.route === 'asset', ); expect(generationActions.length).toBeGreaterThan(0); expect( generationActions .filter( (candidate) => !resourceCanvasAssetGenerationAcceptsReferences(candidate), ) .map((candidate) => candidate.id), ).toEqual(['generate-icon-spritesheet']); // 图标规范就在这一批入口里:这条断言同时挡住「入口被改名 / 被摘掉」的静默漂移。 expect(generationActions.map((candidate) => candidate.id)).toContain( 'generate-spec-icon', ); for (const candidate of generationActions) { const view = render( undefined} />, ); expect( screen.queryByRole('button', { name: '插入素材引用' }) !== null, `${candidate.id}(${candidate.label})`, ).toBe(resourceCanvasAssetGenerationAcceptsReferences(candidate)); // 说明行与选择器互补:有 @ 就不该出现「为什么不给 @」,反之必须在场。 expect( screen.queryByText(/不支持另挑参考图/u) !== null, `${candidate.id}(${candidate.label})的参考口径说明`, ).toBe(!resourceCanvasAssetGenerationAcceptsReferences(candidate)); view.unmount(); } }); test('图集入口不呈现参考选择,只提交纯提示词', async () => { const user = userEvent.setup(); const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图标素材' }); /* * 「不呈现参考选择」的判据落在**计数与选择器**上,不落在「正文里出现过『参考图』三个字」上: * 这一档现在会有一段说明行解释为什么不给 `@`(「…不支持另挑参考图…」),按词匹配会把 * 说明行也一起判失败。 */ expect( panel.querySelector('[data-resource-canvas-generation-reference-count]'), ).toBeNull(); expect(panel.textContent).not.toMatch(/参考图\s*\d+\s*\/\s*\d+/u); const prompt = screen.getByLabelText('生成提示词'); expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA'); /* * 不给 `@` 的入口必须当场说清为什么:验收现场那条「生成图标素材没法 @」读起来像漏了一个 * 按钮,真实原因却在那条平台通道上(只有一张权威规范图 `referenceId` + `iconDescriptions`, * 客户端对图集的用户参考是显式拒绝)。说明行带 `data-*` 标记,便于逐条入口对照判据。 */ expect( panel.querySelector('[data-resource-canvas-generation-reference-free]') ?.textContent, ).toContain('权威规范图'); await user.click( within(panel).getByRole('button', { name: '生成图标素材' }), ); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0]; expect(submitted?.kind).toBe('icon-spritesheet'); // 面板逐字交出用户输入(不在这里裁剪空白);收边规范化在出站任务落账那一处, // 见 `resourceCanvasAssetGenerationTaskModel` 的用例。 expect(submitted?.content).toEqual([ { type: 'input_text', text: ' 金币\n\n宝箱\t钥匙 ' }, ]); }); test.each([ ['😀'.repeat(200), true], ['😀'.repeat(201), false], ['\u0085金币\u0085', true], ['\u0085\n ', false], ['\uFEFF', true], ])( '图集描述按 API 的 Unicode 字符与空白语义校验(%#)', (prompt, accepted) => { const onSubmit = vi.fn(); render( undefined} />, ); const input = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: prompt } }); expect(input.value).toBe(prompt); expect(input.hasAttribute('maxlength')).toBe(false); const button = screen.getByRole('button', { name: '生成图标素材', }) as HTMLButtonElement; expect(button.disabled).toBe(!accepted); fireEvent.submit(input.closest('form')!); if (accepted) { // 面板不只管「能不能提交」:交出去的还是用户原样输入的那份 content。 expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ content: [{ type: 'input_text', text: prompt }], }), ); } else { expect(onSubmit).not.toHaveBeenCalled(); if (Array.from(prompt).length > 200) { expect( screen.getByText('生成提示词最多 200 个字符,当前 201 个'), ).not.toBeNull(); } } }, ); test('点提示词输入区不该自己弹出素材选择框', async () => { const user = userEvent.setup(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); render( undefined} />, ); await user.click(screen.getByLabelText('生成提示词')); expect(screen.queryByRole('dialog', { name: '选择素材' })).toBeNull(); // 只有「插入素材引用」这一枚按钮才打开选择器。 await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect( await screen.findByRole('dialog', { name: '选择素材' }), ).not.toBeNull(); }); test('紧凑版排布:动作行同时装下润色 / 参考计数 / 两个动作,参数与提示词各占一行', () => { const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); render( undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成图片' }); /* 验收现场那张图里面板要滚动:标签各占一行、提示词 6 行、比例与尺寸各占一行、润色与参考 计数又各占一行。这里钉住重排后的形状——动作行一行收口、参数两栏并排、提示词三行, 面板高度因此回落到几何上界以内(滚动条不该再出现)。 */ const actions = panel.querySelector('.game-resource-generation-actions'); expect(actions).not.toBeNull(); expect( within(actions as HTMLElement).getByRole('button', { name: 'AI 润色' }), ).not.toBeNull(); expect( within(actions as HTMLElement).getByText('参考图 0/5'), ).not.toBeNull(); expect( within(actions as HTMLElement).getByRole('button', { name: '取消' }), ).not.toBeNull(); expect( within(actions as HTMLElement).getByRole('button', { name: '生成图片' }), ).not.toBeNull(); // 比例与尺寸同处一格(CSS 里排成两栏),不再各占一行。 const dimensions = panel.querySelectorAll( '.resource-canvas-asset-generation-dimensions', ); expect(dimensions).toHaveLength(1); // 两组选择器(比例 5 项、尺寸 3 项)同处这一格,CSS 里排成两栏。 expect( within(dimensions[0] as HTMLElement).getByRole('button', { name: '生成图片比例 1:1', }), ).not.toBeNull(); expect( within(dimensions[0] as HTMLElement).getByRole('button', { name: '生成图片尺寸 1K', }), ).not.toBeNull(); }); test('超限时提交被挡住并给出原因', async () => { const user = userEvent.setup(); const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); render( resourceReference(`asset-${id}`, `素材 ${id}`), ), }), assetName: 'UI', aspectRatio: '16:9', imageSize: '1K', }} onSubmit={onSubmit} onClose={() => undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); expect(panel.textContent).toContain('参考图 5/4'); const submit = within(panel).getByRole('button', { name: '生成 UI 设计图', }); expect((submit as HTMLButtonElement).disabled).toBe(true); await user.click(submit); expect(onSubmit).not.toHaveBeenCalled(); }); });