dc243b0d57
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标 - 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路 - 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」 - 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径 - 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变) - 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染 - 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除 - 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 × - 标签 pill 内的删除标签按钮保持不变 - 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
455 lines
16 KiB
TypeScript
455 lines
16 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();
|
||
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('删掉 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 };
|
||
});
|
||
renderPanel({ asset: { ...asset, tags: ['主页'] } });
|
||
|
||
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([
|
||
'主页',
|
||
'未回车的尾巴',
|
||
]);
|
||
});
|
||
|
||
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 不再承载删除素材', () => {
|
||
/**
|
||
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
|
||
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
|
||
*
|
||
* 顺序也一并钉住:头部关闭 → 标签 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(['关闭编辑素材标签', '删除标签 主角', '添加']);
|
||
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
|
||
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,
|
||
);
|
||
});
|
||
});
|