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
- 解决 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 触发沙箱预检失败,与本合并无关)
1990 lines
62 KiB
TypeScript
1990 lines
62 KiB
TypeScript
/** @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('hook:rederiveNow 按 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);
|
||
});
|
||
});
|