/** @vitest-environment jsdom */ import { act, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppAssetManifestEntry, GameCreationAppManifest, ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { type ChatReference, RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; import { createGameCreationAppManifest, fireEvent, React, render, screen, } from './appSurface/harness'; /** * 「拖动素材到对话 = 批量 @ 引用」的行为级验收。 * * 三条口径: * 1. 把资源卡拖到 Agent 对话栏上松手 → 按这次拖动真正参与位移的那批(多选整批)插入引用, * 一条布局坐标都不写(拖到对话不是排版)。 * 2. 只认已登记到 manifest 的素材,引用身份、来源标记与资源卡工具条那枚「引用」按钮逐字一致。 * 3. 画布内的拖动语义一行不改:照旧提交手动坐标、照旧不派发引用事件。 */ type AssetFixture = GameCreationAppAssetManifestEntry; function characterAsset(id: string, fileName: string): AssetFixture { return { id, kind: 'character', category: 'character', mediaType: 'image/png', localPath: `assets/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, }; } function manifestFor( projectId: string, assets: AssetFixture[], ): GameCreationAppManifest { return { ...createGameCreationAppManifest(projectId, `${projectId} 项目`), assets: assets.map((asset) => structuredClone(asset)), }; } 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, }; } type LayoutWrite = { mode: string; positions: ProjectResourceCanvasPosition[]; }; function installTauri(): { layoutWrites: LayoutWrite[] } { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); 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 key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`; return { schemaVersion: 'game-creator-resource-layout.v1', // 前端只传 projectPath / mode;读回来的 projectId 必须与当前 scope 一致,否则这份布局 // 会被判成不属于当前项目(写入整段停摆)。 projectId: 'chat-reference-drop', mode: args?.mode, revision: revisions.get(key) ?? 0, positions: structuredClone(persisted.get(key) ?? []), updatedAt: 0, }; } if (command === 'update_local_project_resource_canvas_layout') { const key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`; const positions = structuredClone( (args?.positions ?? []) as ProjectResourceCanvasPosition[], ); persisted.set(key, positions); revisions.set(key, Number(args?.expectedRevision ?? 0) + 1); layoutWrites.push({ mode: String(args?.mode ?? ''), positions }); return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: String(args?.expectedProjectId ?? ''), mode: String(args?.mode ?? ''), revision: revisions.get(key)!, positions, updatedAt: 1, }, }; } if (command === 'list_pending_local_project_resource_edits') { return []; } if (command === 'list_local_project_asset_generations') { return []; } return undefined; }, ); window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; return { layoutWrites }; } async function settle() { await act(async () => { await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); } const CHAT_RECT = { left: 900, top: 40, right: 1200, bottom: 640, width: 300, height: 600, x: 900, y: 40, } as DOMRect; function collectReferenceInserts() { const inserts: ChatReference[][] = []; const listener = (event: Event) => { const detail = ( event as CustomEvent ).detail; inserts.push(detail?.references ?? []); }; window.addEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener); return { inserts, dispose: () => window.removeEventListener( RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener, ), }; } async function mountCanvas() { const tauri = installTauri(); const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/chat-reference-drop', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, supervisor: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await settle(); 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: '打开角色与对象' })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); const inserts = collectReferenceInserts(); return { chat, manager, tauri, ...inserts }; } function cardIn(manager: HTMLElement, resourceId: string) { return manager.querySelector( `[data-resource-id="${resourceId}"]`, )!; } afterEach(() => { delete window.__TAURI__; vi.restoreAllMocks(); }); describe('拖动素材到对话:批量 @ 引用', () => { it('拖到对话栏松手:插入引用、不写布局坐标、提示条只在落点上出现', async () => { const { manager, tauri, inserts, dispose } = await mountCanvas(); const card = cardIn(manager, 'asset:drop-a'); expect(card).not.toBeNull(); const writesBefore = tauri.layoutWrites.length; fireEvent.pointerDown(card, { pointerId: 70, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(card, { pointerId: 70, buttons: 1, clientX: 1000, clientY: 300, }); // 落在对话上:提示条按对话栏矩形铺,卡片不再跟着指针走(不写成"把素材拖出项目")。 expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull(); expect(card.className).not.toContain('is-dragging'); fireEvent.pointerUp(card, { pointerId: 70, button: 0, clientX: 1000, clientY: 300, }); await settle(); expect(inserts).toHaveLength(1); expect(inserts[0]).toHaveLength(1); expect(inserts[0]![0]).toMatchObject({ type: 'resource', resourceId: 'drop-a', kind: 'character', source: 'resource-card', }); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); dispose(); }); it('多选后拖任意一张:整批一起 @ 引用', async () => { const { manager, tauri, inserts, dispose } = await mountCanvas(); const cardA = cardIn(manager, 'asset:drop-a'); const cardB = cardIn(manager, 'asset:drop-b'); fireEvent.click(cardA); fireEvent.click(cardB, { shiftKey: true }); const writesBefore = tauri.layoutWrites.length; fireEvent.pointerDown(cardB, { pointerId: 71, button: 0, clientX: 200, clientY: 200, }); fireEvent.pointerMove(cardB, { pointerId: 71, buttons: 1, clientX: 1000, clientY: 320, }); expect(screen.getByText('松手即可 @ 引用 2 项素材')).not.toBeNull(); fireEvent.pointerUp(cardB, { pointerId: 71, button: 0, clientX: 1000, clientY: 320, }); await settle(); expect(inserts).toHaveLength(1); expect( new Set(inserts[0]!.map((reference) => reference.resourceId)), ).toEqual(new Set(['drop-a', 'drop-b'])); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); it('指针移回画布后松手:照旧写手动坐标,不派发任何引用', async () => { const { manager, tauri, inserts, dispose } = await mountCanvas(); const card = cardIn(manager, 'asset:drop-a'); const writesBefore = tauri.layoutWrites.length; fireEvent.pointerDown(card, { pointerId: 72, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(card, { pointerId: 72, buttons: 1, clientX: 1000, clientY: 300, }); expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull(); // 又拖回画布里:落点提示收起,拖动回到排版语义。 fireEvent.pointerMove(card, { pointerId: 72, buttons: 1, clientX: 260, clientY: 200, }); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); fireEvent.pointerUp(card, { pointerId: 72, button: 0, clientX: 260, clientY: 200, }); await settle(); expect(inserts).toHaveLength(0); await waitFor(() => expect(tauri.layoutWrites.length).toBeGreaterThan(writesBefore), ); expect(tauri.layoutWrites.at(-1)?.positions).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: 'asset:drop-a', manuallyPlaced: true, }), ]), ); dispose(); }); it('拖动取消(pointercancel)时落点提示与引用一起收干净', async () => { const { manager, inserts, dispose } = await mountCanvas(); const card = cardIn(manager, 'asset:drop-a'); fireEvent.pointerDown(card, { pointerId: 73, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(card, { pointerId: 73, buttons: 1, clientX: 1000, clientY: 300, }); expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull(); fireEvent.pointerCancel(card, { pointerId: 73 }); await settle(); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); expect(inserts).toHaveLength(0); dispose(); }); });