/** @vitest-environment jsdom */ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppAssetManifestEntry, GameCreationAppManifest, ProjectResourceCanvasCategory, ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; import { createResourceSignature, useProjectResourceCanvasLayout, } from '../src/view/project-development/useProjectResourceCanvasLayout'; import { createGameCreationAppManifest, findResourceSelectButton, fireEvent, openResourceFilterPanel, React, render, screen, within, } from './appSurface/harness'; /** * 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。 * * 三条口径: * 1. 画布不再因为「资源协调签名变化」重排整张画布——新增一张素材只补它的位置, * 既有自动卡坐标逐值不变;要重排只能由用户按「整理画布」显式发起。 * 2. 新素材入库后自动进入视口并被选中;被搜索条件挡住时沿用既有「清除搜索并定位」。 * 3. 首次打开项目 / 切项目不触发聚焦跳转。 */ const NEW_ASSET_ID = 'asset-newly-generated'; const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; type AssetFixture = GameCreationAppAssetManifestEntry; function pngAsset(id: string, fileName: string): AssetFixture { return { id, kind: 'character', category: 'character', mediaType: 'image/png', localPath: `assets/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, }; } function markdownAsset(id: string, fileName: string): AssetFixture { return { id, kind: 'game-rules', mediaType: 'text/markdown', localPath: `docs/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, }; } function manifestFor( projectId: string, assets: AssetFixture[], ): GameCreationAppManifest { return { ...createGameCreationAppManifest(projectId, `${projectId} 项目`), assets: assets.map((asset) => structuredClone(asset)), }; } type LayoutWrite = { projectPath: string; mode: string; positions: ProjectResourceCanvasPosition[]; }; type FakeTauri = { invoke: ReturnType; layoutWrites: LayoutWrite[]; layoutReads: Array<{ projectPath: string; mode: string }>; unexpectedCommands: string[]; }; function resourceGraphFor( resources: Array<{ resourceId: string }> | undefined, ) { const resourceIds = (resources ?? []).map((resource) => resource.resourceId); return { resourceIds, referenceEdges: [], taskFlows: [], connectionIndex: resourceIds.map((resourceId) => ({ resourceId, upstreamReferenceResourceIds: [], downstreamReferenceResourceIds: [], referenceEdgeIds: [], taskFlowIds: [], })), producerAssignments: [], dependencyDepths: resourceIds.map((resourceId) => ({ resourceId, dependencyDepth: 0, })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], producerMappingTruncated: false, }; } /** * 只铺张画布真正用到的那几个本地命令。 * * 未知命令返回 `undefined` 并记账(而不是抛错):这份用例验的是布局与聚焦链路, * 不该因为画布里别的入口多调一个命令就整体转红;真要漏了关键命令, * 目标链路自己会停在"等待"上,断言照样失败。 */ function installLayoutTauri( options: { projectIdsByPath?: Record; layoutByScope?: Record; } = {}, ): FakeTauri { const layoutWrites: LayoutWrite[] = []; const layoutReads: Array<{ projectPath: string; mode: string }> = []; const unexpectedCommands: string[] = []; const persisted = new Map(); const revisions = new Map(); for (const [key, positions] of Object.entries(options.layoutByScope ?? {})) { persisted.set(key, structuredClone(positions)); } const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { return { revision: 1 }; } if (command === 'read_local_project_resource_graph') { return resourceGraphFor( args?.resources as Array<{ resourceId: string }> | undefined, ); } if (command === 'read_local_project_resource_canvas_layout') { const projectPath = String(args?.projectPath ?? ''); const mode = String(args?.mode ?? ''); layoutReads.push({ projectPath, mode }); const key = `${projectPath}|${mode}`; return { schemaVersion: 'game-creator-resource-layout.v1', projectId: options.projectIdsByPath?.[projectPath] ?? '', mode, revision: revisions.get(key) ?? 0, positions: structuredClone(persisted.get(key) ?? []), updatedAt: 0, }; } if (command === 'update_local_project_resource_canvas_layout') { const projectPath = String(args?.projectPath ?? ''); const mode = String(args?.mode ?? ''); const key = `${projectPath}|${mode}`; const positions = structuredClone( (args?.positions ?? []) as ProjectResourceCanvasPosition[], ); persisted.set(key, positions); revisions.set(key, Number(args?.expectedRevision ?? 0) + 1); layoutWrites.push({ projectPath, mode, positions }); return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: String(args?.expectedProjectId ?? ''), mode, revision: revisions.get(key)!, positions, updatedAt: 1, }, }; } if (command === 'list_pending_local_project_resource_edits') { return []; } if (command === 'list_local_project_asset_generations') { /* * 后台生成任务账本(`../src-tauri/src/asset_generation_tasks.rs` 的 * `list_local_project_asset_generations`)。工作台打开 / 切项目时读一次,用来恢复 * 「生成任务」面板;返回结构是 `Vec`,也就是**数组** * (前端按 `LocalProjectAssetGenerationTaskRecord[]` 消费),不是 `{ tasks }` 包一层。 * * 本文件验的是布局与聚焦链路,不涉及后台生成任务,所以回空账本: * `mergeResourceCanvasAssetGenerationTasksWithRecords(tasks, [])` 是恒等合并。 * 这是合法的跨工作流新调用,登记它,而不是放宽下面的 `unexpectedCommands` 断言。 */ return []; } if (command === 'read_local_project_image_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'image/png', byteLen: 1, dataUrl: 'data:image/png;base64,AA==', }; } if (command === 'read_local_project_text_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'text/markdown', byteLen: 8, content: '# 玩法规则', }; } unexpectedCommands.push(command); return undefined; }, ); window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; return { invoke, layoutWrites, layoutReads, unexpectedCommands }; } function typeWrites(tauri: FakeTauri) { return tauri.layoutWrites.filter((write) => write.mode === 'type'); } function dependencyWrites(tauri: FakeTauri) { return tauri.layoutWrites.filter((write) => write.mode === 'dependency'); } function selectedResourceIdsInDom() { return Array.from( document.querySelectorAll('[data-resource-id]'), ) .filter((element) => element.getAttribute('aria-pressed') === 'true') .map((element) => element.dataset.resourceId); } /** * 把布局读写与聚焦裁决链跑到底再下断言。 * * 「不该聚焦」这类否定断言最怕"跑得太早":断言时链路还没走到聚焦那一步,写什么都会绿。 * 这里先把微任务与一个宏任务放完,让链路在该触发的情况下已经触发过。 */ async function settleFocusChain() { await act(async () => { await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); } type ProjectFixture = { projectId: string; projectPath: string; assets: AssetFixture[]; }; /** * 把 manifest 交给真实工作台视图持有,并提供两个"外部世界"动作: * 「入库新素材」(等价于生成流程落盘后 `onManifestChange` 收到多一条 asset 的清单) * 与「切换项目」(换 projectPath + projectId + 清单)。 */ function LayoutWorkbench({ projects, appendedAsset, }: { projects: ProjectFixture[]; appendedAsset?: AssetFixture; }) { const [activeIndex, setActiveIndex] = React.useState(0); const [manifests, setManifests] = React.useState< Record >(() => Object.fromEntries( projects.map((project) => [ project.projectId, manifestFor(project.projectId, project.assets), ]), ), ); const active = projects[activeIndex]!; const manifest = manifests[active.projectId]!; return ( <> {appendedAsset ? ( ) : null} {projects.length > 1 ? ( ) : null} 项目对话} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifests((current) => { const projectId = nextManifest.projectId; return { ...current, [projectId]: nextManifest }; }) } /> ); } afterEach(() => { delete window.__TAURI__; vi.restoreAllMocks(); }); const POINTER_WORKBENCH_PROJECT_ID = 'pointer-workbench'; const POINTER_WORKBENCH_PATH = '/tmp/pointer-workbench'; async function mountPointerWorkbench( target: 'main' | 'character' | 'all' = 'character', options: { assets?: AssetFixture[]; /** 每个排序模式一份持久化坐标:`{mode}` 传具体模式名(`dependency` / `type`)。 */ layoutByMode?: Record; } = {}, ) { const projectId = POINTER_WORKBENCH_PROJECT_ID; const projectPath = POINTER_WORKBENCH_PATH; const layoutByScope = Object.fromEntries( Object.entries(options.layoutByMode ?? {}).map(([mode, positions]) => [ `${projectPath}|${mode}`, positions, ]), ); const tauri = installLayoutTauri({ projectIdsByPath: { [projectPath]: projectId }, layoutByScope, }); render( , ); await settleFocusChain(); const manager = document.querySelector( '.game-resource-book-manager', )!; vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); if (target !== 'main') { fireEvent.click( screen.getByRole('button', { name: target === 'all' ? '打开所有资源' : '打开角色与对象', }), ); } await settleFocusChain(); const surface = manager.querySelector( target === 'main' ? '.game-resource-book-main' : '.game-resource-book-scene', )!; const capture = new Set(); Object.defineProperties(surface, { setPointerCapture: { configurable: true, value: vi.fn((id: number) => capture.add(id)), }, hasPointerCapture: { configurable: true, value: (id: number) => capture.has(id), }, releasePointerCapture: { configurable: true, value: vi.fn((id: number) => capture.delete(id)), }, }); const world = manager.querySelector( '.game-resource-book-scene-world', )!; const viewport = () => Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) => Number(m[0]), ); return { manager, surface, world, viewport, tauri }; } describe('资源画布指针与运行提示', () => { it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => { const { manager, viewport, tauri } = await mountPointerWorkbench(); const card = manager.querySelector( '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]', )!; const before = viewport(); const writes = tauri.layoutWrites.length; fireEvent.pointerDown(card, { pointerId: 30, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(card, { pointerId: 30, buttons: 1, clientX: 180, clientY: 140, }); fireEvent.pointerUp(card, { pointerId: 30, button: 0, clientX: 180, clientY: 140, }); await waitFor(() => expect(tauri.layoutWrites.length).toBeGreaterThan(writes), ); expect(tauri.layoutWrites.at(-1)?.positions).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'asset:pointer-a', manuallyPlaced: true, }), ]), ); expect(viewport()).toEqual(before); expect( document.querySelector('.genarrative-image-canvas__selection-overlay'), ).toBeNull(); }); it('资源选中不移除运行不可用提示或改变运行能力', async () => { const { manager } = await mountPointerWorkbench(); const hint = '首个可运行原型尚未完成,运行视图暂不可用'; expect(screen.getByText(hint)).not.toBeNull(); fireEvent.click( manager.querySelector( '.is-expanded [data-resource-id="asset:pointer-a"]', )!, ); expect(screen.getByText(hint)).not.toBeNull(); const run = screen.getByRole('tab', { name: '运行' }); expect(run.getAttribute('data-unavailable')).toBe('true'); expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint'); fireEvent.click(run); expect(run.getAttribute('aria-selected')).toBe('false'); }); it.each(['main', 'character', 'all'] as const)( '%s 的空白处支持右键平移且不写资源布局', async (target) => { const { surface, viewport, tauri } = await mountPointerWorkbench(target); const before = viewport(); const writes = tauri.layoutWrites.length; fireEvent.pointerDown(surface, { pointerId: 31, button: 2, clientX: 100, clientY: 100, }); fireEvent.pointerMove(surface, { pointerId: 31, buttons: 2, clientX: 170, clientY: 140, }); expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]); expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false); fireEvent.pointerUp(surface, { pointerId: 31, button: 2, clientX: 170, clientY: 140, }); expect( document.querySelector('.genarrative-image-canvas__selection-overlay'), ).toBeNull(); expect(tauri.layoutWrites).toHaveLength(writes); }, ); it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => { const { manager, surface, viewport, tauri } = await mountPointerWorkbench(); const card = manager.querySelector( '.is-expanded [data-resource-id="asset:pointer-a"]', )!; fireEvent.click(card); const before = viewport(); const writes = tauri.layoutWrites.length; fireEvent.pointerDown(card, { pointerId: 32, button: 2, clientX: 100, clientY: 100, }); fireEvent.pointerMove(surface, { pointerId: 32, buttons: 2, clientX: 150, clientY: 160, }); fireEvent.pointerUp(surface, { pointerId: 32, button: 2, clientX: 150, clientY: 160, }); expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]); expect(card.getAttribute('aria-pressed')).toBe('true'); expect(tauri.layoutWrites).toHaveLength(writes); const panned = viewport(); fireEvent.pointerDown(surface, { pointerId: 33, button: 0, clientX: 10, clientY: 10, }); fireEvent.pointerMove(surface, { pointerId: 33, buttons: 1, clientX: 780, clientY: 580, }); expect( document.querySelector('.genarrative-image-canvas__selection-overlay'), ).not.toBeNull(); expect(selectedResourceIdsInDom()).toContain('asset:pointer-a'); expect(viewport()).toEqual(panned); fireEvent.pointerUp(surface, { pointerId: 33, button: 0 }); expect( document.querySelector('.genarrative-image-canvas__selection-overlay'), ).toBeNull(); }); it.each(['cancel', 'capture', 'blur'] as const)( '%s 后右键平移不会继续跟随指针', async (reason) => { const { surface, viewport } = await mountPointerWorkbench(); const before = viewport(); fireEvent.pointerDown(surface, { pointerId: 34, button: 2, clientX: 100, clientY: 100, }); fireEvent.pointerMove(surface, { pointerId: 34, buttons: 2, clientX: 110, clientY: 120, }); const moved = viewport(); expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]); if (reason === 'cancel') fireEvent.pointerCancel(surface, { pointerId: 34 }); else if (reason === 'capture') fireEvent.lostPointerCapture(surface, { pointerId: 34 }); else act(() => window.dispatchEvent(new Event('blur'))); fireEvent.pointerMove(surface, { pointerId: 34, clientX: 300, clientY: 300, }); expect(viewport()).toEqual(moved); }, ); it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => { const { surface, viewport } = await mountPointerWorkbench('main'); const before = viewport(); fireEvent.pointerDown(surface, { pointerId: 36, button: 2, clientX: 10, clientY: 10, }); fireEvent.pointerMove(surface, { pointerId: 36, buttons: 2, clientX: 40, clientY: 40, }); const moved = viewport(); expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]); act(() => window.dispatchEvent(new Event('blur'))); expect(surface.releasePointerCapture).toHaveBeenCalledWith(36); fireEvent.pointerMove(surface, { pointerId: 36, clientX: 100, clientY: 100, }); expect(viewport()).toEqual(moved); fireEvent.pointerDown(surface, { pointerId: 37, button: 2, clientX: 10, clientY: 10, }); fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); expect(surface.releasePointerCapture).toHaveBeenCalledWith(37); }); it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => { const { manager, surface, viewport } = await mountPointerWorkbench(); const zoom = screen.getByRole('button', { name: '放大画布' }); const before = viewport(); fireEvent.pointerDown(zoom, { pointerId: 35, button: 2, clientX: 100, clientY: 100, }); fireEvent.pointerMove(surface, { pointerId: 35, buttons: 2, clientX: 200, clientY: 200, }); expect(viewport()).toEqual(before); expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true); await act(async () => { for (let i = 0; i < 100; i++) { fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 }); } }); expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]); }); }); describe('资源画布手动重排口径', () => { it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { const projectId = 'manual-rederive-project'; const projectPath = '/tmp/manual-rederive-project'; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', projectId, mode: args?.mode, revision: 3, positions: [ { resourceId: 'resource-b', section: 'document', x: 600, y: 40, manuallyPlaced: true, }, { resourceId: 'resource-a', section: 'document', x: 900, y: 900, manuallyPlaced: false, }, ], updatedAt: 300, }; } if (command === 'update_local_project_resource_canvas_layout') { return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId, mode: args?.mode, revision: 4, positions: args?.positions, updatedAt: 400, }, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke }, } as unknown as typeof window.__TAURI__; const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( (id) => ({ id, category: 'document' as ProjectResourceCanvasCategory, subtype: 'agent-result', label: id, mediaType: 'text/markdown', dependencyDepth: 0, }), ); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, projectId, mode: 'type', resources, rederiveAutomaticPositions: false, }), ); await waitFor(() => expect(result.current.ready).toBe(true)); // 自动卡还停在落后坐标上:preserve 口径不会自己去纠正它。 expect( result.current.layout.positions.find( (position) => position.resourceId === 'resource-a', ), ).toMatchObject({ x: 900, y: 900 }); await act(async () => { result.current.rederiveNow(); }); await waitFor(() => expect( invoke.mock.calls.filter( ([command]) => command === 'update_local_project_resource_canvas_layout', ), ).toHaveLength(1), ); const written = invoke.mock.calls.find( ([command]) => command === 'update_local_project_resource_canvas_layout', )?.[1]?.positions as ProjectResourceCanvasPosition[]; expect(written).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'resource-a', x: 0, y: 0, manuallyPlaced: false, }), expect.objectContaining({ resourceId: 'resource-b', x: 600, y: 40, manuallyPlaced: true, }), ]), ); }); it('新素材入库后既有自动卡坐标逐值不变,新卡只补在末尾', async () => { const tauri = installLayoutTauri({ projectIdsByPath: { '/tmp/manual-layout-project': 'manual-layout-project', }, }); render( , ); await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); const before = typeWrites(tauri).at(-1)!; expect( before.positions.map((position) => position.resourceId), ).not.toContain(NEW_RESOURCE_ID); fireEvent.click( await screen.findByRole('button', { name: '测试:入库新素材' }), ); await waitFor(() => expect( typeWrites(tauri).some((write) => write.positions.some( (position) => position.resourceId === NEW_RESOURCE_ID, ), ), ).toBe(true), ); const after = typeWrites(tauri).find((write) => write.positions.some( (position) => position.resourceId === NEW_RESOURCE_ID, ), )!; // 核心判据:除新卡外全部既有坐标逐值不变(顺序、分区、手动标记都不许动)。 expect( after.positions.filter( (position) => position.resourceId !== NEW_RESOURCE_ID, ), ).toEqual( before.positions.filter( (position) => position.resourceId !== NEW_RESOURCE_ID, ), ); }); it('依赖画布只在关系图首次就绪时重算一次,之后新增素材不再重排', async () => { const projectPath = '/tmp/manual-dependency-project'; const tauri = installLayoutTauri({ projectIdsByPath: { [projectPath]: 'manual-dependency-project' }, layoutByScope: { [`${projectPath}|dependency`]: [ { resourceId: 'asset:asset-art-b', section: 'character', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'asset:asset-art-a', section: 'character', x: 900, y: 900, manuallyPlaced: false, }, ], }, }); render( , ); // 一次性重派生:关系图首次就绪后按最终拓扑把落后的自动坐标对齐一次。 await waitFor(() => expect( dependencyWrites(tauri).some((write) => write.positions.some( (position) => position.resourceId === 'asset:asset-art-a' && (position.x !== 900 || position.y !== 900), ), ), ).toBe(true), ); const before = dependencyWrites(tauri).at(-1)!; expect( before.positions.map((position) => position.resourceId), ).not.toContain(NEW_RESOURCE_ID); fireEvent.click( await screen.findByRole('button', { name: '测试:入库新素材' }), ); await waitFor(() => expect( dependencyWrites(tauri).some((write) => write.positions.some( (position) => position.resourceId === NEW_RESOURCE_ID, ), ), ).toBe(true), ); const after = dependencyWrites(tauri).find((write) => write.positions.some( (position) => position.resourceId === NEW_RESOURCE_ID, ), )!; expect( after.positions.filter( (position) => position.resourceId !== NEW_RESOURCE_ID, ), ).toEqual( before.positions.filter( (position) => position.resourceId !== NEW_RESOURCE_ID, ), ); }); it('新素材入库后自动进入视口并被选中', async () => { const tauri = installLayoutTauri({ projectIdsByPath: { '/tmp/manual-focus-project': 'manual-focus-project' }, }); render( , ); await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); expect(selectedResourceIdsInDom()).toEqual([]); fireEvent.click( await screen.findByRole('button', { name: '测试:入库新素材' }), ); await waitFor(() => expect(new Set(selectedResourceIdsInDom())).toEqual( new Set([NEW_RESOURCE_ID]), ), ); }); it('新素材被搜索条件挡住时走既有「清除搜索并定位」路径', async () => { const tauri = installLayoutTauri({ projectIdsByPath: { '/tmp/manual-focus-hidden-project': 'manual-focus-hidden-project', }, }); render( , ); await findResourceSelectButton('art-b.png'); const search = openResourceFilterPanel(); fireEvent.change(search, { target: { value: 'art-b' } }); expect(selectedResourceIdsInDom()).toEqual([]); fireEvent.click(screen.getByRole('button', { name: '测试:入库新素材' })); expect( await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), ).not.toBeNull(); // 搜索条件只由显式动作清除,不静默改用户输入。 expect(openResourceFilterPanel().value).toBe('art-b'); fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); await waitFor(() => expect(new Set(selectedResourceIdsInDom())).toEqual( new Set([NEW_RESOURCE_ID]), ), ); expect(tauri.unexpectedCommands).toEqual([]); }); it('「整理画布」重排当前栏目全部素材(含手动卡)成自动坐标,一次撤销恢复坐标与手动标记', async () => { const projectPath = '/tmp/manual-rederive-button-project'; const tauri = installLayoutTauri({ projectIdsByPath: { [projectPath]: 'manual-rederive-button-project', }, layoutByScope: { [`${projectPath}|type`]: [ { resourceId: 'asset:asset-art-a', section: 'character', x: 600, y: 40, manuallyPlaced: true, }, { resourceId: 'asset:asset-art-b', section: 'character', x: 800, y: 800, manuallyPlaced: false, }, ], }, }); render( , ); await waitFor(() => expect( document.querySelector('[data-resource-id="asset:asset-art-b"]'), ).not.toBeNull(), ); // 切到「类型」视图:两个排序模式各有一份 sidecar,按钮作用于当前生效的那一份。 fireEvent.click(screen.getByRole('button', { name: '按类型' })); // 打开项目 / 切排序 tab 这两步都不该重排:自动卡的落后坐标原样保留。 expect(typeWrites(tauri)).toEqual([]); fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); expect(typeWrites(tauri)[0]!.positions).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'asset:asset-art-b', section: 'character', x: 196, y: 0, manuallyPlaced: false, }), // 手动摆放过的卡也在整理范围内:坐标重算、手动标记转成自动。 expect.objectContaining({ resourceId: 'asset:asset-art-a', section: 'character', x: 0, y: 0, manuallyPlaced: false, }), ]), ); expect(await screen.findByText('布局已保存')).not.toBeNull(); // 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图 // 不得持久化自动布局」用例依赖同一条 `changed` 门)。 fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await settleFocusChain(); expect(typeWrites(tauri)).toHaveLength(1); // 一次撤销还原整理前的坐标**与手动标记**:整次整理只是一笔可撤销操作。 fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); expect(typeWrites(tauri)[1]!.positions).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'asset:asset-art-a', section: 'character', x: 600, y: 40, manuallyPlaced: true, }), expect.objectContaining({ resourceId: 'asset:asset-art-b', section: 'character', x: 800, y: 800, manuallyPlaced: false, }), ]), ); }); it('「整理画布」不属于「资源排列方式」这组模式切换,而是一枚资源动作按钮', async () => { const projectPath = '/tmp/manual-layout-surface-project'; const tauri = installLayoutTauri({ projectIdsByPath: { [projectPath]: 'manual-layout-surface-project' }, }); render( , ); const sortGroup = await screen.findByRole('group', { name: '资源排列方式', }); // 这组里只有两种排列方式:用户不该把「整理画布」读成第三种排列方式。 expect( within(sortGroup) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), ).toEqual(['按依赖', '按类型']); expect( within(sortGroup).queryByRole('button', { name: '整理画布' }), ).toBeNull(); // 它仍是同一行里的同一枚动作按钮,只是搬出了那个 group、也离开了行尾。 const rederiveButton = screen.getByRole('button', { name: '整理画布' }); expect(sortGroup.contains(rederiveButton)).toBe(false); expect(rederiveButton.closest('.game-resource-sort-tabs')).toBeNull(); const actionsRow = rederiveButton.closest('.game-workbench-view-actions'); expect(actionsRow).not.toBeNull(); // 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。 const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); expect(rowButtons.at(-1)).not.toBe(rederiveButton); const rederiveIndex = rowButtons.indexOf(rederiveButton); const sortGroupIndex = rowButtons.findIndex((button) => sortGroup.contains(button), ); expect(rederiveIndex).toBeGreaterThanOrEqual(0); expect(sortGroupIndex).toBeGreaterThanOrEqual(0); expect(rederiveIndex).toBeLessThan(sortGroupIndex); // 语义没变:可点性只跟布局就绪绑定,布局读完后它就是可点的。 await waitFor(() => expect( screen .getByRole('button', { name: '整理画布' }) .hasAttribute('disabled'), ).toBe(false), ); }); it('首次打开项目与切项目都不触发新素材聚焦跳转', async () => { const tauri = installLayoutTauri({ projectIdsByPath: { '/tmp/manual-open-project-a': 'manual-open-project-a', '/tmp/manual-open-project-b': 'manual-open-project-b', }, }); render( , ); await waitFor(() => expect( document.querySelector('[data-resource-id="asset:asset-a1"]'), ).not.toBeNull(), ); // 布局读写先跑完,再给聚焦裁决链一次"要是会被误触发就已经触发"的机会。 await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); await settleFocusChain(); expect(selectedResourceIdsInDom()).toEqual([]); fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); await waitFor(() => expect( tauri.layoutReads.some( (read) => read.projectPath === '/tmp/manual-open-project-b', ), ).toBe(true), ); await waitFor(() => expect( document.querySelector('[data-resource-id="asset:asset-b1"]'), ).not.toBeNull(), ); await waitFor(() => expect( typeWrites(tauri).some( (write) => write.projectPath === '/tmp/manual-open-project-b', ), ).toBe(true), ); await settleFocusChain(); expect(selectedResourceIdsInDom()).toEqual([]); }); it('资源协调签名仍把新增素材算作变化(重排判据没有被人为掐掉)', () => { const single = createResourceSignature([ { id: 'asset:x', category: 'character', subtype: 'character', label: 'x', mediaType: 'image/png', dependencyDepth: 0, }, ]); const doubled = createResourceSignature([ { id: 'asset:x', category: 'character', subtype: 'character', label: 'x', mediaType: 'image/png', dependencyDepth: 0, }, { id: 'asset:y', category: 'character', subtype: 'character', label: 'y', mediaType: 'image/png', dependencyDepth: 0, }, ]); expect(single).not.toBe(doubled); }); }); /** * 多选批量移动、整理范围与取消/切项目边界的画布级验收。 * * 口径:拖动已选卡按统一位移移动整个选择集;拖动未选卡保持单选;松手一次提交、一次撤销; * 取消/失焦/切项目不留下半截位移;整理只作用于当前栏目(筛选不缩小集合),其他栏目不变。 */ describe('资源画布多选拖动与整理范围', () => { function writePositionsById(write: LayoutWrite) { return new Map( write.positions.map((position) => [position.resourceId, position]), ); } function cardIn(manager: HTMLElement, resourceId: string) { return manager.querySelector( `.is-expanded .game-resource-card[data-resource-card-id="${resourceId}"]`, )!; } /** * type 侧的持久化坐标:两张角色卡摆在互为上下的相对位置上,整理后槽位可预测 * (第一槽 0,0 / 第二槽 196,0)。 */ const twoCharacterCards: ProjectResourceCanvasPosition[] = [ { resourceId: 'asset:pointer-a', section: 'character', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'asset:pointer-b', section: 'character', x: 400, y: 300, manuallyPlaced: false, }, ]; async function selectTwoCharacterCards( manager: HTMLElement, tauri: FakeTauri, ) { // 打开角色与对象栏目页并切到「按类型」:type 侧坐标可预测,写入也走同一套写队列。 fireEvent.click(screen.getByRole('button', { name: '按类型' })); const cardA = cardIn(manager, 'asset:pointer-a'); const cardB = cardIn(manager, 'asset:pointer-b'); fireEvent.click(cardA); fireEvent.click(cardB, { shiftKey: true }); await waitFor(() => expect(new Set(selectedResourceIdsInDom())).toEqual( new Set(['asset:pointer-a', 'asset:pointer-b']), ), ); expect(typeWrites(tauri)).toEqual([]); return { cardA, cardB }; } it.each(['character', 'all'] as const)( '%s:多选拖动期间布局重建,预览仍使用按下时起点并与松手写回一致', async (target) => { const { manager, viewport, tauri } = await mountPointerWorkbench(target, { layoutByMode: { type: twoCharacterCards }, }); const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); const scale = viewport()[2]!; fireEvent.pointerDown(cardB, { pointerId: 49, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 49, buttons: 1, clientX: 180, clientY: 140, }); const beforeA = [ cardA.style.getPropertyValue('--resource-x'), cardA.style.getPropertyValue('--resource-y'), ]; const beforeB = [ cardB.style.getPropertyValue('--resource-x'), cardB.style.getPropertyValue('--resource-y'), ]; // 重建底层布局,模拟拖动尚未结束时另一笔布局操作完成。 fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); expect( typeWrites(tauri)[0]!.positions.find( (p) => p.resourceId === 'asset:pointer-b', )?.y, ).toBe(0); expect([ cardA.style.getPropertyValue('--resource-x'), cardA.style.getPropertyValue('--resource-y'), ]).toEqual(beforeA); expect([ cardB.style.getPropertyValue('--resource-x'), cardB.style.getPropertyValue('--resource-y'), ]).toEqual(beforeB); fireEvent.pointerUp(cardB, { pointerId: 49, button: 0, clientX: 180, clientY: 140, }); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); expect(typeWrites(tauri)[1]!.positions).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'asset:pointer-a', x: Math.round(80 / scale), y: Math.round(40 / scale), }), expect.objectContaining({ resourceId: 'asset:pointer-b', x: Math.round(400 + 80 / scale), y: Math.round(300 + 40 / scale), }), ]), ); }, ); it('拖动已选卡整批等位移、一笔提交、一次撤销,多选保持', async () => { const { manager, viewport, tauri } = await mountPointerWorkbench( 'character', { layoutByMode: { type: twoCharacterCards }, }, ); const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); const scale = viewport()[2]!; expect(scale).toBeGreaterThan(0); fireEvent.pointerDown(cardB, { pointerId: 41, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 41, buttons: 1, clientX: 180, clientY: 140, }); fireEvent.pointerUp(cardB, { pointerId: 41, button: 0, clientX: 180, clientY: 140, }); // 松手只提交一笔:整批卡在同一次 CAS 里落盘。 await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); const after = writePositionsById(typeWrites(tauri)[0]!); const deltaX = Math.round(80 / scale); const deltaY = Math.round(40 / scale); expect(after.get('asset:pointer-a')).toEqual({ resourceId: 'asset:pointer-a', section: 'character', x: 0 + deltaX, y: 0 + deltaY, manuallyPlaced: true, }); expect(after.get('asset:pointer-b')).toEqual({ resourceId: 'asset:pointer-b', section: 'character', x: Math.round(400 + 80 / scale), y: Math.round(300 + 40 / scale), manuallyPlaced: true, }); // 相对位置保持原样(等位移)。 expect( after.get('asset:pointer-b')!.x - after.get('asset:pointer-a')!.x, ).toBe(400); expect( after.get('asset:pointer-b')!.y - after.get('asset:pointer-a')!.y, ).toBe(300); // 拖动已选卡不丢多选。 expect(new Set(selectedResourceIdsInDom())).toEqual( new Set(['asset:pointer-a', 'asset:pointer-b']), ); // 一次撤销把整批带回拖动前的坐标。 fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); const restored = writePositionsById(typeWrites(tauri)[1]!); expect(restored.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0, manuallyPlaced: false, }); expect(restored.get('asset:pointer-b')).toMatchObject({ x: 400, y: 300, manuallyPlaced: false, }); }); it('不同缩放下多选位移按 scale 换算', async () => { const { manager, viewport, tauri } = await mountPointerWorkbench( 'character', { layoutByMode: { type: twoCharacterCards }, }, ); const { cardB } = await selectTwoCharacterCards(manager, tauri); const before = viewport()[2]!; fireEvent.click(screen.getByRole('button', { name: '放大画布' })); fireEvent.click(screen.getByRole('button', { name: '放大画布' })); const scale = viewport()[2]!; expect(scale).toBeGreaterThan(before); fireEvent.pointerDown(cardB, { pointerId: 42, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 42, buttons: 1, clientX: 200, clientY: 150, }); fireEvent.pointerUp(cardB, { pointerId: 42, button: 0, clientX: 200, clientY: 150, }); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); const after = writePositionsById(typeWrites(tauri)[0]!); expect(after.get('asset:pointer-a')).toMatchObject({ x: Math.round(100 / scale), y: Math.round(50 / scale), }); expect(after.get('asset:pointer-b')).toMatchObject({ x: Math.round(400 + 100 / scale), y: Math.round(300 + 50 / scale), }); // 屏幕位移相同、坐标位移被 scale 收窄:不是按像素直接落盘。 expect( after.get('asset:pointer-a')!.x - after.get('asset:pointer-a')!.y, ).not.toBe(50); }); it('拖动未选中的卡保持单选语义:只有它自己动', async () => { const { manager, viewport, tauri } = await mountPointerWorkbench( 'character', { layoutByMode: { type: twoCharacterCards }, }, ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); const cardA = cardIn(manager, 'asset:pointer-a'); const cardB = cardIn(manager, 'asset:pointer-b'); fireEvent.click(cardA); expect(selectedResourceIdsInDom()).toEqual(['asset:pointer-a']); const scale = viewport()[2]!; fireEvent.pointerDown(cardB, { pointerId: 43, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 43, buttons: 1, clientX: 180, clientY: 120, }); fireEvent.pointerUp(cardB, { pointerId: 43, button: 0, clientX: 180, clientY: 120, }); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); const after = writePositionsById(typeWrites(tauri)[0]!); expect(after.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0 }); expect(after.get('asset:pointer-b')).toMatchObject({ x: Math.round(400 + 80 / scale), y: Math.round(300 + 20 / scale), }); }); it.each(['cancel', 'capture', 'blur'] as const)( '%s 取消多选拖动:不落盘、坐标回到拖动前、选择保留', async (reason) => { const { manager, tauri } = await mountPointerWorkbench('character', { layoutByMode: { type: twoCharacterCards }, }); const { cardB } = await selectTwoCharacterCards(manager, tauri); const cardARoot = cardIn(manager, 'asset:pointer-a'); fireEvent.pointerDown(cardB, { pointerId: 44, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 44, buttons: 1, clientX: 260, clientY: 220, }); // 拖动中整批卡先跟指针走。 expect(cardIn(manager, 'asset:pointer-b').className).toContain( 'is-dragging', ); expect(cardARoot.className).toContain('is-dragging'); if (reason === 'cancel') { fireEvent.pointerCancel(cardB, { pointerId: 44 }); } else if (reason === 'capture') { fireEvent.lostPointerCapture(cardB, { pointerId: 44 }); } else { act(() => window.dispatchEvent(new Event('blur'))); } await settleFocusChain(); expect(typeWrites(tauri)).toEqual([]); // 预览已丢掉:卡片回到拖动前的位置(不再带拖动态)。 expect(cardIn(manager, 'asset:pointer-b').className).not.toContain( 'is-dragging', ); expect(cardARoot.className).not.toContain('is-dragging'); expect(new Set(selectedResourceIdsInDom())).toEqual( new Set(['asset:pointer-a', 'asset:pointer-b']), ); }, ); it('整理只重排当前栏目,其他栏目坐标与手动标记不变', async () => { const { manager, tauri } = await mountPointerWorkbench('character', { assets: [ pngAsset('pointer-a', 'a.png'), pngAsset('pointer-b', 'b.png'), { ...pngAsset('scene-c', 'scene-c.png'), category: 'scene', kind: 'scene', }, ], layoutByMode: { type: [ ...twoCharacterCards, { resourceId: 'asset:scene-c', section: 'scene', x: 30, y: 60, manuallyPlaced: true, }, ], }, }); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await waitFor(() => expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), ); await settleFocusChain(); fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); const after = writePositionsById(typeWrites(tauri)[0]!); // 当前栏目:两张角色卡一起重算成自动坐标。 expect(after.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0, manuallyPlaced: false, }); expect(after.get('asset:pointer-b')).toMatchObject({ x: 196, y: 0, manuallyPlaced: false, }); // 其他栏目:坐标与手动标记逐值不动。 expect(after.get('asset:scene-c')).toEqual({ resourceId: 'asset:scene-c', section: 'scene', x: 30, y: 60, manuallyPlaced: true, }); }); it('筛选不缩小整理集合:被搜索隐藏的同栏目素材同样参与重排', async () => { const { manager, tauri } = await mountPointerWorkbench('character', { layoutByMode: { type: twoCharacterCards }, }); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await waitFor(() => expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), ); const search = openResourceFilterPanel(); fireEvent.change(search, { target: { value: 'a.png' } }); // 被搜索挡住的第二张卡在画布上不可交互(宿主机标记 aria-hidden),但仍在当前栏目里。 const hiddenCardHost = () => cardIn(manager, 'asset:pointer-b').closest( '.game-resource-book-scene-card', )!; await waitFor(() => expect(hiddenCardHost().getAttribute('aria-hidden')).toBe('true'), ); fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); const after = writePositionsById(typeWrites(tauri)[0]!); expect(after.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0, manuallyPlaced: false, }); expect(after.get('asset:pointer-b')).toMatchObject({ x: 196, y: 0, manuallyPlaced: false, }); }); it('「所有资源」页整理全部栏目的可展示素材,手动坐标一并重算且只写一笔', async () => { const { manager, tauri } = await mountPointerWorkbench('all', { assets: [ pngAsset('pointer-a', 'a.png'), pngAsset('pointer-b', 'b.png'), { ...pngAsset('scene-c', 'scene-c.png'), category: 'scene', kind: 'scene', }, { ...markdownAsset('doc-d', 'd.md'), category: 'document' }, ], layoutByMode: { type: [ { resourceId: 'asset:pointer-a', section: 'character', x: 500, y: 500, manuallyPlaced: true, }, { resourceId: 'asset:pointer-b', section: 'character', x: 400, y: 300, manuallyPlaced: false, }, { resourceId: 'asset:scene-c', section: 'scene', x: 30, y: 60, manuallyPlaced: true, }, { resourceId: 'asset:doc-d', section: 'document', x: 700, y: 700, manuallyPlaced: false, }, ], }, }); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await waitFor(() => expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), ); await settleFocusChain(); // 读盘那一次「归并 / 规范化」写回与本次要验的整理是两回事,先等它落地再记账。 const writesBeforeOrganize = typeWrites(tauri).length; fireEvent.click(screen.getByRole('button', { name: '整理画布' })); // 「所有资源」= 当前项目全部可展示素材,一次整理仍然只写一笔。 await waitFor(() => expect(typeWrites(tauri)).toHaveLength(writesBeforeOrganize + 1), ); const after = writePositionsById(typeWrites(tauri).at(-1)!); // 每个栏目各自重算成自动槽位:同栏目第二张卡落在 196,0,跨栏目互不影响。 expect(after.get('asset:pointer-a')).toMatchObject({ section: 'character', x: 0, y: 0, manuallyPlaced: false, }); expect(after.get('asset:pointer-b')).toMatchObject({ section: 'character', x: 196, y: 0, manuallyPlaced: false, }); expect(after.get('asset:scene-c')).toMatchObject({ section: 'scene', x: 0, y: 0, manuallyPlaced: false, }); expect(after.get('asset:doc-d')).toMatchObject({ section: 'document', x: 0, y: 0, manuallyPlaced: false, }); }); it('切项目清空选择并丢弃进行中的拖动,不把位移写进新项目', async () => { const projectPathA = '/tmp/multi-drag-project-a'; const projectPathB = '/tmp/multi-drag-project-b'; const tauri = installLayoutTauri({ projectIdsByPath: { [projectPathA]: 'multi-drag-project-a', [projectPathB]: 'multi-drag-project-b', }, layoutByScope: { [`${projectPathA}|type`]: twoCharacterCards, [`${projectPathB}|type`]: [ { resourceId: 'asset:project-b-1', section: 'document', x: 10, y: 20, manuallyPlaced: false, }, ], }, }); render( , ); await settleFocusChain(); const manager = document.querySelector( '.game-resource-book-manager', )!; vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); fireEvent.click(screen.getByRole('button', { name: '按类型' })); fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); await waitFor(() => expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), ); const cardA = cardIn(manager, 'asset:pointer-a'); const cardB = cardIn(manager, 'asset:pointer-b'); fireEvent.click(cardA); fireEvent.click(cardB, { shiftKey: true }); await waitFor(() => expect(selectedResourceIdsInDom()).toHaveLength(2)); fireEvent.pointerDown(cardB, { pointerId: 45, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardB, { pointerId: 45, buttons: 1, clientX: 300, clientY: 240, }); fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); await waitFor(() => expect( tauri.layoutReads.some((read) => read.projectPath === projectPathB), ).toBe(true), ); // 松手发生在切项目之后:位移不写进新项目。 fireEvent.pointerUp(cardB, { pointerId: 45, button: 0, clientX: 300, clientY: 240, }); await settleFocusChain(); // 两个项目都没有留下拖动落点:没有任何一笔手动坐标。 for (const projectPath of [projectPathA, projectPathB]) { const manualWrites = tauri.layoutWrites .filter((write) => write.projectPath === projectPath) .flatMap((write) => write.positions) .filter((position) => position.manuallyPlaced); expect(manualWrites).toEqual([]); } // 两个项目的 type 侧坐标逐值保持原样(拖动没有落盘,也没有跨项目串写)。 expect( tauri.layoutWrites.filter( (write) => write.mode === 'type' && (write.projectPath === projectPathA || write.projectPath === projectPathB), ), ).toEqual([]); await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([])); }); /** * 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。 * * 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把 * type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的 * 布局。 */ it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => { const { manager, tauri } = await mountPointerWorkbench('character', { layoutByMode: { type: [ { resourceId: 'asset:pointer-a', section: 'character', x: 0, y: 0, manuallyPlaced: false, }, { resourceId: 'asset:pointer-b', section: 'character', x: 400, y: 300, manuallyPlaced: false, }, ], dependency: [ { resourceId: 'asset:pointer-a', section: 'character', x: 700, y: 700, manuallyPlaced: false, }, { resourceId: 'asset:pointer-b', section: 'character', x: 900, y: 900, manuallyPlaced: false, }, ], }, }); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await waitFor(() => expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(), ); await settleFocusChain(); // 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。 const cardA = cardIn(manager, 'asset:pointer-a'); fireEvent.pointerDown(cardA, { pointerId: 61, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(cardA, { pointerId: 61, buttons: 1, clientX: 180, clientY: 140, }); fireEvent.pointerUp(cardA, { pointerId: 61, button: 0, clientX: 180, clientY: 140, }); await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); await settleFocusChain(); // 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。 fireEvent.click(screen.getByRole('button', { name: '按依赖' })); await settleFocusChain(); const dependencyWritesBefore = dependencyWrites(tauri).length; fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); await settleFocusChain(); // 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。 expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore); }); });