6bdc8bbd93
- 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 明文
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
// @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();
|
||
});
|
||
});
|