// @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(); 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, ) { 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, chat: 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, chat: 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(); }); });