Files
Genarrative/apps/ai-game-creator-shell/tests/resourceRename.test.tsx
T
suzmii 5c31ae91ca 返修资源卡完整名悬停入口并收短文档卡注释
- 完整资源名改挂在覆盖整卡、真正命中指针的选中按钮 title 上,名称条自身不再挂悬停不到的 title

- 无障碍名保持原有 aria-label 不变,长名仍是 CSS 单行省略

- 断言改为校验选中按钮 title,并钉住名称条不挂 title

- 文档卡分支注释只写当前行为,去掉旧实现说明
2026-09-17 17:37:48 +08:00

342 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
cleanup,
createGameCreationAppManifest,
fireEvent,
ProjectDevelopmentView,
React,
render,
screen,
waitFor,
within,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/workbench-asset-rename';
/** 资源卡懒加载预览要靠 IntersectionObserver 才认成可见,这里只做最小替身。 */
function installResourceCardIntersectionObserver() {
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
}
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 createManifestWithHero(localPath: string): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
'workbench-asset-rename',
'素材重命名测试',
);
manifest.assets = [
{
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath,
source: { kind: 'generated' },
},
];
return manifest;
}
async function openHeroCard(name = 'hero.png') {
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
// 资源画本先切到子画布再渲染卡片,等它落到 character 栏目再找卡。
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
expect(
manager?.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]',
),
).not.toBeNull();
});
// C3 后点卡是「选中 + 浮出工具条」,重命名入口在工具条上。
fireEvent.click(
await screen.findByRole('button', {
name: new RegExp(`^选中资源:角色与对象 ${name}$`),
}),
);
return screen.findByRole('toolbar', { name: '图片工具栏' });
}
function renderWorkbench(
manifest: GameCreationAppManifest,
onManifestChange?: (
projectPath: string,
next: GameCreationAppManifest,
metadata: unknown,
) => void,
) {
const { rerender } = render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onManifestChange,
}),
);
return {
rerenderWith(next: GameCreationAppManifest) {
rerender(
React.createElement(ProjectDevelopmentView, {
projectName: next.name,
projectPath: PROJECT_PATH,
manifest: next,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onManifestChange,
}),
);
},
};
}
afterEach(() => {
cleanup();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
});
describe('素材重命名前端链路', () => {
test('renames through the strict native command and refreshes the resource card name', async () => {
installResourceCardIntersectionObserver();
const renamedManifest = createManifestWithHero('assets/hero-v2.png');
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 11, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
return {
asset: renamedManifest.assets[0],
previousLocalPath: 'assets/hero.png',
committedProjectRevision: 12,
};
}
if (command === 'get_local_game_manifest') {
return renamedManifest;
}
throw new Error(`unexpected command: ${command}`);
});
const onManifestChange = vi.fn();
const rendered = renderWorkbench(
createManifestWithHero('assets/hero.png'),
onManifestChange,
);
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
expect(field.value).toBe('hero.png');
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
await waitFor(() => {
expect(onManifestChange).toHaveBeenCalledTimes(1);
});
// 与删除 / 分类保存同一套 CAS 口径:带上项目身份 + **这一次读到的** revision。
expect(invoke).toHaveBeenCalledWith('rename_local_project_asset', {
input: {
projectPath: PROJECT_PATH,
expectedProjectId: 'workbench-asset-rename',
expectedProjectRevision: 11,
assetId: 'asset-hero',
newFileName: 'hero-v2.png',
},
});
expect(invoke).toHaveBeenCalledWith('get_local_game_project_revision', {
projectPath: PROJECT_PATH,
});
// revision 必须来自那一次读取:先读再改名,顺序反了就等于把过期值贴上去。
expect(
invoke.mock.calls.findIndex(
([command]) => command === 'get_local_game_project_revision',
),
).toBeLessThan(
invoke.mock.calls.findIndex(
([command]) => command === 'rename_local_project_asset',
),
);
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
});
expect(onManifestChange.mock.calls[0]?.[2]).toMatchObject({
revision: 12,
source: 'asset-command',
commitId: 'asset-rename:asset-hero',
});
rendered.rerenderWith(renamedManifest);
await waitFor(() => {
const renamedSelect = screen.getByRole('button', {
name: '选中资源:角色与对象 hero-v2.png',
});
const renamedCard = renamedSelect.closest('.game-resource-card');
// 卡面名称与重命名后的 manifest `localPath` 同步:名称就是正式文件名的投影,
// 不存在第二份需要一起改的显示名;完整名挂在命中指针的选中按钮 `title` 上。
const nameNode = renamedCard?.querySelector('.game-resource-card-name');
expect(nameNode?.textContent).toBe('hero-v2.png');
expect(renamedSelect.getAttribute('title')).toBe('hero-v2.png');
});
});
test('reads the project revision before renaming so a stale local value cannot be replayed', async () => {
installResourceCardIntersectionObserver();
// 本地 manifest 的 revision 落后(5)而磁盘上已经是 42:提交的必须是读到的 42,
// 否则后端按「陈旧改写」拒收,用户会看到一次凭空失败的重命名。
const staleManifest = createManifestWithHero('assets/hero.png');
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 42, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
return {
asset: createManifestWithHero('assets/hero-v2.png').assets[0],
previousLocalPath: 'assets/hero.png',
committedProjectRevision: 43,
};
}
if (command === 'get_local_game_manifest') {
return createManifestWithHero('assets/hero-v2.png');
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(staleManifest);
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'rename_local_project_asset',
expect.anything(),
);
});
expect(
invoke.mock.calls.find(
([command]) => command === 'rename_local_project_asset',
)?.[1],
).toMatchObject({ input: { expectedProjectRevision: 42 } });
});
test('surfaces the project CAS rejection as readable copy instead of the raw code', async () => {
installResourceCardIntersectionObserver();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
throw 'project-revision-conflict';
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(createManifestWithHero('assets/hero.png'));
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('项目已被其它操作改动,请刷新后重试');
expect(alert.textContent).not.toContain('project-revision-conflict');
});
test('keeps the panel open and surfaces the native rejection', async () => {
installResourceCardIntersectionObserver();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
throw '新文件名非法:扩展名必须与原文件一致';
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(createManifestWithHero('assets/hero.png'));
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero.jpg' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('扩展名必须与原文件一致');
expect(screen.getByLabelText('新文件名')).not.toBeNull();
});
});