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