Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx
T
suzmii 7b57cba14d 补齐「图集(生成图标素材)为什么没有 @」的当面说明与依据
- 生成浮层:不给 @ 选择器的入口新增一段说明行(`data-resource-canvas-generation-reference-free`),讲清「该入口走平台图集通道,参考只有一张权威规范图(图标规范),不支持另挑参考图」——验收现场那条「生成图标素材没法 @」读起来像漏了按钮,原因其实在通道上。
- 样式:说明行允许折行、沿用次要文字色;与既有的「参考图 n/5」计数行区分开。
- 用例:图集入口断言说明行在场;逐条入口断言「说明行」与「@ 选择器」严格互补;原「不呈现参考选择」的判据从按词匹配改成按计数元素/计数值匹配(说明行本身会提到「参考图」三个字)。
- 文档:入口矩阵补「图集为什么没有 @」一条,写明图集走 `icon-spritesheets` 通道、提交体只有 referenceId + iconDescriptions(无 referenceImageSrcs)、结果绑定硬校验参考恰好一张,以及「平台 OpenAPI 该请求 schema 有 referenceImageSrcs 字段,放开属于产品 + 平台口径变更」。
2026-09-21 12:23:32 +08:00

565 lines
20 KiB
TypeScript
Raw 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, 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,
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<ResourceCanvasAssetToolAction> &
Pick<ResourceCanvasAssetToolAction, 'id' | 'label' | 'assetKind'>,
): 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',
};
}
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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
// 重开草稿的提示词与引用是同一次编辑的产物:`@显示名` 已经在正文里。
prompt: '画一只猫 @素材-a',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => 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);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'image',
prompt: '画一只猫 @素材-a',
references: [expect.objectContaining({ 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(
<ResourceCanvasAssetGenerationPanelView
action={iconSpecAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '按这套界面风格出图标规范 @素材-a',
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => 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);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'icon-spec',
references: [expect.objectContaining({ resourceId: 'asset-a' })],
});
});
test('图标规范的 @ 真的能开出素材选择器(不是只画了一枚按钮)', async () => {
const user = userEvent.setup();
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
render(
<ResourceCanvasAssetGenerationPanelView
action={iconSpecAction}
assets={[imageAsset]}
projectPath="/tmp/project"
onSubmit={vi.fn()}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={candidate}
assets={[imageAsset]}
projectPath="/tmp/project"
onSubmit={vi.fn()}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={spritesheetAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: '一套敌人头像',
assetName: '图标素材',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={onSubmit}
onClose={() => 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);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'icon-spritesheet',
references: [],
});
});
test('点提示词输入区不该自己弹出素材选择框', async () => {
const user = userEvent.setup();
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
render(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: '主界面',
assetName: 'UI',
aspectRatio: '16:9',
imageSize: '1K',
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
resourceReference(`asset-${id}`, `素材 ${id}`),
),
}}
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();
});
});