Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts
T
suzmii e15456ea14 读时丢弃的坐标不再静默:提示条带条数并在 DOM 上分开三类计数
- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。

- 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。

- 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。

- 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。

- 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。

- 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
2026-09-11 19:18:12 +08:00

1417 lines
46 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
type ResourceCanvasItem,
} from '../src/view/project-development/resourceCanvasLayoutModel';
import {
createResourceSignature,
createResourceTopologySignature,
describeProjectResourceCanvasLayoutRead,
inspectProjectResourceCanvasLayoutRead,
useProjectResourceCanvasLayout,
} from '../src/view/project-development/useProjectResourceCanvasLayout';
const projectId = 'layout-hook-project';
const projectPath = '/tmp/layout-hook-project';
const dependencySlotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
function resource(id: string): ResourceCanvasItem {
return {
id,
category: 'document',
subtype: 'agent-result',
label: id,
mediaType: 'text/markdown',
dependencyDepth: 0,
};
}
function persistedLayout(
mode: ProjectResourceCanvasLayoutMode,
revision: number,
positions: ProjectResourceCanvasPosition[],
): ProjectResourceCanvasLayout {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision,
positions,
updatedAt: revision * 100,
};
}
function position(
resourceId: string,
x: number,
y: number,
): ProjectResourceCanvasPosition {
return {
resourceId,
section: 'document',
x,
y,
manuallyPlaced: true,
};
}
function automaticPosition(
resourceId: string,
x: number,
y: number,
): ProjectResourceCanvasPosition {
return {
...position(resourceId, x, y),
manuallyPlaced: false,
};
}
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
vi.restoreAllMocks();
});
describe('useProjectResourceCanvasLayout', () => {
it('includes subtype changes in the resource coordination signature', () => {
const original = resource('resource-a');
expect(createResourceSignature([original])).not.toBe(
createResourceSignature([{ ...original, subtype: 'task-artifact' }]),
);
});
it('uses a stable identity-only topology signature', () => {
const topology = {
referenceEdges: [
{ sourceResourceId: 'asset-b', targetResourceId: 'asset-c' },
{ sourceResourceId: 'asset-a', targetResourceId: 'asset-b' },
],
taskFlows: [
{
sourceResourceIds: ['asset-a', 'asset-b'],
targetResourceIds: ['asset-c'],
},
],
};
expect(createResourceTopologySignature(topology)).toBe(
createResourceTopologySignature({
referenceEdges: [...topology.referenceEdges].reverse(),
taskFlows: [
{
sourceResourceIds: ['asset-b', 'asset-a', 'asset-a'],
targetResourceIds: ['asset-c'],
},
],
}),
);
});
it('keeps large topology signatures bounded without using resource labels', () => {
const signature = createResourceTopologySignature({
referenceEdges: Array.from({ length: 4095 }, (_, index) => ({
sourceResourceId: `resource-${index}`,
targetResourceId: `resource-${index + 1}`,
})),
taskFlows: [],
});
expect(signature).toMatch(/^[0-9a-f]{8}:[0-9a-f]{8}:[0-9a-f]{8}$/u);
expect(signature).toHaveLength(26);
});
it('waits for dependency graph initialization before reading or writing layout', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 0, []);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('dependency', 1, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const shallowResource = resource('resource-a');
const deepResource = { ...shallowResource, dependencyDepth: 2 };
const { result, rerender } = renderHook(
({ initializationReady, resources }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
initializationReady,
rederiveAutomaticPositions: true,
}),
{
initialProps: {
initializationReady: false,
resources: [shallowResource],
},
},
);
expect(invoke).not.toHaveBeenCalled();
expect(result.current.layout.positions).toEqual([]);
rerender({ initializationReady: true, resources: [deepResource] });
await waitFor(() => expect(updates).toHaveLength(1));
expect(result.current.layout.positions[0]).toMatchObject({
resourceId: 'resource-a',
x: dependencySlotWidth * 2,
manuallyPlaced: false,
});
expect(invoke.mock.calls[0]?.[0]).toBe(
'read_local_project_resource_canvas_layout',
);
});
it('can render an unpersisted fallback after graph failure without becoming settled', () => {
const invoke = vi.fn();
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resource('resource-a')],
initializationReady: false,
renderFallbackWhileBlocked: true,
}),
);
expect(result.current.layout.positions).toEqual([
expect.objectContaining({
resourceId: 'resource-a',
section: 'document',
manuallyPlaced: false,
}),
]);
expect(result.current.ready).toBe(false);
expect(result.current.settled).toBe(false);
expect(invoke).not.toHaveBeenCalled();
});
it('persists distinct automatic columns for dependency depths 0, 1, and 2', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 0, []);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('dependency', 1, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [
resource('resource-depth-0'),
{ ...resource('resource-depth-1'), dependencyDepth: 1 },
{ ...resource('resource-depth-2'), dependencyDepth: 2 },
],
rederiveAutomaticPositions: true,
}),
);
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-depth-0',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-depth-1',
x: dependencySlotWidth,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-depth-2',
x: dependencySlotWidth * 2,
y: 0,
manuallyPlaced: false,
}),
]),
);
});
it('rederives automatic dependency positions while preserving manual positions', async () => {
const resourceA = { ...resource('resource-a'), dependencyDepth: 2 };
const resourceB = resource('resource-b');
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 4, [
automaticPosition('resource-a', 0, 0),
position('resource-b', 600, 40),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('dependency', 5, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA, resourceB],
rederiveAutomaticPositions: true,
}),
);
await waitFor(() => expect(updates).toHaveLength(1));
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-a',
x: dependencySlotWidth * 2,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-b',
x: 600,
y: 40,
manuallyPlaced: true,
}),
]),
);
});
it('rederives automatic positions when topology changes without a depth change', async () => {
const resources = [
resource('resource-a'),
{ ...resource('resource-b'), dependencyDepth: 1 },
{ ...resource('resource-c'), dependencyDepth: 1 },
];
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 4, [
automaticPosition('resource-a', 0, 0),
automaticPosition('resource-b', 196, 128),
automaticPosition('resource-c', 196, 0),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated' as const,
layout: persistedLayout(
'dependency',
updates.length + 4,
positions,
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { rerender } = renderHook(
({ topology }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
topology,
rederiveAutomaticPositions: true,
}),
{
initialProps: {
topology: {
referenceEdges: [
{
sourceResourceId: 'resource-a',
targetResourceId: 'resource-b',
},
],
taskFlows: [],
},
},
},
);
await waitFor(() => expect(updates).toHaveLength(1));
rerender({
topology: {
referenceEdges: [
{ sourceResourceId: 'resource-a', targetResourceId: 'resource-c' },
],
taskFlows: [],
},
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(
updates[1]?.find(({ resourceId }) => resourceId === 'resource-c'),
).toMatchObject({ x: dependencySlotWidth, y: 0, manuallyPlaced: false });
});
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
[position('resource-a', 10, 20)],
);
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() =>
expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'),
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(0);
});
it('rejects an unsafe revision from an update response and restores the trusted layout', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() =>
expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'),
);
expect(result.current.layout.positions[0]).toMatchObject({ x: 10, y: 20 });
expect(result.current.saving).toBe(false);
});
it('keeps the initial layout read and coordinates the latest resources in the same scope', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const resolveReads: Array<(layout: ProjectResourceCanvasLayout) => void> =
[];
const updates: Array<{
expectedProjectId: string;
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return await new Promise<ProjectResourceCanvasLayout>((resolve) => {
resolveReads.push(resolve);
});
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedProjectId: string;
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ resources }: { resources: ResourceCanvasItem[] }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
}),
{ initialProps: { resources: [resourceA] } },
);
await waitFor(() => expect(resolveReads).toHaveLength(1));
rerender({ resources: [resourceA, resourceB] });
await act(async () => {
resolveReads[0]?.(
persistedLayout('dependency', 1, [position('resource-a', 10, 20)]),
);
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(1));
expect(
result.current.layout.positions.map(({ resourceId }) => resourceId),
).toEqual(expect.arrayContaining(['resource-a', 'resource-b']));
expect(updates[0]?.expectedRevision).toBe(1);
expect(updates[0]?.expectedProjectId).toBe(projectId);
expect(
updates[0]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_resource_canvas_layout',
),
).toHaveLength(1);
});
it('keeps an in-flight manual CAS ahead of resource coordination in the same scope', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
let resolveFirstUpdate:
| ((result: {
status: 'updated' | 'conflict';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
if (updates.length === 1) {
return await new Promise((resolve) => {
resolveFirstUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ resources }: { resources: ResourceCanvasItem[] }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
}),
{ initialProps: { resources: [resourceA] } },
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() => expect(updates).toHaveLength(1));
rerender({ resources: [resourceA, resourceB] });
await waitFor(() =>
expect(
result.current.layout.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true),
);
expect(updates).toHaveLength(1);
await act(async () => {
resolveFirstUpdate?.({
status: 'updated',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[0]!.positions),
),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 2,
]);
expect(
updates[1]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }),
expect.objectContaining({ resourceId: 'resource-b' }),
]),
);
});
it('coalesces repeated queued manual placements for the same resource behind an in-flight CAS', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
let resolveFirstUpdate:
| ((result: {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
if (updates.length === 1) {
return await new Promise((resolve) => {
resolveFirstUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() => expect(updates).toHaveLength(1));
act(() => {
for (let x = 200; x < 300; x += 1) {
result.current.commitPosition('resource-a', 'document', x, 40);
}
});
expect(updates).toHaveLength(1);
expect(result.current.layout.positions[0]?.x).toBe(299);
await act(async () => {
resolveFirstUpdate?.({
status: 'updated',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[0]!.positions),
),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 2,
]);
expect(updates[1]?.positions[0]?.x).toBe(299);
await waitFor(() => expect(result.current.saving).toBe(false));
});
it('drops manual intents queued behind a CAS conflict', async () => {
const resourceA = resource('resource-a');
let updateCalls = 0;
let resolveUpdate:
| ((result: {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
updateCalls += 1;
return await new Promise((resolve) => {
resolveUpdate = resolve;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => {
result.current.commitPosition('resource-a', 'document', 100, 30);
result.current.commitPosition('resource-a', 'document', 220, 40);
});
await waitFor(() => expect(updateCalls).toBe(1));
await act(async () => {
resolveUpdate?.({
status: 'conflict',
layout: persistedLayout('dependency', 2, [
position('resource-a', 400, 80),
]),
});
await Promise.resolve();
});
await waitFor(() => {
expect(result.current.layout.positions[0]).toMatchObject({
x: 400,
y: 80,
});
expect(result.current.saving).toBe(false);
});
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(updateCalls).toBe(1);
});
it('keeps the manual conflict notice when resource reconciliation drops a queued manual intent and retries', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
let resolveResourceSync:
| ((result: {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
if (updates.length === 1) {
return await new Promise((resolve) => {
resolveResourceSync = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA, resourceB],
}),
);
await waitFor(() => expect(updates).toHaveLength(1));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }),
]),
);
expect(updates).toHaveLength(1);
await act(async () => {
resolveResourceSync?.({
status: 'conflict',
layout: persistedLayout('dependency', 2, [
position('resource-a', 400, 80),
]),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
await waitFor(() => expect(result.current.saving).toBe(false));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 2,
]);
expect(updates[1]?.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }),
expect.objectContaining({ resourceId: 'resource-b' }),
]),
);
expect(updates[1]?.positions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }),
]),
);
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }),
]),
);
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe(
false,
);
act(() => result.current.commitPosition('resource-a', 'document', 500, 90));
await waitFor(() => expect(updates).toHaveLength(3));
await waitFor(() => expect(result.current.notice).toBe('布局已保存'));
expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe(
true,
);
expect(updates[2]?.expectedRevision).toBe(3);
expect(updates[2]?.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 500, y: 90 }),
]),
);
});
it('ignores a late read from the previous mode', async () => {
const resourceA = resource('resource-a');
const readResolvers = new Map<
ProjectResourceCanvasLayoutMode,
(layout: ProjectResourceCanvasLayout) => void
>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'read_local_project_resource_canvas_layout') {
throw new Error(`unexpected invoke ${command}`);
}
const mode = args?.mode as ProjectResourceCanvasLayoutMode;
return await new Promise<ProjectResourceCanvasLayout>((resolve) => {
readResolvers.set(mode, resolve);
});
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ mode }: { mode: ProjectResourceCanvasLayoutMode }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources: [resourceA],
}),
{ initialProps: { mode: 'dependency' as const } },
);
await waitFor(() => expect(readResolvers.has('dependency')).toBe(true));
rerender({ mode: 'type' });
await waitFor(() => expect(readResolvers.has('type')).toBe(true));
await act(async () => {
readResolvers.get('type')?.(
persistedLayout('type', 5, [position('resource-a', 200, 50)]),
);
await Promise.resolve();
});
await waitFor(() => expect(result.current.layout.mode).toBe('type'));
await act(async () => {
readResolvers.get('dependency')?.(
persistedLayout('dependency', 4, [position('resource-a', 10, 20)]),
);
await Promise.resolve();
});
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]).toMatchObject({ x: 200, y: 50 });
});
it('does not let a stuck old-scope write block the new scope queue', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
mode: ProjectResourceCanvasLayoutMode;
expectedRevision: number;
}> = [];
let resolveDependencyUpdate:
| ((result: {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const mode = args?.mode as ProjectResourceCanvasLayoutMode;
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout(mode, mode === 'dependency' ? 1 : 5, [
position('resource-a', mode === 'dependency' ? 10 : 200, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const expectedRevision = args?.expectedRevision as number;
updates.push({ mode, expectedRevision });
if (mode === 'dependency') {
return await new Promise((resolve) => {
resolveDependencyUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(mode, expectedRevision + 1, [
position('resource-a', 300, 40),
]),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ mode }: { mode: ProjectResourceCanvasLayoutMode }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources: [resourceA],
}),
{ initialProps: { mode: 'dependency' as const } },
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() => expect(updates).toHaveLength(1));
rerender({ mode: 'type' });
await waitFor(() => expect(result.current.layout.mode).toBe('type'));
act(() => result.current.commitPosition('resource-a', 'document', 300, 40));
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 });
await waitFor(() => expect(result.current.saving).toBe(false));
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]?.x).toBe(300);
await act(async () => {
resolveDependencyUpdate?.({
status: 'updated',
layout: persistedLayout('dependency', 2, [
position('resource-a', 100, 30),
]),
});
await Promise.resolve();
});
expect(updates).toHaveLength(2);
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]?.x).toBe(300);
});
it('bounds automatic resource reconciliation retries under repeated CAS conflicts', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const expectedRevisions: number[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const expectedRevision = args?.expectedRevision as number;
expectedRevisions.push(expectedRevision);
return {
status: 'conflict',
layout: persistedLayout('dependency', expectedRevision + 1, [
position('resource-a', 10, 20),
]),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA, resourceB],
}),
);
await waitFor(() => expect(expectedRevisions).toHaveLength(3));
await waitFor(() => expect(result.current.saving).toBe(false));
expect(expectedRevisions).toEqual([1, 2, 3]);
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(
result.current.layout.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
});
it('leaves other automatic cards in place when one card is dragged', async () => {
// 三张同深度自动卡按当前默认口径(层内 2 列网格)落盘:a(0,0) b(228,0) c(0,168)。
const resources = [
resource('resource-a'),
resource('resource-b'),
resource('resource-c'),
];
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 3, [
automaticPosition('resource-a', 0, 0),
automaticPosition('resource-b', 228, 0),
automaticPosition('resource-c', 0, 168),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
rederiveAutomaticPositions: true,
}),
);
await waitFor(() =>
expect(result.current.layout.positions).toHaveLength(3),
);
act(() =>
result.current.commitPosition('resource-a', 'document', 900, 640),
);
await waitFor(() =>
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-a',
),
).toMatchObject({ x: 900, y: 640, manuallyPlaced: true }),
);
// 拖动只改被拖的那张:其余自动卡在所有写入里坐标逐项不变,也不会补位到空出的格子。
expect(updates.length).toBeGreaterThan(0);
for (const write of updates) {
const written = new Map(
write.positions.map((candidate) => [candidate.resourceId, candidate]),
);
expect(written.size).toBe(3);
expect(written.get('resource-a')).toMatchObject({
x: 900,
y: 640,
manuallyPlaced: true,
});
expect(written.get('resource-b')).toMatchObject({
x: 228,
y: 0,
manuallyPlaced: false,
});
expect(written.get('resource-c')).toMatchObject({
x: 0,
y: 168,
manuallyPlaced: false,
});
}
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-b',
),
).toMatchObject({ x: 228, y: 0 });
expect(
result.current.layout.positions.find(
({ resourceId }) => resourceId === 'resource-c',
),
).toMatchObject({ x: 0, y: 168 });
});
it('reports read-time section realignment and skipped coordinates for a stored sidecar', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('type', 7, [
// 旧 `art` 分区 + 资源当前分类 `character`:读时归并,坐标不动。
{
resourceId: 'asset:legacy-art',
section: 'art',
x: 10,
y: 20,
manuallyPlaced: true,
},
// 资源还在,但持久化分区与资源分类不匹配:坐标被跳过。
{
resourceId: 'asset:mismatched-audio',
section: 'document',
x: 30,
y: 40,
manuallyPlaced: true,
},
// 资源已不在 manifest:坐标被跳过。
{
resourceId: 'asset:ghost',
section: 'art',
x: 50,
y: 60,
manuallyPlaced: true,
},
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated' as const,
layout: persistedLayout('type', 8, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [
{ ...resource('asset:legacy-art'), category: 'character' },
{ ...resource('asset:mismatched-audio'), category: 'audio' },
],
}),
);
await waitFor(() => expect(result.current.readReport).not.toBeNull());
const report = result.current.readReport;
expect(report).toMatchObject({
projectScopeKey: JSON.stringify([projectPath, projectId]),
mode: 'type',
normalizedSections: 1,
droppedMissingResource: 1,
droppedSectionMismatch: 1,
dropped: 2,
});
expect(describeProjectResourceCanvasLayoutRead(report!)).toBe(
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)',
);
// 归并与丢弃的结果确实被那一次写回固化:写盘内容里旧 `art` 与不匹配分区都不在了。
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]?.map(({ section }) => section)).toEqual([
'character',
'audio',
]);
});
it('keeps the read report empty when the stored sidecar already matches the current sections', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('type', 2, [
position('asset:aligned', 12, 34),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
updates.push(
structuredClone(args?.positions as ProjectResourceCanvasPosition[]),
);
return {
status: 'updated' as const,
layout: persistedLayout('type', 3, []),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [resource('asset:aligned')],
}),
);
await waitFor(() => expect(result.current.settled).toBe(true));
// 没有归并、没有丢弃:既不报统计,也不因为读盘多写一次 sidecar。
expect(result.current.readReport).toBeNull();
expect(updates).toHaveLength(0);
});
it('does not report a read that only places newly added resources', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('type', 5, [position('resource-a', 0, 0)]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated' as const,
layout: persistedLayout('type', 6, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [resource('resource-a'), resource('resource-b')],
}),
);
await waitFor(() => expect(updates).toHaveLength(1));
// 补新资源落位同样会写回一次,但它不是「读时归并旧分区」,不能报读时统计。
expect(result.current.readReport).toBeNull();
});
});
describe('resource canvas layout read report', () => {
const emptyCounts = {
normalizedSections: 0,
droppedMissingResource: 0,
droppedSectionMismatch: 0,
dropped: 0,
};
function layoutWith(
positions: ProjectResourceCanvasPosition[],
): ProjectResourceCanvasLayout {
return persistedLayout('type', 1, positions);
}
it('separates realigned sections from both drop reasons', () => {
const counts = inspectProjectResourceCanvasLayoutRead(
layoutWith([
{ resourceId: 'a', section: 'art', x: 0, y: 0, manuallyPlaced: true },
{
resourceId: 'b',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: true,
},
{
resourceId: 'ghost',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: true,
},
]),
[
{ ...resource('a'), category: 'scene' },
{ ...resource('b'), category: 'audio' },
],
);
expect(counts).toEqual({
normalizedSections: 1,
droppedMissingResource: 1,
droppedSectionMismatch: 1,
dropped: 2,
});
});
it('describes each read-report shape', () => {
expect(describeProjectResourceCanvasLayoutRead(emptyCounts)).toBe('');
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
normalizedSections: 112,
}),
).toBe('已把 112 条旧分区坐标对齐到新分区并写回,坐标位置未变');
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
normalizedSections: 112,
droppedMissingResource: 66,
droppedSectionMismatch: 16,
dropped: 82,
}),
).toBe(
'已把 112 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 82 条坐标无法对齐已跳过(66 条资源已不在项目中,16 条分区与资源分类不匹配)',
);
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
droppedMissingResource: 3,
dropped: 3,
}),
).toBe('打开项目时有 3 条坐标无法对齐已跳过(3 条资源已不在项目中)');
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
droppedSectionMismatch: 2,
dropped: 2,
}),
).toBe('打开项目时有 2 条坐标无法对齐已跳过(2 条分区与资源分类不匹配)');
});
});