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
App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向) activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载) tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄 tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警 decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行 反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处
560 lines
18 KiB
TypeScript
560 lines
18 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 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();
|
||
});
|
||
});
|