Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx
T
k88936 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
修复 AGC 前端资源 kind 判据与用例的 canonical 残留
- 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 成员重写。
2026-09-19 03:21:37 +08:00

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();
});
});