Files
Genarrative/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx
T
suzmii 46788edbb8
Project CI / Repository checks (pull_request) Successful in 2m44s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m30s
修复「编辑素材标签」保存后关窗:点「添加」成功后面板保持打开,可连续添加
- 定位关窗来源:面板的 saveResourceClassification 只调 onSaved、从不调 onClose;真正卸载面板的是宿主 index.tsx 的 setResourceClassificationAssetId(null)(在 reloadManifestAfterAssetCommand 里,由 handleResourceClassificationSaved 触发)
- 在宿主层给 reloadManifestAfterAssetCommand 加 keepClassificationPanelOpen 开关:默认仍然收起,删除素材 / 版本替换 / 重命名的既有行为不变,只有标签保存显式传 true
- 标签保存后不再清 resourceClassificationAssetId:面板保持打开,新标签已落成 pill、输入框已清空,用户可以接着加下一个标签
- 关闭面板仍只走头部 ×;标签 pill 内的「删除标签」按钮与删除素材流程都不受影响
- 面板用例:添加后不得调用 onClose、面板仍在、新标签出现在已有标签列表、输入框清空;连续两次「添加」各自写盘且第二次带上累计标签、category 仍原样回传落盘值
- 工台用例(resourceVersionReplacement.test.tsx)补真宿主链路:经工具条「编辑标签」打开面板 → 添加 → 断言写入载荷与面板仍在 → 再添加一次 → 两次写入各自发生且面板依然打开(只测面板组件看不出宿主把面板卸载了)
2026-09-12 15:59:44 +08:00

507 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,
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 };
});
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/);
});
});
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,
);
});
});