Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx
T
suzmii 9639a080c6 资源卡拖到对话栏实现批量 @ 引用
- 新增落点模型 resourceCardReferenceDropModel:对话栏矩形量取(0 x 0 判成没有落点,避免画布内拖动被误判)、命中判据、引用构造与落点提示文案。
- 引用构造与工具条「引用」按钮逐字一致(只认已登记 manifest 素材、source 为 resource-card、kind/分类/标签取自资源投影),一条都构造不出来时用提示条说明原因。
- 拖动中指针落到对话栏上即从排版切到引用:卡片不再跟指针走,改铺虚线落点浮层并提示「松手即可 @ 引用 N 项素材」;拖回画布内松手仍是原来的排版语义。
- 批量范围与拖动位移同一个集合(多选整批、单选只引用按住的那张);新增批量事件 RESOURCE_REFERENCE_INSERT_MANY_EVENT,App 侧一次插入整批并只聚焦一次,空批次不派发。
- 对话栏挂 ref(不 document.querySelector)、落点浮层按对话栏当时的屏幕矩形铺在根节点上,不改对话栏自身的定位与排版。
- 测试:新增落点模型用例 5 条与画布集成用例 4 条(单卡/多选/拖回画布/pointercancel),resourceReferenceInput 新增批量事件与一次插入整批 chip 两条,共 39 条定向全绿。
- 文档:功能说明新增「拖拽引用」一节与完成清单条目、PRD 更新时间、decision-log 新增 2026-09-21 条目。
2026-09-21 03:37:43 +08:00

399 lines
12 KiB
TypeScript

/** @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<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<ResourceReferenceInsertManyEventDetail>
).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<HTMLElement>(
'.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<HTMLElement>('.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<HTMLElement>(
`[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();
});
});