1079f6c91b
- ResourceClassificationPanel.tsx:加素材类型(功能分类)选择器,与标签同一次保存、同一条写入路径;选项 = GAME_CREATION_APP_ASSET_CATEGORIES × resourceReferenceCategoryLabel,不新造第二套中文译名 - ResourceClassificationPanel.tsx:选择器读显示口径(与画布栏目 projectResourceAssetCategory 同源,选中项就是卡片所在栏目);写回分两路——没碰过类型控件回传 gameCreationAppAssetPersistedCategory 的落盘原值,主动选过才写用户选的值(categoryChoice ?? persisted),保证「只改标签」不漂移分类 - index.tsx:资源卡右上角角标由「文件/媒体类型」改为「资源类型」,取 categoryLabels[resource.category](栏目与筛选共用的同一份文案),角标恒等于该卡所在栏目;projectResourceTypeLabel 仍服务资源管理面板小字与总览摞分列,不再用于角标 - tests/resourceClassificationPanel.test.tsx:新增「变更素材类型」6 条用例(选择器 6 项与中文口径、选择器显示显示口径自愈值、主动选值写入载荷、只改标签回传落盘原值对照、显式待归类写入 unclassified、连续两次各带当次选择且不关窗),并按新增控件更新既有按钮序与「分类不再由面板编辑」两条断言 - tests/projectResourceLiveIntegration.test.tsx:新增真宿主跟随链用例(角标显示资源类型并与栏目同名、改类型后卡片进目标栏目且角标跟着变、原栏目不再有它),配套状态化宿主 fixture、分类写入与 get_local_game_manifest 两条命令桩、切栏目与读角标助手 - tests/appSurface/project-development.suite.ts:原 data-resource-type 媒体类型断言改为资源类型(角色与对象 / 待归类 / 音频),并说明卡面正文走媒体分支、角标走资源类型这两个独立轴 - docs:PRD §5.3 由「分类没有用户手动设置入口」改为当前两口径分叉(选择器读显示口径、写回按是否主动选过分流)与「显式待归类被读时自愈覆盖」盲区 - docs:AGC 资源工作台 V3 端到端验收 S5 的「前端交互不能改分类」改写为可改分类,补判据(角标读 .game-resource-card-type-badge)与盲区说明;实施计划写入命令一节的面板名与职责同步为「编辑素材标签(标签 + 素材类型)」 - docs:decision-log 新增条目记录本次决策(Rust 无需改动、画布跟随链无需额外迁移代码、待归类盲区、四条变异验证结果与一条不可构造的变异) - 说明:Rust、manifest 字段构成与 SpacetimeDB 均不改;写入命令 update_local_project_resource_classification 早已接受任意合法 category
738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
import {
|
||
cleanup,
|
||
fireEvent,
|
||
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;
|
||
} = {},
|
||
) {
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={overrides.asset ?? asset}
|
||
onClose={overrides.onClose ?? vi.fn()}
|
||
onSaved={overrides.onSaved ?? 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.queryByRole('button', { name: '保存标签' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '删除资源' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||
// 「管理全部标签」没有实现。
|
||
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
|
||
// 素材类型(功能分类)重新有了用户入口:6 个合法分类各一项。
|
||
for (const label of [
|
||
'UI 交互',
|
||
'角色与对象',
|
||
'场景与环境',
|
||
'音频',
|
||
'文档',
|
||
'待归类',
|
||
]) {
|
||
expect(screen.getByRole('button', { name: label })).not.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('删掉 pill 之后只关面板不写盘:撤销不需要额外按钮', 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: ['节日', '新春'],
|
||
},
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 漂移防线:落盘 `unclassified` 而 `kind` 能派生出明确分类时,读显示口径会自愈成
|
||
* `ui-interaction`,但**回写必须是落盘原值** `unclassified`。否则「编辑标签」面板
|
||
* 会在用户只改标签时静默改掉分类——这正是真机上同一条 `kind:"ui"` 资产同时出现
|
||
* `unclassified` 与 `ui-interaction` 两种落盘值的成因。
|
||
*
|
||
* 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
|
||
*/
|
||
test('添加标签不改动落盘 category:自愈值不得被回写', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
return {
|
||
asset: { ...asset, kind: 'ui', category: 'unclassified' },
|
||
committedProjectRevision: 8,
|
||
};
|
||
});
|
||
renderPanel({
|
||
asset: {
|
||
...asset,
|
||
kind: 'ui',
|
||
category: 'unclassified',
|
||
tags: [],
|
||
},
|
||
});
|
||
|
||
await user.type(
|
||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||
'界面',
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
const [, args] = classificationWrites(invoke)[0]!;
|
||
// 读显示口径下该资产是 ui-interaction;回写口径必须是落盘值 unclassified。
|
||
expect(args).toEqual({
|
||
input: {
|
||
projectPath: 'C:/project',
|
||
expectedProjectId: 'project-1',
|
||
expectedProjectRevision: 7,
|
||
assetId: 'asset-hero',
|
||
category: 'unclassified',
|
||
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([
|
||
'主舞台',
|
||
'日夜',
|
||
]);
|
||
});
|
||
|
||
/**
|
||
* 「添加」的语义包含**输入框里还没按回车的尾巴**:用户打了字直接点按钮,不能白输入。
|
||
*
|
||
* 这里刻意用 `fireEvent.click` 而不是 `user.click`:前者不会先让输入框失焦,所以只有
|
||
* 「点击处理器自己把草稿并进来」这一条路能让用例通过
|
||
* (`onBlur` 的 `commitTagDraft` 兜不住它)。
|
||
*
|
||
* 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。
|
||
*/
|
||
test('点「添加」把没按回车的尾巴一起落成 pill 并保存,且不自己关窗', async () => {
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
return { asset, committedProjectRevision: 8 };
|
||
});
|
||
const onClose = vi.fn();
|
||
const onSaved = vi.fn();
|
||
renderPanel({
|
||
asset: { ...asset, tags: ['主页'] },
|
||
onClose,
|
||
onSaved,
|
||
});
|
||
|
||
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
|
||
fireEvent.change(field, { target: { value: '未回车的尾巴' } });
|
||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||
|
||
// 落 pill 是即时反馈,保存是这一步的语义本身。
|
||
await waitFor(() => {
|
||
expect(pillLabels()).toEqual(['主页', '未回车的尾巴']);
|
||
});
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
const [, args] = classificationWrites(invoke)[0]!;
|
||
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
|
||
'主页',
|
||
'未回车的尾巴',
|
||
]);
|
||
// 保存成功后面板自己不能关窗(关窗是头部 × 与宿主的职责),输入框清空便于接着加。
|
||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
|
||
expect((field as HTMLInputElement).value).toBe('');
|
||
});
|
||
|
||
/**
|
||
* 「添加」是可连续执行的动作:点第二次必须真的再写一次盘(各自保存),
|
||
* 第二次的载荷要带上累计标签,分类仍原样回传落盘原值。
|
||
*
|
||
* 变异验证:让「添加」只在第一次写盘(例如保存后把按钮禁用不再恢复),本用例必须失败。
|
||
*/
|
||
test('连续两次「添加」各自保存,第二次带上累计标签', async () => {
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
return { asset, committedProjectRevision: 8 };
|
||
});
|
||
const onClose = vi.fn();
|
||
renderPanel({ asset: { ...asset, tags: [] }, onClose });
|
||
|
||
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
|
||
const add = () => screen.getByRole('button', { name: '添加' });
|
||
|
||
fireEvent.change(field, { target: { value: '主角' } });
|
||
fireEvent.click(add());
|
||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
|
||
|
||
fireEvent.change(field, { target: { value: '待定稿' } });
|
||
fireEvent.click(add());
|
||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
|
||
|
||
const secondInput = (
|
||
classificationWrites(invoke)[1]?.[1] as {
|
||
input: { tags: string[]; category: string };
|
||
}
|
||
).input;
|
||
expect(secondInput.tags).toEqual(['主角', '待定稿']);
|
||
// 分类不是这个面板的编辑对象:两次写入都回传同一条落盘原值。
|
||
expect(secondInput.category).toBe('character');
|
||
expect(pillLabels()).toEqual(['主角', '待定稿']);
|
||
expect((field as HTMLInputElement).value).toBe('');
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
|
||
});
|
||
|
||
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/);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
|
||
*
|
||
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
|
||
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `category` 必须仍是**落盘原值**,
|
||
* 不能等于选择器显示的读显示值。
|
||
*
|
||
* 任何一边被改成另一边的口径,都会有一条例用变红,见各用例上的「变异验证」。
|
||
*/
|
||
describe('ResourceClassificationPanel 变更素材类型', () => {
|
||
/**
|
||
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
|
||
* 也正是"选择器显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
|
||
*/
|
||
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
|
||
...asset,
|
||
id: 'asset-ui',
|
||
kind: 'ui',
|
||
category: 'unclassified',
|
||
tags: [],
|
||
};
|
||
|
||
function installClassificationInvoke() {
|
||
return installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
if (command === 'update_local_project_resource_classification') {
|
||
return { asset, committedProjectRevision: 8 };
|
||
}
|
||
throw new Error(`unexpected command: ${command}`);
|
||
});
|
||
}
|
||
|
||
function segmentedTabItem(label: string) {
|
||
return screen
|
||
.getByRole('button', { name: label })
|
||
.closest('.platform-segmented-tabs');
|
||
}
|
||
|
||
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
|
||
installInvoke(async () => undefined);
|
||
renderPanel({ asset: selfHealingAsset });
|
||
|
||
for (const label of [
|
||
'UI 交互',
|
||
'角色与对象',
|
||
'场景与环境',
|
||
'音频',
|
||
'文档',
|
||
'待归类',
|
||
]) {
|
||
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
|
||
expect(segmentedTabItem(label)).not.toBeNull();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`,
|
||
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」。
|
||
*
|
||
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
|
||
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位。
|
||
*/
|
||
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
|
||
installInvoke(async () => undefined);
|
||
renderPanel({ asset: selfHealingAsset });
|
||
|
||
expect(
|
||
screen
|
||
.getByRole('button', { name: 'UI 交互' })
|
||
.getAttribute('aria-pressed'),
|
||
).toBe('true');
|
||
expect(
|
||
screen
|
||
.getByRole('button', { name: '待归类' })
|
||
.getAttribute('aria-pressed'),
|
||
).toBe('false');
|
||
});
|
||
|
||
/**
|
||
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
|
||
*
|
||
* 变异验证:把写入改回"永远回传落盘原值"
|
||
* (`category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`,
|
||
* 本用例必须失败。
|
||
*/
|
||
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installClassificationInvoke();
|
||
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
|
||
|
||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
|
||
input: {
|
||
projectPath: 'C:/project',
|
||
expectedProjectId: 'project-1',
|
||
expectedProjectRevision: 7,
|
||
assetId: 'asset-ui',
|
||
category: 'scene',
|
||
tags: [],
|
||
},
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`,
|
||
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
|
||
*
|
||
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
|
||
*
|
||
* 变异验证:把写入改成"永远回传读显示口径"(`gameCreationAppAssetCategory(asset)`),
|
||
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
|
||
* 本用例必须失败。
|
||
*/
|
||
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installClassificationInvoke();
|
||
renderPanel({ asset: selfHealingAsset });
|
||
|
||
expect(
|
||
screen
|
||
.getByRole('button', { name: 'UI 交互' })
|
||
.getAttribute('aria-pressed'),
|
||
).toBe('true');
|
||
|
||
await user.type(
|
||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||
'界面',
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
|
||
input: {
|
||
projectPath: 'C:/project',
|
||
expectedProjectId: 'project-1',
|
||
expectedProjectRevision: 7,
|
||
assetId: 'asset-ui',
|
||
category: 'unclassified',
|
||
tags: ['界面'],
|
||
},
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 显式选「待归类」写下去的就是 `unclassified`。
|
||
*
|
||
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺:读显示口径会把
|
||
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
|
||
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
|
||
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
|
||
*/
|
||
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installClassificationInvoke();
|
||
renderPanel({ asset: { ...asset, category: 'character' } });
|
||
|
||
await user.click(screen.getByRole('button', { name: '待归类' }));
|
||
await user.click(screen.getByRole('button', { name: '添加' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
const input = (
|
||
classificationWrites(invoke)[0]?.[1] as {
|
||
input: { category: string };
|
||
}
|
||
).input;
|
||
expect(input.category).toBe('unclassified');
|
||
});
|
||
|
||
/**
|
||
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
|
||
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
|
||
*
|
||
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
|
||
* `category` 会退回落盘值,本用例必须失败。
|
||
*/
|
||
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
|
||
const invoke = installClassificationInvoke();
|
||
const onClose = vi.fn();
|
||
renderPanel({ asset: selfHealingAsset, onClose });
|
||
|
||
const add = () => screen.getByRole('button', { name: '添加' });
|
||
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
|
||
fireEvent.click(add());
|
||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '音频' }));
|
||
fireEvent.click(add());
|
||
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
|
||
|
||
expect(
|
||
classificationWrites(invoke).map(
|
||
([, args]) => (args as { input: { category: string } }).input.category,
|
||
),
|
||
).toEqual(['character', 'audio']);
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ResourceClassificationPanel 不再承载删除素材', () => {
|
||
/**
|
||
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
|
||
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
|
||
*
|
||
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」,
|
||
* 多出任何一个按钮都会红。
|
||
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
|
||
*/
|
||
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
|
||
const invoke = installInvoke(async (command) => {
|
||
throw new Error(`unexpected command: ${command}`);
|
||
});
|
||
renderPanel();
|
||
|
||
expect(
|
||
screen
|
||
.getAllByRole('button')
|
||
.map(
|
||
(button) => button.getAttribute('aria-label') ?? button.textContent,
|
||
),
|
||
).toEqual([
|
||
'关闭编辑素材标签',
|
||
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
|
||
'UI 交互',
|
||
'角色与对象',
|
||
'场景与环境',
|
||
'音频',
|
||
'文档',
|
||
'待归类',
|
||
'删除标签 主角',
|
||
'添加',
|
||
]);
|
||
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
|
||
expect(invoke).not.toHaveBeenCalled();
|
||
});
|
||
|
||
/**
|
||
* 底部只有一个「添加」且靠右下角。
|
||
*
|
||
* 变异验证:把 `justify-content` 改回默认(或改 `flex-start`),本用例必须失败。
|
||
*/
|
||
test('底部只有一个「添加」并靠右下角', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const rule = styles.match(
|
||
/\.game-resource-classification-footer\s*\{([^}]*)\}/s,
|
||
)?.[1];
|
||
expect(rule).toBeDefined();
|
||
expect(rule).toMatch(/display:\s*flex/);
|
||
expect(rule).toMatch(/justify-content:\s*flex-end/);
|
||
|
||
installInvoke(async () => undefined);
|
||
renderPanel();
|
||
|
||
const footer = document.querySelector(
|
||
'.game-resource-classification-footer',
|
||
);
|
||
expect(footer).not.toBeNull();
|
||
expect(within(footer as HTMLElement).getAllByRole('button')).toHaveLength(
|
||
1,
|
||
);
|
||
});
|
||
});
|