Files
Genarrative/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx
T
suzmii 6bdc8bbd93 AGC 资源分类面板改为「编辑素材标签」:只编辑标签、分类不再有手动入口
- ResourceClassificationPanel.tsx:面板改为只编辑 manifest `assets[].tags`,移除分类 6 类
  chip 那一排;标题与 aria-label 改「编辑素材标签」,副标题由资源路径改为素材名
  (`localPath` 的 basename,仓库没有独立显示名字段)
- ResourceClassificationPanel.tsx:标签状态由「整段逗号分隔字符串」改为字符串数组,
  每个已有标签渲染成自带删除按钮的胶囊 pill;保存时把未落成 pill 的输入尾巴一并切分,
  归一化仍走 `normalizeGameCreationAppAssetTags`,回车 / 中英文逗号 / 顿号提交方式不变
- ResourceClassificationPanel.tsx:写入命令 `category` 是必填,读一次当前权威值并
  在保存时原样回传,保证「只改标签」不漂移分类;底部按钮「保存」改「保存标签」,
  删除资源入口按既有保留(截图未画,但它是唯一的资源删除入口)
- resourceClassificationTagPanel.css:新增标签 pill 样式,删除按钮嵌在 pill 内部
  (视觉图标 11px、热区 32×32、负外边距抵消不撑高 pill);若第二处出现带删除按钮的
  标签 pill,抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现
- index.tsx:资源卡工具条入口 label / title / 文案由「分类与标签」改「编辑标签」,
  与面板标题一致(否则入口写着分类、打开的是纯标签面板)
- tests/resourceClassificationPanel.test.tsx:断言对齐截图文案、逐个 pill 自带删除、
  点某个 × 只删对应标签、aria-label 可区分、保存 payload 的 tags 数组与 category 原样回传、
  取消不写盘、入口 label 与面板标题一致、删除热区不小于 32px
- 文档:PRD §5.3「分类取值优先级」改写为当前状态(分类没有用户手动设置入口、
  该面板只编辑标签),并新增 decision-log 条目记录本次产品决策与「用户手动设
  category 的能力就此移除」这一事实
- 说明:截图文案(编辑素材标签 / 新增标签,多个用逗号分隔 / 保存标签)在全仓检索
  无命中,属仓库之外来源,本次按用户截图对齐,不是 PRD 明文
2026-09-11 15:34:36 +08:00

545 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
cleanup,
render,
screen,
waitFor,
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 { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
category: 'character',
tags: ['主角'],
};
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function renderPanel(
overrides: {
asset?: GameCreationAppAssetManifestEntry;
onClose?: () => void;
onSaved?: (result: unknown) => void;
onDeleted?: (result: unknown) => void;
} = {},
) {
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
onDeleted={overrides.onDeleted ?? vi.fn()}
/>,
);
}
/** 已有标签按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */
function pillLabels() {
const list = screen.queryByRole('list', { name: '已有标签' });
if (!list) return [];
return within(list)
.getAllByRole('listitem')
.map((item) => item.textContent?.replace('×', '').trim());
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('ResourceClassificationPanel 编辑素材标签', () => {
test('对齐用户截图:标题、素材名副标题、标签占位与底部按钮', () => {
installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
});
expect(
screen.getByRole('heading', { name: '编辑素材标签' }),
).not.toBeNull();
// 副标题是素材名(localPath 的 basename),不再是资源路径。
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
expect(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
).not.toBeNull();
expect(screen.getByRole('button', { name: '保存标签' })).not.toBeNull();
expect(screen.getByRole('button', { name: '取消' })).not.toBeNull();
// 「管理全部标签」没有实现,分类也不再由这个面板编辑。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull();
expect(screen.queryByRole('button', { name: '场景与环境' })).toBeNull();
});
test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => {
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
expect(pillLabels()).toEqual(['主页', '节日']);
// 删除按钮在 pill 内部(不是飘在外面的独立图标)。
const pill = screen.getByRole('button', { name: '删除标签 主页' });
expect(pill.closest('.game-resource-tag-pill')).not.toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('点某个 pill 的删除按钮只删掉那一个标签', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
expect(screen.queryByRole('button', { name: '删除标签 主页' })).toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('删除在保存前可撤销:点取消不写盘', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const invoke = installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, tags: ['主页', '节日'] },
onClose,
});
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
await user.click(screen.getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(classificationWrites(invoke)).toHaveLength(0);
});
test('新增标签沿用同一套提交方式:回车 / 中英文逗号 / 顿号都切分', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, '主页,');
expect(pillLabels()).toEqual(['主页']);
await user.type(field, '节日,');
expect(pillLabels()).toEqual(['主页', '节日']);
await user.type(field, '新春{Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
// 纯空白草稿不会落成空标签。
await user.type(field, ' {Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
});
test('保存时把剩余标签写成数组,并原样回传读到的分类', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return {
asset: { ...asset, tags: ['节日', '新春'] },
committedProjectRevision: 8,
};
}
throw new Error(`unexpected command: ${command}`);
});
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'新春',
);
await user.click(screen.getByRole('button', { name: '保存标签' }));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-hero',
// 本面板不编辑分类:读到的权威值原样回传,不因为只改标签而漂移。
category: 'character',
tags: ['节日', '新春'],
},
});
});
test('归一化口径不变:重复与空白标签在保存前被收敛', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, ' 主舞台 ,日夜,主舞台、');
await user.click(screen.getByRole('button', { name: '保存标签' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主舞台',
'日夜',
]);
});
test('surfaces the native rejection without reporting a save', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
throw '非法资源分类:future-category';
});
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '保存标签' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
expect(onSaved).not.toHaveBeenCalled();
});
test('does not attempt a write outside the native client', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '保存标签' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
expect(onSaved).not.toHaveBeenCalled();
});
test('资源卡工具条入口 label 与面板标题一致,不再是「分类与标签」', () => {
const viewSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
// 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。
expect(viewSource).toContain('label="编辑标签"');
expect(viewSource).toContain('title="编辑标签"');
expect(viewSource).toContain('<span>编辑标签</span>');
expect(viewSource).not.toContain('label="分类与标签"');
});
test('标签 pill 的删除按钮热区不小于 32px', () => {
const panelStyles = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css',
),
'utf8',
);
const rule = panelStyles.match(
/\.game-resource-tag-remove\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
// 视觉图标可以小,热区不能小:这是"看得到却点不中"的防线。
expect(rule).toMatch(/width:\s*32px/);
expect(rule).toMatch(/height:\s*32px/);
expect(rule).toMatch(/flex:\s*0 0 32px/);
});
});
describe('ResourceClassificationPanel 删除资源', () => {
test('deletes the asset registration only after the confirmation panel is submitted', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
// 删除入口只打开确认面板,不得直接发起删除。
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(onDeleted).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: 'C:/project', assetId: 'asset-hero' },
});
expect(
invoke.mock.calls.some(
([command]) => command === 'delete_local_project_asset',
),
).toBe(false);
// 未被任何版本引用时不出现连带删除勾选。
expect(
screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 9,
assetId: 'asset-hero',
deleteReferencedVersions: false,
},
});
expect(onDeleted.mock.calls[0]?.[0]).toEqual({
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
});
});
test('lists the versions using the asset and keeps the cascade option unchecked', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
{
versionId: 'agent-4',
projectRevision: 4,
createdAt: 1_760_003_600,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(screen.getByText('被 2 个游戏版本使用')).toBeTruthy();
expect(screen.getByText('initial-1')).toBeTruthy();
expect(screen.getByText('agent-4')).toBeTruthy();
const cascade = screen.getByRole('checkbox', {
name: '把相关游戏版本一并删除',
});
expect((cascade as HTMLInputElement).checked).toBe(false);
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: false }),
}),
);
});
test('requests the cascade delete only when the user checks the option', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(
screen.getByRole('checkbox', { name: '把相关游戏版本一并删除' }),
);
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
test('surfaces the native rejection without reporting a delete', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw 'project-revision-conflict';
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
await user.click(screen.getByRole('button', { name: '删除资源' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain(
'project-revision-conflict',
);
expect(onDeleted).not.toHaveBeenCalled();
});
});