Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
Git Hooks Test 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
移除首页与工作台素材生成入口
首页只保留做游戏和做方案入口

移除项目工作台顶部生成素材按钮

同步相关测试与产品文档
2026-09-16 02:40:51 +08:00

911 lines
28 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}
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('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('「整理画布」按 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);
});
});