Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

1990 lines
62 KiB
TypeScript
Raw Permalink 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, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
ProjectResourceCanvasCategory,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
import {
createResourceSignature,
useProjectResourceCanvasLayout,
} from '../src/view/project-development/useProjectResourceCanvasLayout';
import {
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
openResourceFilterPanel,
React,
render,
screen,
within,
} from './appSurface/harness';
/**
* 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。
*
* 三条口径:
* 1. 画布不再因为「资源协调签名变化」重排整张画布——新增一张素材只补它的位置,
* 既有自动卡坐标逐值不变;要重排只能由用户按「整理画布」显式发起。
* 2. 新素材入库后自动进入视口并被选中;被搜索条件挡住时沿用既有「清除搜索并定位」。
* 3. 首次打开项目 / 切项目不触发聚焦跳转。
*/
const NEW_ASSET_ID = 'asset-newly-generated';
const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`;
type AssetFixture = GameCreationAppAssetManifestEntry;
function pngAsset(id: string, fileName: string): AssetFixture {
return {
id,
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: `assets/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function markdownAsset(id: string, fileName: string): AssetFixture {
return {
id,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `docs/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function manifestFor(
projectId: string,
assets: AssetFixture[],
): GameCreationAppManifest {
return {
...createGameCreationAppManifest(projectId, `${projectId} 项目`),
assets: assets.map((asset) => structuredClone(asset)),
};
}
type LayoutWrite = {
projectPath: string;
mode: string;
positions: ProjectResourceCanvasPosition[];
};
type FakeTauri = {
invoke: ReturnType<typeof vi.fn>;
layoutWrites: LayoutWrite[];
layoutReads: Array<{ projectPath: string; mode: string }>;
unexpectedCommands: string[];
};
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,
};
}
/**
* 只铺张画布真正用到的那几个本地命令。
*
* 未知命令返回 `undefined` 并记账(而不是抛错):这份用例验的是布局与聚焦链路,
* 不该因为画布里别的入口多调一个命令就整体转红;真要漏了关键命令,
* 目标链路自己会停在"等待"上,断言照样失败。
*/
function installLayoutTauri(
options: {
projectIdsByPath?: Record<string, string>;
layoutByScope?: Record<string, ProjectResourceCanvasPosition[]>;
} = {},
): FakeTauri {
const layoutWrites: LayoutWrite[] = [];
const layoutReads: Array<{ projectPath: string; mode: string }> = [];
const unexpectedCommands: string[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
for (const [key, positions] of Object.entries(options.layoutByScope ?? {})) {
persisted.set(key, structuredClone(positions));
}
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 projectPath = String(args?.projectPath ?? '');
const mode = String(args?.mode ?? '');
layoutReads.push({ projectPath, mode });
const key = `${projectPath}|${mode}`;
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: options.projectIdsByPath?.[projectPath] ?? '',
mode,
revision: revisions.get(key) ?? 0,
positions: structuredClone(persisted.get(key) ?? []),
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const projectPath = String(args?.projectPath ?? '');
const mode = String(args?.mode ?? '');
const key = `${projectPath}|${mode}`;
const positions = structuredClone(
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
);
persisted.set(key, positions);
revisions.set(key, Number(args?.expectedRevision ?? 0) + 1);
layoutWrites.push({ projectPath, mode, positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode,
revision: revisions.get(key)!,
positions,
updatedAt: 1,
},
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'list_local_project_asset_generations') {
/*
* 后台生成任务账本(`../src-tauri/src/asset_generation_tasks.rs` 的
* `list_local_project_asset_generations`)。工作台打开 / 切项目时读一次,用来恢复
* 「生成任务」面板;返回结构是 `Vec<AssetGenerationTaskRecord>`,也就是**数组**
* (前端按 `LocalProjectAssetGenerationTaskRecord[]` 消费),不是 `{ tasks }` 包一层。
*
* 本文件验的是布局与聚焦链路,不涉及后台生成任务,所以回空账本:
* `mergeResourceCanvasAssetGenerationTasksWithRecords(tasks, [])` 是恒等合并。
* 这是合法的跨工作流新调用,登记它,而不是放宽下面的 `unexpectedCommands` 断言。
*/
return [];
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 8,
content: '# 玩法规则',
};
}
unexpectedCommands.push(command);
return undefined;
},
);
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return { invoke, layoutWrites, layoutReads, unexpectedCommands };
}
function typeWrites(tauri: FakeTauri) {
return tauri.layoutWrites.filter((write) => write.mode === 'type');
}
function dependencyWrites(tauri: FakeTauri) {
return tauri.layoutWrites.filter((write) => write.mode === 'dependency');
}
function selectedResourceIdsInDom() {
return Array.from(
document.querySelectorAll<HTMLElement>('[data-resource-id]'),
)
.filter((element) => element.getAttribute('aria-pressed') === 'true')
.map((element) => element.dataset.resourceId);
}
/**
* 把布局读写与聚焦裁决链跑到底再下断言。
*
* 「不该聚焦」这类否定断言最怕"跑得太早":断言时链路还没走到聚焦那一步,写什么都会绿。
* 这里先把微任务与一个宏任务放完,让链路在该触发的情况下已经触发过。
*/
async function settleFocusChain() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
type ProjectFixture = {
projectId: string;
projectPath: string;
assets: AssetFixture[];
};
/**
* 把 manifest 交给真实工作台视图持有,并提供两个"外部世界"动作:
* 「入库新素材」(等价于生成流程落盘后 `onManifestChange` 收到多一条 asset 的清单)
* 与「切换项目」(换 projectPath + projectId + 清单)。
*/
function LayoutWorkbench({
projects,
appendedAsset,
}: {
projects: ProjectFixture[];
appendedAsset?: AssetFixture;
}) {
const [activeIndex, setActiveIndex] = React.useState(0);
const [manifests, setManifests] = React.useState<
Record<string, GameCreationAppManifest>
>(() =>
Object.fromEntries(
projects.map((project) => [
project.projectId,
manifestFor(project.projectId, project.assets),
]),
),
);
const active = projects[activeIndex]!;
const manifest = manifests[active.projectId]!;
return (
<>
{appendedAsset ? (
<button
type="button"
onClick={() =>
setManifests((current) => {
const base = current[active.projectId]!;
return {
...current,
[active.projectId]: {
...base,
assets: [...base.assets, structuredClone(appendedAsset)],
},
};
})
}
>
测试:入库新素材
</button>
) : null}
{projects.length > 1 ? (
<button
type="button"
onClick={() =>
setActiveIndex((current) => (current + 1) % projects.length)
}
>
测试:切换项目
</button>
) : null}
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={active.projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
chat={<div>项目对话</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) =>
setManifests((current) => {
const projectId = nextManifest.projectId;
return { ...current, [projectId]: nextManifest };
})
}
/>
</>
);
}
afterEach(() => {
delete window.__TAURI__;
vi.restoreAllMocks();
});
const POINTER_WORKBENCH_PROJECT_ID = 'pointer-workbench';
const POINTER_WORKBENCH_PATH = '/tmp/pointer-workbench';
async function mountPointerWorkbench(
target: 'main' | 'character' | 'all' = 'character',
options: {
assets?: AssetFixture[];
/** 每个排序模式一份持久化坐标:`{mode}` 传具体模式名(`dependency` / `type`)。 */
layoutByMode?: Record<string, ProjectResourceCanvasPosition[]>;
} = {},
) {
const projectId = POINTER_WORKBENCH_PROJECT_ID;
const projectPath = POINTER_WORKBENCH_PATH;
const layoutByScope = Object.fromEntries(
Object.entries(options.layoutByMode ?? {}).map(([mode, positions]) => [
`${projectPath}|${mode}`,
positions,
]),
);
const tauri = installLayoutTauri({
projectIdsByPath: { [projectPath]: projectId },
layoutByScope,
});
render(
<LayoutWorkbench
projects={[
{
projectId,
projectPath,
assets: options.assets ?? [
pngAsset('pointer-a', 'a.png'),
pngAsset('pointer-b', 'b.png'),
],
},
]}
/>,
);
await settleFocusChain();
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')));
if (target !== 'main') {
fireEvent.click(
screen.getByRole('button', {
name: target === 'all' ? '打开所有资源' : '打开角色与对象',
}),
);
}
await settleFocusChain();
const surface = manager.querySelector<HTMLElement>(
target === 'main'
? '.game-resource-book-main'
: '.game-resource-book-scene',
)!;
const capture = new Set<number>();
Object.defineProperties(surface, {
setPointerCapture: {
configurable: true,
value: vi.fn((id: number) => capture.add(id)),
},
hasPointerCapture: {
configurable: true,
value: (id: number) => capture.has(id),
},
releasePointerCapture: {
configurable: true,
value: vi.fn((id: number) => capture.delete(id)),
},
});
const world = manager.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
)!;
const viewport = () =>
Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) =>
Number(m[0]),
);
return { manager, surface, world, viewport, tauri };
}
describe('资源画布指针与运行提示', () => {
it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => {
const { manager, viewport, tauri } = await mountPointerWorkbench();
const card = manager.querySelector<HTMLElement>(
'.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]',
)!;
const before = viewport();
const writes = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 30,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 30,
buttons: 1,
clientX: 180,
clientY: 140,
});
fireEvent.pointerUp(card, {
pointerId: 30,
button: 0,
clientX: 180,
clientY: 140,
});
await waitFor(() =>
expect(tauri.layoutWrites.length).toBeGreaterThan(writes),
);
expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:pointer-a',
manuallyPlaced: true,
}),
]),
);
expect(viewport()).toEqual(before);
expect(
document.querySelector('.genarrative-image-canvas__selection-overlay'),
).toBeNull();
});
it('资源选中不移除运行不可用提示或改变运行能力', async () => {
const { manager } = await mountPointerWorkbench();
const hint = '首个可运行原型尚未完成,运行视图暂不可用';
expect(screen.getByText(hint)).not.toBeNull();
fireEvent.click(
manager.querySelector<HTMLElement>(
'.is-expanded [data-resource-id="asset:pointer-a"]',
)!,
);
expect(screen.getByText(hint)).not.toBeNull();
const run = screen.getByRole('tab', { name: '运行' });
expect(run.getAttribute('data-unavailable')).toBe('true');
expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint');
fireEvent.click(run);
expect(run.getAttribute('aria-selected')).toBe('false');
});
it.each(['main', 'character', 'all'] as const)(
'%s 的空白处支持右键平移且不写资源布局',
async (target) => {
const { surface, viewport, tauri } = await mountPointerWorkbench(target);
const before = viewport();
const writes = tauri.layoutWrites.length;
fireEvent.pointerDown(surface, {
pointerId: 31,
button: 2,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(surface, {
pointerId: 31,
buttons: 2,
clientX: 170,
clientY: 140,
});
expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]);
expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false);
fireEvent.pointerUp(surface, {
pointerId: 31,
button: 2,
clientX: 170,
clientY: 140,
});
expect(
document.querySelector('.genarrative-image-canvas__selection-overlay'),
).toBeNull();
expect(tauri.layoutWrites).toHaveLength(writes);
},
);
it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => {
const { manager, surface, viewport, tauri } = await mountPointerWorkbench();
const card = manager.querySelector<HTMLElement>(
'.is-expanded [data-resource-id="asset:pointer-a"]',
)!;
fireEvent.click(card);
const before = viewport();
const writes = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 32,
button: 2,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(surface, {
pointerId: 32,
buttons: 2,
clientX: 150,
clientY: 160,
});
fireEvent.pointerUp(surface, {
pointerId: 32,
button: 2,
clientX: 150,
clientY: 160,
});
expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]);
expect(card.getAttribute('aria-pressed')).toBe('true');
expect(tauri.layoutWrites).toHaveLength(writes);
const panned = viewport();
fireEvent.pointerDown(surface, {
pointerId: 33,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(surface, {
pointerId: 33,
buttons: 1,
clientX: 780,
clientY: 580,
});
expect(
document.querySelector('.genarrative-image-canvas__selection-overlay'),
).not.toBeNull();
expect(selectedResourceIdsInDom()).toContain('asset:pointer-a');
expect(viewport()).toEqual(panned);
fireEvent.pointerUp(surface, { pointerId: 33, button: 0 });
expect(
document.querySelector('.genarrative-image-canvas__selection-overlay'),
).toBeNull();
});
it.each(['cancel', 'capture', 'blur'] as const)(
'%s 后右键平移不会继续跟随指针',
async (reason) => {
const { surface, viewport } = await mountPointerWorkbench();
const before = viewport();
fireEvent.pointerDown(surface, {
pointerId: 34,
button: 2,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(surface, {
pointerId: 34,
buttons: 2,
clientX: 110,
clientY: 120,
});
const moved = viewport();
expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]);
if (reason === 'cancel')
fireEvent.pointerCancel(surface, { pointerId: 34 });
else if (reason === 'capture')
fireEvent.lostPointerCapture(surface, { pointerId: 34 });
else act(() => window.dispatchEvent(new Event('blur')));
fireEvent.pointerMove(surface, {
pointerId: 34,
clientX: 300,
clientY: 300,
});
expect(viewport()).toEqual(moved);
},
);
it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => {
const { surface, viewport } = await mountPointerWorkbench('main');
const before = viewport();
fireEvent.pointerDown(surface, {
pointerId: 36,
button: 2,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(surface, {
pointerId: 36,
buttons: 2,
clientX: 40,
clientY: 40,
});
const moved = viewport();
expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]);
act(() => window.dispatchEvent(new Event('blur')));
expect(surface.releasePointerCapture).toHaveBeenCalledWith(36);
fireEvent.pointerMove(surface, {
pointerId: 36,
clientX: 100,
clientY: 100,
});
expect(viewport()).toEqual(moved);
fireEvent.pointerDown(surface, {
pointerId: 37,
button: 2,
clientX: 10,
clientY: 10,
});
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
expect(surface.releasePointerCapture).toHaveBeenCalledWith(37);
});
it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => {
const { manager, surface, viewport } = await mountPointerWorkbench();
const zoom = screen.getByRole('button', { name: '放大画布' });
const before = viewport();
fireEvent.pointerDown(zoom, {
pointerId: 35,
button: 2,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(surface, {
pointerId: 35,
buttons: 2,
clientX: 200,
clientY: 200,
});
expect(viewport()).toEqual(before);
expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true);
await act(async () => {
for (let i = 0; i < 100; i++) {
fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 });
}
});
expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]);
});
});
describe('资源画布手动重排口径', () => {
it('hookrederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
const projectId = 'manual-rederive-project';
const projectPath = '/tmp/manual-rederive-project';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 3,
positions: [
{
resourceId: 'resource-b',
section: 'document',
x: 600,
y: 40,
manuallyPlaced: true,
},
{
resourceId: 'resource-a',
section: 'document',
x: 900,
y: 900,
manuallyPlaced: false,
},
],
updatedAt: 300,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 4,
positions: args?.positions,
updatedAt: 400,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = {
core: { invoke },
} as unknown as typeof window.__TAURI__;
const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map(
(id) => ({
id,
category: 'document' as ProjectResourceCanvasCategory,
subtype: 'agent-result',
label: id,
mediaType: 'text/markdown',
dependencyDepth: 0,
}),
);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources,
rederiveAutomaticPositions: false,
}),
);
await waitFor(() => expect(result.current.ready).toBe(true));
// 自动卡还停在落后坐标上:preserve 口径不会自己去纠正它。
expect(
result.current.layout.positions.find(
(position) => position.resourceId === 'resource-a',
),
).toMatchObject({ x: 900, y: 900 });
await act(async () => {
result.current.rederiveNow();
});
await waitFor(() =>
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(1),
);
const written = invoke.mock.calls.find(
([command]) => command === 'update_local_project_resource_canvas_layout',
)?.[1]?.positions as ProjectResourceCanvasPosition[];
expect(written).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-a',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-b',
x: 600,
y: 40,
manuallyPlaced: true,
}),
]),
);
});
it('新素材入库后既有自动卡坐标逐值不变,新卡只补在末尾', async () => {
const tauri = installLayoutTauri({
projectIdsByPath: {
'/tmp/manual-layout-project': 'manual-layout-project',
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-layout-project',
projectPath: '/tmp/manual-layout-project',
assets: [
pngAsset('asset-art-b', 'art-b.png'),
pngAsset('asset-art-c', 'art-c.png'),
pngAsset('asset-art-d', 'art-d.png'),
],
},
]}
appendedAsset={pngAsset(NEW_ASSET_ID, 'art-a-new.png')}
/>,
);
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
const before = typeWrites(tauri).at(-1)!;
expect(
before.positions.map((position) => position.resourceId),
).not.toContain(NEW_RESOURCE_ID);
fireEvent.click(
await screen.findByRole('button', { name: '测试:入库新素材' }),
);
await waitFor(() =>
expect(
typeWrites(tauri).some((write) =>
write.positions.some(
(position) => position.resourceId === NEW_RESOURCE_ID,
),
),
).toBe(true),
);
const after = typeWrites(tauri).find((write) =>
write.positions.some(
(position) => position.resourceId === NEW_RESOURCE_ID,
),
)!;
// 核心判据:除新卡外全部既有坐标逐值不变(顺序、分区、手动标记都不许动)。
expect(
after.positions.filter(
(position) => position.resourceId !== NEW_RESOURCE_ID,
),
).toEqual(
before.positions.filter(
(position) => position.resourceId !== NEW_RESOURCE_ID,
),
);
});
it('依赖画布只在关系图首次就绪时重算一次,之后新增素材不再重排', async () => {
const projectPath = '/tmp/manual-dependency-project';
const tauri = installLayoutTauri({
projectIdsByPath: { [projectPath]: 'manual-dependency-project' },
layoutByScope: {
[`${projectPath}|dependency`]: [
{
resourceId: 'asset:asset-art-b',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:asset-art-a',
section: 'character',
x: 900,
y: 900,
manuallyPlaced: false,
},
],
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-dependency-project',
projectPath,
assets: [
pngAsset('asset-art-a', 'art-a.png'),
pngAsset('asset-art-b', 'art-b.png'),
],
},
]}
appendedAsset={pngAsset(NEW_ASSET_ID, 'art-a-new.png')}
/>,
);
// 一次性重派生:关系图首次就绪后按最终拓扑把落后的自动坐标对齐一次。
await waitFor(() =>
expect(
dependencyWrites(tauri).some((write) =>
write.positions.some(
(position) =>
position.resourceId === 'asset:asset-art-a' &&
(position.x !== 900 || position.y !== 900),
),
),
).toBe(true),
);
const before = dependencyWrites(tauri).at(-1)!;
expect(
before.positions.map((position) => position.resourceId),
).not.toContain(NEW_RESOURCE_ID);
fireEvent.click(
await screen.findByRole('button', { name: '测试:入库新素材' }),
);
await waitFor(() =>
expect(
dependencyWrites(tauri).some((write) =>
write.positions.some(
(position) => position.resourceId === NEW_RESOURCE_ID,
),
),
).toBe(true),
);
const after = dependencyWrites(tauri).find((write) =>
write.positions.some(
(position) => position.resourceId === NEW_RESOURCE_ID,
),
)!;
expect(
after.positions.filter(
(position) => position.resourceId !== NEW_RESOURCE_ID,
),
).toEqual(
before.positions.filter(
(position) => position.resourceId !== NEW_RESOURCE_ID,
),
);
});
it('新素材入库后自动进入视口并被选中', async () => {
const tauri = installLayoutTauri({
projectIdsByPath: { '/tmp/manual-focus-project': 'manual-focus-project' },
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-focus-project',
projectPath: '/tmp/manual-focus-project',
assets: [
pngAsset('asset-art-b', 'art-b.png'),
pngAsset('asset-art-c', 'art-c.png'),
],
},
]}
appendedAsset={pngAsset(NEW_ASSET_ID, 'art-a-new.png')}
/>,
);
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
expect(selectedResourceIdsInDom()).toEqual([]);
fireEvent.click(
await screen.findByRole('button', { name: '测试:入库新素材' }),
);
await waitFor(() =>
expect(new Set(selectedResourceIdsInDom())).toEqual(
new Set([NEW_RESOURCE_ID]),
),
);
});
it('新素材被搜索条件挡住时走既有「清除搜索并定位」路径', async () => {
const tauri = installLayoutTauri({
projectIdsByPath: {
'/tmp/manual-focus-hidden-project': 'manual-focus-hidden-project',
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-focus-hidden-project',
projectPath: '/tmp/manual-focus-hidden-project',
assets: [pngAsset('asset-art-b', 'art-b.png')],
},
]}
appendedAsset={markdownAsset(NEW_ASSET_ID, 'new-rules.md')}
/>,
);
await findResourceSelectButton('art-b.png');
const search = openResourceFilterPanel();
fireEvent.change(search, { target: { value: 'art-b' } });
expect(selectedResourceIdsInDom()).toEqual([]);
fireEvent.click(screen.getByRole('button', { name: '测试:入库新素材' }));
expect(
await screen.findByText('新资源已保存,但被当前搜索条件隐藏'),
).not.toBeNull();
// 搜索条件只由显式动作清除,不静默改用户输入。
expect(openResourceFilterPanel().value).toBe('art-b');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
await waitFor(() =>
expect(new Set(selectedResourceIdsInDom())).toEqual(
new Set([NEW_RESOURCE_ID]),
),
);
expect(tauri.unexpectedCommands).toEqual([]);
});
it('「整理画布」重排当前栏目全部素材(含手动卡)成自动坐标,一次撤销恢复坐标与手动标记', async () => {
const projectPath = '/tmp/manual-rederive-button-project';
const tauri = installLayoutTauri({
projectIdsByPath: {
[projectPath]: 'manual-rederive-button-project',
},
layoutByScope: {
[`${projectPath}|type`]: [
{
resourceId: 'asset:asset-art-a',
section: 'character',
x: 600,
y: 40,
manuallyPlaced: true,
},
{
resourceId: 'asset:asset-art-b',
section: 'character',
x: 800,
y: 800,
manuallyPlaced: false,
},
],
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-rederive-button-project',
projectPath,
assets: [
pngAsset('asset-art-a', 'art-a.png'),
pngAsset('asset-art-b', 'art-b.png'),
],
},
]}
/>,
);
await waitFor(() =>
expect(
document.querySelector('[data-resource-id="asset:asset-art-b"]'),
).not.toBeNull(),
);
// 切到「类型」视图:两个排序模式各有一份 sidecar,按钮作用于当前生效的那一份。
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
// 打开项目 / 切排序 tab 这两步都不该重排:自动卡的落后坐标原样保留。
expect(typeWrites(tauri)).toEqual([]);
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
expect(typeWrites(tauri)[0]!.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:asset-art-b',
section: 'character',
x: 196,
y: 0,
manuallyPlaced: false,
}),
// 手动摆放过的卡也在整理范围内:坐标重算、手动标记转成自动。
expect.objectContaining({
resourceId: 'asset:asset-art-a',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
}),
]),
);
expect(await screen.findByText('布局已保存')).not.toBeNull();
// 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图
// 不得持久化自动布局」用例依赖同一条 `changed` 门)。
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
await settleFocusChain();
expect(typeWrites(tauri)).toHaveLength(1);
// 一次撤销还原整理前的坐标**与手动标记**:整次整理只是一笔可撤销操作。
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2));
expect(typeWrites(tauri)[1]!.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:asset-art-a',
section: 'character',
x: 600,
y: 40,
manuallyPlaced: true,
}),
expect.objectContaining({
resourceId: 'asset:asset-art-b',
section: 'character',
x: 800,
y: 800,
manuallyPlaced: false,
}),
]),
);
});
it('「整理画布」不属于「资源排列方式」这组模式切换,而是一枚资源动作按钮', async () => {
const projectPath = '/tmp/manual-layout-surface-project';
const tauri = installLayoutTauri({
projectIdsByPath: { [projectPath]: 'manual-layout-surface-project' },
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-layout-surface-project',
projectPath,
assets: [pngAsset('asset-art-b', 'art-b.png')],
},
]}
/>,
);
const sortGroup = await screen.findByRole('group', {
name: '资源排列方式',
});
// 这组里只有两种排列方式:用户不该把「整理画布」读成第三种排列方式。
expect(
within(sortGroup)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['按依赖', '按类型']);
expect(
within(sortGroup).queryByRole('button', { name: '整理画布' }),
).toBeNull();
// 它仍是同一行里的同一枚动作按钮,只是搬出了那个 group、也离开了行尾。
const rederiveButton = screen.getByRole('button', { name: '整理画布' });
expect(sortGroup.contains(rederiveButton)).toBe(false);
expect(rederiveButton.closest('.game-resource-sort-tabs')).toBeNull();
const actionsRow = rederiveButton.closest('.game-workbench-view-actions');
expect(actionsRow).not.toBeNull();
// 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。
const rowButtons = Array.from(actionsRow!.querySelectorAll('button'));
expect(rowButtons.at(-1)).not.toBe(rederiveButton);
const rederiveIndex = rowButtons.indexOf(rederiveButton);
const sortGroupIndex = rowButtons.findIndex((button) =>
sortGroup.contains(button),
);
expect(rederiveIndex).toBeGreaterThanOrEqual(0);
expect(sortGroupIndex).toBeGreaterThanOrEqual(0);
expect(rederiveIndex).toBeLessThan(sortGroupIndex);
// 语义没变:可点性只跟布局就绪绑定,布局读完后它就是可点的。
await waitFor(() =>
expect(
screen
.getByRole('button', { name: '整理画布' })
.hasAttribute('disabled'),
).toBe(false),
);
});
it('首次打开项目与切项目都不触发新素材聚焦跳转', async () => {
const tauri = installLayoutTauri({
projectIdsByPath: {
'/tmp/manual-open-project-a': 'manual-open-project-a',
'/tmp/manual-open-project-b': 'manual-open-project-b',
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'manual-open-project-a',
projectPath: '/tmp/manual-open-project-a',
assets: [
pngAsset('asset-a1', 'a1.png'),
pngAsset('asset-a2', 'a2.png'),
],
},
{
projectId: 'manual-open-project-b',
projectPath: '/tmp/manual-open-project-b',
assets: [
pngAsset('asset-b1', 'b1.png'),
pngAsset('asset-b2', 'b2.png'),
],
},
]}
/>,
);
await waitFor(() =>
expect(
document.querySelector('[data-resource-id="asset:asset-a1"]'),
).not.toBeNull(),
);
// 布局读写先跑完,再给聚焦裁决链一次"要是会被误触发就已经触发"的机会。
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
await settleFocusChain();
expect(selectedResourceIdsInDom()).toEqual([]);
fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' }));
await waitFor(() =>
expect(
tauri.layoutReads.some(
(read) => read.projectPath === '/tmp/manual-open-project-b',
),
).toBe(true),
);
await waitFor(() =>
expect(
document.querySelector('[data-resource-id="asset:asset-b1"]'),
).not.toBeNull(),
);
await waitFor(() =>
expect(
typeWrites(tauri).some(
(write) => write.projectPath === '/tmp/manual-open-project-b',
),
).toBe(true),
);
await settleFocusChain();
expect(selectedResourceIdsInDom()).toEqual([]);
});
it('资源协调签名仍把新增素材算作变化(重排判据没有被人为掐掉)', () => {
const single = createResourceSignature([
{
id: 'asset:x',
category: 'character',
subtype: 'character',
label: 'x',
mediaType: 'image/png',
dependencyDepth: 0,
},
]);
const doubled = createResourceSignature([
{
id: 'asset:x',
category: 'character',
subtype: 'character',
label: 'x',
mediaType: 'image/png',
dependencyDepth: 0,
},
{
id: 'asset:y',
category: 'character',
subtype: 'character',
label: 'y',
mediaType: 'image/png',
dependencyDepth: 0,
},
]);
expect(single).not.toBe(doubled);
});
});
/**
* 多选批量移动、整理范围与取消/切项目边界的画布级验收。
*
* 口径:拖动已选卡按统一位移移动整个选择集;拖动未选卡保持单选;松手一次提交、一次撤销;
* 取消/失焦/切项目不留下半截位移;整理只作用于当前栏目(筛选不缩小集合),其他栏目不变。
*/
describe('资源画布多选拖动与整理范围', () => {
function writePositionsById(write: LayoutWrite) {
return new Map(
write.positions.map((position) => [position.resourceId, position]),
);
}
function cardIn(manager: HTMLElement, resourceId: string) {
return manager.querySelector<HTMLElement>(
`.is-expanded .game-resource-card[data-resource-card-id="${resourceId}"]`,
)!;
}
/**
* type 侧的持久化坐标:两张角色卡摆在互为上下的相对位置上,整理后槽位可预测
* (第一槽 0,0 / 第二槽 196,0)。
*/
const twoCharacterCards: ProjectResourceCanvasPosition[] = [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 400,
y: 300,
manuallyPlaced: false,
},
];
async function selectTwoCharacterCards(
manager: HTMLElement,
tauri: FakeTauri,
) {
// 打开角色与对象栏目页并切到「按类型」:type 侧坐标可预测,写入也走同一套写队列。
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const cardA = cardIn(manager, 'asset:pointer-a');
const cardB = cardIn(manager, 'asset:pointer-b');
fireEvent.click(cardA);
fireEvent.click(cardB, { shiftKey: true });
await waitFor(() =>
expect(new Set(selectedResourceIdsInDom())).toEqual(
new Set(['asset:pointer-a', 'asset:pointer-b']),
),
);
expect(typeWrites(tauri)).toEqual([]);
return { cardA, cardB };
}
it.each(['character', 'all'] as const)(
'%s:多选拖动期间布局重建,预览仍使用按下时起点并与松手写回一致',
async (target) => {
const { manager, viewport, tauri } = await mountPointerWorkbench(target, {
layoutByMode: { type: twoCharacterCards },
});
const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri);
const scale = viewport()[2]!;
fireEvent.pointerDown(cardB, {
pointerId: 49,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 49,
buttons: 1,
clientX: 180,
clientY: 140,
});
const beforeA = [
cardA.style.getPropertyValue('--resource-x'),
cardA.style.getPropertyValue('--resource-y'),
];
const beforeB = [
cardB.style.getPropertyValue('--resource-x'),
cardB.style.getPropertyValue('--resource-y'),
];
// 重建底层布局,模拟拖动尚未结束时另一笔布局操作完成。
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
expect(
typeWrites(tauri)[0]!.positions.find(
(p) => p.resourceId === 'asset:pointer-b',
)?.y,
).toBe(0);
expect([
cardA.style.getPropertyValue('--resource-x'),
cardA.style.getPropertyValue('--resource-y'),
]).toEqual(beforeA);
expect([
cardB.style.getPropertyValue('--resource-x'),
cardB.style.getPropertyValue('--resource-y'),
]).toEqual(beforeB);
fireEvent.pointerUp(cardB, {
pointerId: 49,
button: 0,
clientX: 180,
clientY: 140,
});
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2));
expect(typeWrites(tauri)[1]!.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:pointer-a',
x: Math.round(80 / scale),
y: Math.round(40 / scale),
}),
expect.objectContaining({
resourceId: 'asset:pointer-b',
x: Math.round(400 + 80 / scale),
y: Math.round(300 + 40 / scale),
}),
]),
);
},
);
it('拖动已选卡整批等位移、一笔提交、一次撤销,多选保持', async () => {
const { manager, viewport, tauri } = await mountPointerWorkbench(
'character',
{
layoutByMode: { type: twoCharacterCards },
},
);
const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri);
const scale = viewport()[2]!;
expect(scale).toBeGreaterThan(0);
fireEvent.pointerDown(cardB, {
pointerId: 41,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 41,
buttons: 1,
clientX: 180,
clientY: 140,
});
fireEvent.pointerUp(cardB, {
pointerId: 41,
button: 0,
clientX: 180,
clientY: 140,
});
// 松手只提交一笔:整批卡在同一次 CAS 里落盘。
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
const after = writePositionsById(typeWrites(tauri)[0]!);
const deltaX = Math.round(80 / scale);
const deltaY = Math.round(40 / scale);
expect(after.get('asset:pointer-a')).toEqual({
resourceId: 'asset:pointer-a',
section: 'character',
x: 0 + deltaX,
y: 0 + deltaY,
manuallyPlaced: true,
});
expect(after.get('asset:pointer-b')).toEqual({
resourceId: 'asset:pointer-b',
section: 'character',
x: Math.round(400 + 80 / scale),
y: Math.round(300 + 40 / scale),
manuallyPlaced: true,
});
// 相对位置保持原样(等位移)。
expect(
after.get('asset:pointer-b')!.x - after.get('asset:pointer-a')!.x,
).toBe(400);
expect(
after.get('asset:pointer-b')!.y - after.get('asset:pointer-a')!.y,
).toBe(300);
// 拖动已选卡不丢多选。
expect(new Set(selectedResourceIdsInDom())).toEqual(
new Set(['asset:pointer-a', 'asset:pointer-b']),
);
// 一次撤销把整批带回拖动前的坐标。
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2));
const restored = writePositionsById(typeWrites(tauri)[1]!);
expect(restored.get('asset:pointer-a')).toMatchObject({
x: 0,
y: 0,
manuallyPlaced: false,
});
expect(restored.get('asset:pointer-b')).toMatchObject({
x: 400,
y: 300,
manuallyPlaced: false,
});
});
it('不同缩放下多选位移按 scale 换算', async () => {
const { manager, viewport, tauri } = await mountPointerWorkbench(
'character',
{
layoutByMode: { type: twoCharacterCards },
},
);
const { cardB } = await selectTwoCharacterCards(manager, tauri);
const before = viewport()[2]!;
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
const scale = viewport()[2]!;
expect(scale).toBeGreaterThan(before);
fireEvent.pointerDown(cardB, {
pointerId: 42,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 42,
buttons: 1,
clientX: 200,
clientY: 150,
});
fireEvent.pointerUp(cardB, {
pointerId: 42,
button: 0,
clientX: 200,
clientY: 150,
});
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
const after = writePositionsById(typeWrites(tauri)[0]!);
expect(after.get('asset:pointer-a')).toMatchObject({
x: Math.round(100 / scale),
y: Math.round(50 / scale),
});
expect(after.get('asset:pointer-b')).toMatchObject({
x: Math.round(400 + 100 / scale),
y: Math.round(300 + 50 / scale),
});
// 屏幕位移相同、坐标位移被 scale 收窄:不是按像素直接落盘。
expect(
after.get('asset:pointer-a')!.x - after.get('asset:pointer-a')!.y,
).not.toBe(50);
});
it('拖动未选中的卡保持单选语义:只有它自己动', async () => {
const { manager, viewport, tauri } = await mountPointerWorkbench(
'character',
{
layoutByMode: { type: twoCharacterCards },
},
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const cardA = cardIn(manager, 'asset:pointer-a');
const cardB = cardIn(manager, 'asset:pointer-b');
fireEvent.click(cardA);
expect(selectedResourceIdsInDom()).toEqual(['asset:pointer-a']);
const scale = viewport()[2]!;
fireEvent.pointerDown(cardB, {
pointerId: 43,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 43,
buttons: 1,
clientX: 180,
clientY: 120,
});
fireEvent.pointerUp(cardB, {
pointerId: 43,
button: 0,
clientX: 180,
clientY: 120,
});
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
const after = writePositionsById(typeWrites(tauri)[0]!);
expect(after.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0 });
expect(after.get('asset:pointer-b')).toMatchObject({
x: Math.round(400 + 80 / scale),
y: Math.round(300 + 20 / scale),
});
});
it.each(['cancel', 'capture', 'blur'] as const)(
'%s 取消多选拖动:不落盘、坐标回到拖动前、选择保留',
async (reason) => {
const { manager, tauri } = await mountPointerWorkbench('character', {
layoutByMode: { type: twoCharacterCards },
});
const { cardB } = await selectTwoCharacterCards(manager, tauri);
const cardARoot = cardIn(manager, 'asset:pointer-a');
fireEvent.pointerDown(cardB, {
pointerId: 44,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 44,
buttons: 1,
clientX: 260,
clientY: 220,
});
// 拖动中整批卡先跟指针走。
expect(cardIn(manager, 'asset:pointer-b').className).toContain(
'is-dragging',
);
expect(cardARoot.className).toContain('is-dragging');
if (reason === 'cancel') {
fireEvent.pointerCancel(cardB, { pointerId: 44 });
} else if (reason === 'capture') {
fireEvent.lostPointerCapture(cardB, { pointerId: 44 });
} else {
act(() => window.dispatchEvent(new Event('blur')));
}
await settleFocusChain();
expect(typeWrites(tauri)).toEqual([]);
// 预览已丢掉:卡片回到拖动前的位置(不再带拖动态)。
expect(cardIn(manager, 'asset:pointer-b').className).not.toContain(
'is-dragging',
);
expect(cardARoot.className).not.toContain('is-dragging');
expect(new Set(selectedResourceIdsInDom())).toEqual(
new Set(['asset:pointer-a', 'asset:pointer-b']),
);
},
);
it('整理只重排当前栏目,其他栏目坐标与手动标记不变', async () => {
const { manager, tauri } = await mountPointerWorkbench('character', {
assets: [
pngAsset('pointer-a', 'a.png'),
pngAsset('pointer-b', 'b.png'),
{
...pngAsset('scene-c', 'scene-c.png'),
category: 'scene',
kind: 'scene',
},
],
layoutByMode: {
type: [
...twoCharacterCards,
{
resourceId: 'asset:scene-c',
section: 'scene',
x: 30,
y: 60,
manuallyPlaced: true,
},
],
},
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(),
);
await settleFocusChain();
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
const after = writePositionsById(typeWrites(tauri)[0]!);
// 当前栏目:两张角色卡一起重算成自动坐标。
expect(after.get('asset:pointer-a')).toMatchObject({
x: 0,
y: 0,
manuallyPlaced: false,
});
expect(after.get('asset:pointer-b')).toMatchObject({
x: 196,
y: 0,
manuallyPlaced: false,
});
// 其他栏目:坐标与手动标记逐值不动。
expect(after.get('asset:scene-c')).toEqual({
resourceId: 'asset:scene-c',
section: 'scene',
x: 30,
y: 60,
manuallyPlaced: true,
});
});
it('筛选不缩小整理集合:被搜索隐藏的同栏目素材同样参与重排', async () => {
const { manager, tauri } = await mountPointerWorkbench('character', {
layoutByMode: { type: twoCharacterCards },
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(),
);
const search = openResourceFilterPanel();
fireEvent.change(search, { target: { value: 'a.png' } });
// 被搜索挡住的第二张卡在画布上不可交互(宿主机标记 aria-hidden),但仍在当前栏目里。
const hiddenCardHost = () =>
cardIn(manager, 'asset:pointer-b').closest(
'.game-resource-book-scene-card',
)!;
await waitFor(() =>
expect(hiddenCardHost().getAttribute('aria-hidden')).toBe('true'),
);
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1));
const after = writePositionsById(typeWrites(tauri)[0]!);
expect(after.get('asset:pointer-a')).toMatchObject({
x: 0,
y: 0,
manuallyPlaced: false,
});
expect(after.get('asset:pointer-b')).toMatchObject({
x: 196,
y: 0,
manuallyPlaced: false,
});
});
it('「所有资源」页整理全部栏目的可展示素材,手动坐标一并重算且只写一笔', async () => {
const { manager, tauri } = await mountPointerWorkbench('all', {
assets: [
pngAsset('pointer-a', 'a.png'),
pngAsset('pointer-b', 'b.png'),
{
...pngAsset('scene-c', 'scene-c.png'),
category: 'scene',
kind: 'scene',
},
{ ...markdownAsset('doc-d', 'd.md'), category: 'document' },
],
layoutByMode: {
type: [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 500,
y: 500,
manuallyPlaced: true,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 400,
y: 300,
manuallyPlaced: false,
},
{
resourceId: 'asset:scene-c',
section: 'scene',
x: 30,
y: 60,
manuallyPlaced: true,
},
{
resourceId: 'asset:doc-d',
section: 'document',
x: 700,
y: 700,
manuallyPlaced: false,
},
],
},
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(),
);
await settleFocusChain();
// 读盘那一次「归并 / 规范化」写回与本次要验的整理是两回事,先等它落地再记账。
const writesBeforeOrganize = typeWrites(tauri).length;
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
// 「所有资源」= 当前项目全部可展示素材,一次整理仍然只写一笔。
await waitFor(() =>
expect(typeWrites(tauri)).toHaveLength(writesBeforeOrganize + 1),
);
const after = writePositionsById(typeWrites(tauri).at(-1)!);
// 每个栏目各自重算成自动槽位:同栏目第二张卡落在 196,0,跨栏目互不影响。
expect(after.get('asset:pointer-a')).toMatchObject({
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
});
expect(after.get('asset:pointer-b')).toMatchObject({
section: 'character',
x: 196,
y: 0,
manuallyPlaced: false,
});
expect(after.get('asset:scene-c')).toMatchObject({
section: 'scene',
x: 0,
y: 0,
manuallyPlaced: false,
});
expect(after.get('asset:doc-d')).toMatchObject({
section: 'document',
x: 0,
y: 0,
manuallyPlaced: false,
});
});
it('切项目清空选择并丢弃进行中的拖动,不把位移写进新项目', async () => {
const projectPathA = '/tmp/multi-drag-project-a';
const projectPathB = '/tmp/multi-drag-project-b';
const tauri = installLayoutTauri({
projectIdsByPath: {
[projectPathA]: 'multi-drag-project-a',
[projectPathB]: 'multi-drag-project-b',
},
layoutByScope: {
[`${projectPathA}|type`]: twoCharacterCards,
[`${projectPathB}|type`]: [
{
resourceId: 'asset:project-b-1',
section: 'document',
x: 10,
y: 20,
manuallyPlaced: false,
},
],
},
});
render(
<LayoutWorkbench
projects={[
{
projectId: 'multi-drag-project-a',
projectPath: '/tmp/multi-drag-project-a',
assets: [
pngAsset('pointer-a', 'a.png'),
pngAsset('pointer-b', 'b.png'),
],
},
{
projectId: 'multi-drag-project-b',
projectPath: '/tmp/multi-drag-project-b',
assets: [
{ ...markdownAsset('project-b-1', 'b-1.md'), kind: 'document' },
],
},
]}
/>,
);
await settleFocusChain();
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: '按类型' }));
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(),
);
const cardA = cardIn(manager, 'asset:pointer-a');
const cardB = cardIn(manager, 'asset:pointer-b');
fireEvent.click(cardA);
fireEvent.click(cardB, { shiftKey: true });
await waitFor(() => expect(selectedResourceIdsInDom()).toHaveLength(2));
fireEvent.pointerDown(cardB, {
pointerId: 45,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardB, {
pointerId: 45,
buttons: 1,
clientX: 300,
clientY: 240,
});
fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' }));
await waitFor(() =>
expect(
tauri.layoutReads.some((read) => read.projectPath === projectPathB),
).toBe(true),
);
// 松手发生在切项目之后:位移不写进新项目。
fireEvent.pointerUp(cardB, {
pointerId: 45,
button: 0,
clientX: 300,
clientY: 240,
});
await settleFocusChain();
// 两个项目都没有留下拖动落点:没有任何一笔手动坐标。
for (const projectPath of [projectPathA, projectPathB]) {
const manualWrites = tauri.layoutWrites
.filter((write) => write.projectPath === projectPath)
.flatMap((write) => write.positions)
.filter((position) => position.manuallyPlaced);
expect(manualWrites).toEqual([]);
}
// 两个项目的 type 侧坐标逐值保持原样(拖动没有落盘,也没有跨项目串写)。
expect(
tauri.layoutWrites.filter(
(write) =>
write.mode === 'type' &&
(write.projectPath === projectPathA ||
write.projectPath === projectPathB),
),
).toEqual([]);
await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([]));
});
/**
* 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。
*
* 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把
* type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的
* 布局。
*/
it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => {
const { manager, tauri } = await mountPointerWorkbench('character', {
layoutByMode: {
type: [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 400,
y: 300,
manuallyPlaced: false,
},
],
dependency: [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 700,
y: 700,
manuallyPlaced: false,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 900,
y: 900,
manuallyPlaced: false,
},
],
},
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(),
);
await settleFocusChain();
// 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。
const cardA = cardIn(manager, 'asset:pointer-a');
fireEvent.pointerDown(cardA, {
pointerId: 61,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardA, {
pointerId: 61,
buttons: 1,
clientX: 180,
clientY: 140,
});
fireEvent.pointerUp(cardA, {
pointerId: 61,
button: 0,
clientX: 180,
clientY: 140,
});
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
await settleFocusChain();
// 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await settleFocusChain();
const dependencyWritesBefore = dependencyWrites(tauri).length;
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
await settleFocusChain();
// 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。
expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore);
});
});