Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx
T
suzmii 3f18e3dc77
Project CI / AI game creator shell Rust crates (pull_request) Failing after 1m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m20s
Project CI / Backend tests (pull_request) Failing after 4m39s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m18s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m43s
Project CI / Native shell tests (pull_request) Successful in 7m28s
Project CI / Repository checks (pull_request) Failing after 4m32s
按独立评审的三条 P2 收口:空批次不误报、重复注册留线索、文档与实测对齐
App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向)
activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载)
tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄
tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警
decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行
反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处
2026-10-04 00:38:06 +08:00

560 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @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 ResourceReference,
type ResourceReferenceInsertManyEventDetail,
} from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
fireEvent,
React,
render,
screen,
} from './appSurface/harness';
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
const AppComponent = App as React.ComponentType<
NonNullable<React.ComponentProps<typeof App>>
>;
/**
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
*
* 三条口径:
* 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[];
};
/** 画布与真实 DirectProject 聊天面共用的项目身份。 */
const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop';
function installTauri(manifest: GameCreationAppManifest): {
layoutWrites: LayoutWrite[];
} {
const layoutWrites: LayoutWrite[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
const chatHarness = createProjectChatRuntimeHarness({
projectPath: CANVAS_PROJECT_PATH,
initialSessionExists: false,
});
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 [];
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: CANVAS_PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
return chatHarness.invoke(command, args);
},
);
installTauriRuntime({
core: { invoke },
event: { listen: chatHarness.listen },
});
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(
options: {
/** 项目附件(无 manifest 登记的素材来源)。 */
attachments?: ProjectAttachmentResult[];
/** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */
categoryLabel?: string;
} = {},
) {
const { attachments = [], categoryLabel = '角色与对象' } = options;
const manifest = manifestFor('chat-reference-drop', [
characterAsset('drop-a', 'a.png'),
characterAsset('drop-b', 'b.png'),
]);
const tauri = installTauri(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: CANVAS_PROJECT_PATH,
manifest,
attachments,
recentRunStatus: null,
recentRunStopReason: null,
// 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` →
// `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
chat: React.createElement(AppComponent, {
initialProjectPath: CANVAS_PROJECT_PATH,
}),
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: `打开${categoryLabel}` }));
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}"]`,
)!;
}
/**
* 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。
*
* 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿,
* 不能再读 window 上的一次 dispatch。
*/
function draftReferenceIds() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id') ?? '');
}
afterEach(() => {
resetTauriRuntime();
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',
});
// 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。
await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a']));
// 拖到对话不是排版:一条坐标都不写。
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]!
.filter(
(reference): reference is ResourceReference =>
reference.type === 'resource',
)
.map((reference) => reference.resourceId),
),
).toEqual(new Set(['drop-a', 'drop-b']));
// 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。
await waitFor(() =>
expect(draftReferenceIds()).toEqual(['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();
});
it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => {
// 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。
const { manager, inserts, dispose } = await mountCanvas({
categoryLabel: '待归类',
attachments: [
{
fileName: 'raw-shot.png',
mediaType: 'image/png',
status: 'imported',
localPath: 'assets/raw-shot.png',
},
],
});
const card = cardIn(manager, 'attachment:assets/raw-shot.png');
expect(card).not.toBeNull();
fireEvent.click(card);
// 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。
expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull();
fireEvent.pointerDown(card, {
pointerId: 74,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 74,
buttons: 1,
clientX: 1000,
clientY: 300,
});
// 落点提示也说清原因,不承诺一次插不进去的引用。
expect(
screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'),
).not.toBeNull();
fireEvent.pointerUp(card, {
pointerId: 74,
button: 0,
clientX: 1000,
clientY: 300,
});
await settle();
expect(
screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'),
).not.toBeNull();
expect(inserts).toHaveLength(0);
expect(draftReferenceIds()).toEqual([]);
dispose();
});
it('空批次引用事件不报「没有可用的聊天输入区」', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { dispose } = await mountCanvas();
// 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return),
// 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。
act(() => {
window.dispatchEvent(
new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, {
detail: { references: [] },
}),
);
});
await settle();
expect(
warn.mock.calls
.map((call) => String(call[0]))
.filter((message) => message.includes('没有可用的聊天输入区')),
).toEqual([]);
expect(draftReferenceIds()).toEqual([]);
warn.mockRestore();
dispose();
});
});