7e009a4c94
- 新增 features/project-workspace/reference-source(types + 资源/Skill/附件/运行画面区域四个 provider 工厂),输入区按 providers 顺序取第一个非空回答,不再判断引用种类 - ResourceReferenceInput 删除 assets / versions / activeVersionId / skills / showTriggerButton / openPicker,改为 providers / inputActions / submitSuppressed / containerRef 四个通用接缝 - 新增 ResourceReferencePicker 与 ResourceReferencePickerAction:面板自己拿数据(素材、版本、缩略图预览 invoke)并自管筛选态,由宿主渲染,确认后只经 insertReferences 交回输入区 - 四个宿主改为按需注入 provider 并各自渲染选择器:DirectProject 注入资源+Skill+附件+运行区域,策划输入盒 / 画布生成面板 / 资源卡快速编辑只注入资源+两个静默 provider,非 DirectProject 宿主不再出现 $ Skill 候选 - 附件并入 ChatReference,编辑器收敛为单一 ResourceReferenceNode(删除 AttachmentReferenceNode),附件 chip 的 DOM 契约(data-attachment-reference / data-attachment-status / title / 移除按钮 aria-label)逐字保留 - 附件改为导入成功即以芯片进入正文、失败与异常一律不插入;控制器删除待发附件状态与 removeAttachment,ComposerPendingAttachments 与其提交时拼接一并删除,单次上限按草稿中的附件芯片数计算,导入进行中禁止发送 - directCodexContentToPromptText 的引用 token 前后各留一个空白(不重复),显示名反查改由调用方注入 resourceLabelResolver(assets),并补 TODO:后续改为按组件渲染引用 - 队列 chip 文案、快速编辑出站提示词、润色回填的引用恢复统一走 joinMentionText 的同一份 token 口径 - 新增 tests/referenceSourceProviders.test.ts 规则矩阵单测(触发符、候选过滤与截断、toReference、refresh、mentionToken、Skill 目录懒加载与失败重试),既有输入区/聊天用例迁移为注入 provider - styles.css 删除随面板外移与待发附件列表一起失效的规则
650 lines
24 KiB
TypeScript
650 lines
24 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,
|
||
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<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 ?? [],
|
||
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(
|
||
<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。
|
||
// 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(
|
||
<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();
|
||
});
|
||
});
|