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 条目。
399 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|