47f7cb2bf1
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m1s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m6s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m14s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m52s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / Repository checks (pull_request) Successful in 4m44s
Project CI / Frontend tests (pull_request) Successful in 6m20s
Project CI / Backend tests (pull_request) Successful in 8m27s
Project CI / Native shell tests (pull_request) Successful in 8m15s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m10s
- resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts:「图集不接受用户参考」判据改用 canonical icon-spritesheet,删除残留平台词的字符串清单。 - view/project-development/index.tsx:美术摘要的图片产出判据显式排除 ui-design,候选 UI 原型图不再顶掉「仅完成计划」。 - tests/appSurface/project-development.suite.ts:generateCall 支持按素材名定位,规范 / 图集 / UI 设计图入口断言改用 canonical kind。 - tests/appSurface/project-assets.suite.ts:/asset-register 与开发面板用例改用 canonical kind,面板默认值按 unknown 断言。 - tests/resourceCanvasAssetGenerationReferences.test.tsx:夹具与提交断言改用 icon-spritesheet / ui-design。 - docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md:载荷表与规范通道说明改用 canonical kind,并写明平台请求词汇只留在 source.generationKind。 - docs/project-memory/shared-memory/pitfalls.md:记录前端 kind 判据与 fixture 必须一起按 canonical 成员重写。
335 lines
11 KiB
TypeScript
335 lines
11 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 type { ResourceCanvasAssetToolAction } 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' })],
|
|
});
|
|
});
|
|
|
|
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.textContent).not.toContain('参考图');
|
|
const prompt = screen.getByLabelText('生成提示词');
|
|
expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA');
|
|
|
|
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 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();
|
|
});
|
|
});
|