a11cdf13d7
生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[] 重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch directCodexContentToLegacyContentDto 只在任务落账的出站边界派生 账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现 token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用 无法解析的资源引用合成 unknown 占位而不再静默丢弃 出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用 directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定 同步决策记录与 canonical content 里程碑落地结果
645 lines
23 KiB
TypeScript
645 lines
23 KiB
TypeScript
// @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,
|
||
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',
|
||
};
|
||
}
|
||
|
||
/** 面板提交载荷里的正文:canonical content 按清单展开 `@显示名`。 */
|
||
function submittedText(
|
||
input: ResourceCanvasAssetGenerationSubmitInput | undefined,
|
||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||
): string {
|
||
return directCodexContentToPromptText(input?.content ?? [], 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(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={imageAction}
|
||
assets={[imageAsset]}
|
||
projectPath="/tmp/project"
|
||
draft={{
|
||
// 重开草稿就是面板自己那份 canonical content(不是 prompt + references 双轨):
|
||
// 这里用 legacy DTO 的翻译函数把「正文 `@显示名` + 引用」还原成同一份 content。
|
||
content: legacyContentDtoToContent({
|
||
text: '画一只猫 @素材-a',
|
||
references,
|
||
}),
|
||
assetName: '猫',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
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);
|
||
const submitted = onSubmit.mock.calls[0]?.[0];
|
||
expect(submitted?.kind).toBe('image');
|
||
// 提交载荷是那一份 canonical content:正文逐字,引用仍是稳定 resourceId。
|
||
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(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={iconSpecAction}
|
||
assets={[imageAsset]}
|
||
projectPath="/tmp/project"
|
||
draft={{
|
||
content: legacyContentDtoToContent({
|
||
text: '按这套界面风格出图标规范 @素材-a',
|
||
references,
|
||
}),
|
||
assetName: '图标规范',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
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);
|
||
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(
|
||
<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={{
|
||
content: [{ type: 'input_text', text: ' 金币\n\n宝箱\t钥匙 ' }],
|
||
assetName: '图标素材',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
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);
|
||
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(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={spritesheetAction}
|
||
draft={{
|
||
content: [],
|
||
assetName: '图标素材',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
onSubmit={onSubmit}
|
||
onClose={() => 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(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={imageAction}
|
||
assets={[imageAsset]}
|
||
projectPath="/tmp/project"
|
||
draft={{
|
||
content: [],
|
||
assetName: '猫',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
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={{
|
||
content: [],
|
||
assetName: '猫',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}}
|
||
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={{
|
||
// 五条参考里只有 asset-a 还在清单:草稿自己的 content 不因此被裁,
|
||
// 计数按 content 里的引用节点算,超限照样挡住提交。
|
||
content: legacyContentDtoToContent({
|
||
text: '主界面',
|
||
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
|
||
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();
|
||
});
|
||
});
|