a4a030dfb5
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m35s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m10s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m29s
Project CI / AI game creator shell Rust crates (push) Successful in 2m9s
Project CI / Frontend tests (push) Successful in 4m5s
Project CI / Repository checks (push) Successful in 3m20s
Project CI / Native shell tests (push) Successful in 6m24s
Project CI / Backend tests (push) Successful in 7m40s
Project CI / AI game creator shell web tests (push) Successful in 3m18s
首页只保留做游戏和做方案入口 移除项目工作台顶部生成素材按钮 同步相关测试与产品文档
911 lines
28 KiB
TypeScript
911 lines
28 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}
|
||
supervisor={<div>Supervisor</div>}
|
||
onHomeOpen={() => undefined}
|
||
onProjectsOpen={() => undefined}
|
||
onManifestChange={(_path, nextManifest) =>
|
||
setManifests((current) => {
|
||
const projectId = nextManifest.projectId;
|
||
return { ...current, [projectId]: nextManifest };
|
||
})
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
delete window.__TAURI__;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
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('「整理画布」按 rederive 重算自动坐标、保留手动坐标,并给出一次可见反馈', 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: 0,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
}),
|
||
expect.objectContaining({
|
||
resourceId: 'asset:asset-art-a',
|
||
section: 'character',
|
||
x: 600,
|
||
y: 40,
|
||
manuallyPlaced: true,
|
||
}),
|
||
]),
|
||
);
|
||
expect(await screen.findByText('布局已保存')).not.toBeNull();
|
||
|
||
// 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图
|
||
// 不得持久化自动布局」用例依赖同一条 `changed` 门)。
|
||
fireEvent.click(screen.getByRole('button', { name: '整理画布' }));
|
||
await settleFocusChain();
|
||
expect(typeWrites(tauri)).toHaveLength(1);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|