Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts
T
suzmii 30648e6b93 画布修复:重算意图各占队列槽、撤销不跨排序模式、整理判据与落盘同源
- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理
- 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名
- 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去
- 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化
- 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY
- 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
2026-09-17 18:18:32 +08:00

2184 lines
72 KiB
TypeScript
Raw 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, 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: 2,
droppedMissingResource: 1,
droppedSectionMismatch: 0,
dropped: 1,
});
expect(describeProjectResourceCanvasLayoutRead(report!)).toBe(
'已把 2 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 1 条坐标无法对齐已跳过(1 条资源已不在项目中)',
);
// 归并与丢弃的结果确实被那一次写回固化:写盘内容里旧 `art` 与不匹配分区都不在了。
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]?.map(({ section }) => section)).toEqual([
'character',
'audio',
]);
// 分区不匹配那条**必须连坐标一起归并**:旧行为把它丢掉、由自动排布重算 x / y,
// 用户手摆的位置就没了。
expect(
updates[0]?.find(
({ resourceId }) => resourceId === 'asset:mismatched-audio',
),
).toMatchObject({ x: 30, y: 40, manuallyPlaced: true });
expect(
updates[0]?.find(({ resourceId }) => resourceId === 'asset:legacy-art'),
).toMatchObject({ x: 10, y: 20, manuallyPlaced: true });
});
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 the drop reason that remains', () => {
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' },
],
);
// 'b' 的现行分区与资源当前分类不一致,但**不是**"无从归并":它归并到 audio 并保留坐标,
// 因此计入 normalizedSections。剩下的丢弃理由只有"资源已不在项目中"。
expect(counts).toEqual({
normalizedSections: 2,
droppedMissingResource: 1,
droppedSectionMismatch: 0,
dropped: 1,
});
});
it('counts only values that truly cannot be merged as section mismatches', () => {
// 旧 `code` 的归并目标集合是「待归类」,资源却已归为 `document` ⇒ 确实无从归并;
// 无法识别的取值同理。这两类是 `droppedSectionMismatch` 现在唯一的来源。
const counts = inspectProjectResourceCanvasLayoutRead(
layoutWith([
{ resourceId: 'a', section: 'code', x: 1, y: 2, manuallyPlaced: true },
{ resourceId: 'b', section: 'nope', x: 3, y: 4, manuallyPlaced: true },
]),
[
{ ...resource('a'), category: 'document' },
{ ...resource('b'), category: 'scene' },
],
);
expect(counts).toEqual({
normalizedSections: 0,
droppedMissingResource: 0,
droppedSectionMismatch: 2,
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 条分区与资源分类不匹配)');
});
});
describe('布局落盘 / 读盘失败的原因不再被吞掉', () => {
function warnMessages(warn: ReturnType<typeof vi.spyOn>) {
return warn.mock.calls.map((call) => String(call[0]));
}
it('keeps the update failure reason in a sanitized log line', async () => {
// 写回失败此前是 `.catch(() => {})`:没有 message 参数,原因被整个吞掉。
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
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('布局保存失败,已恢复上次布局'),
);
// 用户可见文案保持原样(不把内部原因塞进面板),原因落到日志里可查。
const logged = warnMessages(warn).filter((message) =>
message.includes('[resource-canvas-layout] 布局保存失败'),
);
expect(logged).toHaveLength(1);
expect(logged[0]).toContain(
'layout response revision or coordinates are invalid',
);
});
it('sanitizes an IPC failure reason before logging it', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
// 原始错误里带本机绝对路径与 token:日志可以留原因,但不能把这两样带出去。
const rawPath = 'C:\\Users\\someone\\workspace\\secret-project';
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
// 带换行是为了证明日志被压成一行,不会把多行堆栈糊进去。
throw new Error(
`读取布局失败:\n${rawPath}\\.agent\\layout.json token=abcdef123456`,
);
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resource('resource-a')],
}),
);
await waitFor(() =>
expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'),
);
const logged = warnMessages(warn).filter((message) =>
message.includes('[resource-canvas-layout] 布局读取失败'),
);
expect(logged).toHaveLength(1);
expect(logged[0]).toContain('读取布局失败');
expect(logged[0]).not.toContain('someone');
expect(logged[0]).not.toContain('abcdef123456');
expect(logged[0]).toContain('<path>');
// 压成一行:不许把多行堆栈糊进日志。
expect(logged[0]).not.toContain('\n');
});
});
/**
* 「整理画布」与批量坐标写入的 hook 级口径。
*
* 整理只作用于目标栏目:栏内**全部**素材(含手动摆放过的卡)一起重算成自动坐标,其他栏目
* 逐值不动;整次整理仍是一笔 CAS。手动写入支持一次多张卡(含手动标记),因此多选拖动与
* 撤销恢复都不会退化成"每卡一笔"。
*/
describe('资源画布整理与批量坐标写入', () => {
function characterResource(id: string): ResourceCanvasItem {
return { ...resource(id), category: 'character' };
}
function typeLayoutHarness(initialPositions: ProjectResourceCanvasPosition[]) {
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, structuredClone(initialPositions));
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('type', 8, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
return { invoke, updates };
}
/**
* 等读盘之后的协调写回彻底落地再清空记账:打开项目那次"归并 / 补位"写回与本次要验的
* 整理、多选写入是两回事,混在一起数笔数会把既有口径算成本次行为。
*/
async function settleInitialWrites(
result: { current: { settled: boolean } },
updates: ProjectResourceCanvasPosition[][],
) {
await waitFor(() => expect(result.current.settled).toBe(true));
updates.length = 0;
}
/**
* 可挂起的 type 侧 harness`holdNextWrite()` 之后的那一笔 update 会停在途上,直到
* `releaseHeldWrite()` 放行。用来复现"上一笔还没落盘、用户又按了整理"的真实窗口。
*/
function gatedTypeLayoutHarness(
initialPositions: ProjectResourceCanvasPosition[],
) {
const updates: ProjectResourceCanvasPosition[][] = [];
let heldWrite: (() => void) | null = null;
let holdNext = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('type', 7, structuredClone(initialPositions));
}
if (command === 'update_local_project_resource_canvas_layout') {
if (holdNext) {
holdNext = false;
await new Promise<void>((resolve) => {
heldWrite = resolve;
});
}
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('type', 8 + updates.length, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
return {
invoke,
updates,
holdNextWrite: () => {
holdNext = true;
},
releaseHeldWrite: () => {
heldWrite?.();
},
};
}
it('整理栏目时连手动坐标一起重算,其他栏目逐值不动', async () => {
const documentA = resource('resource-doc-a');
const documentB = { ...resource('resource-doc-b'), dependencyDepth: 1 };
const characterA = characterResource('resource-char-a');
const { updates } = typeLayoutHarness([
position('resource-doc-a', 600, 40),
automaticPosition('resource-doc-b', 900, 900),
{ ...position('resource-char-a', 777, 55), section: 'character' },
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, documentB, characterA],
}),
);
await settleInitialWrites(result, updates);
act(() => result.current.organizeNow(['document']));
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-doc-b',
section: 'document',
x: 196,
y: 0,
manuallyPlaced: false,
}),
// 其他栏目:手动坐标与手动标记原样保留。
expect.objectContaining({
resourceId: 'resource-char-a',
section: 'character',
x: 777,
y: 55,
manuallyPlaced: true,
}),
]),
);
});
it('整理不重排其他栏目的自动坐标:栏外坐标逐值原样保留', async () => {
const documentA = resource('resource-doc-a');
/**
* 其他栏目的自动卡故意放在「非规范槽位」上:这类坐标在真实项目里来自更早版本的排布、
* 或素材被删后留下的既有位置。整理当前栏目只允许丢**目标栏目**的坐标,因此这些卡
* 必须逐值保留,而不是被丢进重算后按空栏目规整回 (0,0)。
*/
const characterA = characterResource('resource-char-auto-a');
const characterB = characterResource('resource-char-auto-b');
const { updates } = typeLayoutHarness([
position('resource-doc-a', 600, 40),
{ ...automaticPosition('resource-char-auto-a', 900, 900), section: 'character' },
{ ...automaticPosition('resource-char-auto-b', 1100, 940), section: 'character' },
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, characterA, characterB],
}),
);
await settleInitialWrites(result, updates);
act(() => result.current.organizeNow(['document']));
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-char-auto-a',
section: 'character',
x: 900,
y: 900,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-char-auto-b',
section: 'character',
x: 1100,
y: 940,
manuallyPlaced: false,
}),
]),
);
});
it('「所有资源」范围(null)整理全部栏目,不留下手动标记', async () => {
const documentA = resource('resource-doc-a');
const characterA = characterResource('resource-char-a');
const { updates } = typeLayoutHarness([
position('resource-doc-a', 600, 40),
{ ...automaticPosition('resource-char-a', 900, 900), section: 'character' },
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, characterA],
}),
);
await settleInitialWrites(result, updates);
act(() => result.current.organizeNow(null));
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-char-a',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
}),
]),
);
});
/**
* 排队中的「整理画布」不能被「关系图首次就绪」那一次重算覆盖。
*
* 两种重算共用同一条写队列:显式整理是用户动作、首次就绪是系统动作,各自占一个队列槽。
* 系统请求若就地改写用户已经按下、还没落盘的那一笔,用户会看到整理生效、落盘却把手动卡
* 留在原地(整理被悄悄降级成"只丢自动坐标")。
*/
it('排队中的整理不会被「关系图首次就绪」覆盖:两者各占一个队列槽', async () => {
const documentA = resource('resource-doc-a');
const documentB = resource('resource-doc-b');
const { updates, holdNextWrite, releaseHeldWrite } =
gatedTypeLayoutHarness([
position('resource-doc-a', 600, 40),
automaticPosition('resource-doc-b', 900, 900),
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, documentB],
}),
);
await waitFor(() => expect(result.current.settled).toBe(true));
updates.length = 0;
// 让一笔手动写入停在途上:后面的整理只能排队——正是「整理已按下、还没落盘」的窗口。
holdNextWrite();
act(() => {
result.current.commitPosition('resource-doc-a', 'document', 111, 222);
});
act(() => {
expect(result.current.organizeNow(['document'])).toBe(true);
});
// 关系图首次就绪的重算在这个窗口里进来。
act(() => {
result.current.rederiveNow();
});
await act(async () => {
releaseHeldWrite();
await Promise.resolve();
});
await waitFor(() => expect(updates.length).toBeGreaterThan(1));
// 整理那一笔照原样落盘:栏内两张卡都重算成自动坐标,手动坐标不残留。
expect(updates[1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-doc-b',
x: 196,
y: 0,
manuallyPlaced: false,
}),
]),
);
// 首次就绪那一笔退化成空操作:整理后已经是自动坐标,不再多写一次 CAS。
expect(updates).toHaveLength(2);
});
/**
* 「刚拖完、落点还没写回」时按整理:整理必须照常排进队列并最终生效。
*
* 判据不能拿乐观视图(已经把那一笔排队中的手动落点叠上去了)去比:整理结果与它逐值相同
* 就会判成"什么都没变",用户的点击被静默吞掉,随后落盘的手动结果(卡在原地)反客为主。
* 与落盘那一步同源地用"整理结果 vs 已落盘布局"判定,两处才不会再打架。
*/
it('拖动落点还在途时按整理:整理照常排队,最终把这张卡重排回自动槽位', async () => {
const documentA = resource('resource-doc-a');
const documentB = resource('resource-doc-b');
const { updates, holdNextWrite, releaseHeldWrite } =
gatedTypeLayoutHarness([
automaticPosition('resource-doc-a', 0, 0),
automaticPosition('resource-doc-b', 196, 0),
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, documentB],
}),
);
await waitFor(() => expect(result.current.settled).toBe(true));
updates.length = 0;
// 拖动落点已经在画面上(乐观视图),但这一笔还没写回。
holdNextWrite();
act(() => {
result.current.commitPosition('resource-doc-a', 'document', 500, 600);
});
// 用户紧接着按整理:这一按必须真的排进队列。
let organized = false;
act(() => {
organized = result.current.organizeNow(['document']);
});
expect(organized).toBe(true);
await act(async () => {
releaseHeldWrite();
await Promise.resolve();
});
await waitFor(() => expect(updates.length).toBeGreaterThan(1));
// 整理排在手动落点之后落盘:这张卡被重排回自动槽位,手动落点不留在最终布局里。
expect(updates.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
x: 0,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-doc-b',
x: 196,
y: 0,
manuallyPlaced: false,
}),
]),
);
});
/**
* 分类刚变更、同步写还没落盘时拖动这张卡:落点必须按**新栏目**写回,不能被静默跳过。
*
* 顺序上的依据:资源签名变化那一次 effect 先 `reconcileLayout(layoutRef.current, resources)`
* 再 `applyLayout`,而 `resources` 已经是新分类——所以拖动开始时内存布局里这条坐标的
* `section` 已经是新栏目,`resourceId + section` 的匹配不会落空。这条用例把这个顺序钉住:
* 谁把顺序改回去(例如先写后 reconcile),这里就会先红。
*/
it('分类刚变更、同步写还没落盘时拖动:落点按新栏目写回,不被静默跳过', async () => {
const documentResource = resource('resource-shift');
const sceneResource: ResourceCanvasItem = {
...resource('resource-shift'),
category: 'scene',
};
const { updates, holdNextWrite, releaseHeldWrite } =
gatedTypeLayoutHarness([
{ ...automaticPosition('resource-shift', 100, 100), section: 'document' },
]);
const { result, rerender } = renderHook(
(props: {
resources: ResourceCanvasItem[];
}) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: props.resources,
}),
{ initialProps: { resources: [documentResource] } },
);
await waitFor(() => expect(result.current.settled).toBe(true));
updates.length = 0;
// 分类变更:挂住随之而来的同步写,模拟"变更已生效、sidecar 还没对齐"的窗口。
holdNextWrite();
act(() => {
rerender({ resources: [sceneResource] });
});
await act(async () => {
await Promise.resolve();
});
// 内存布局先按新分类归并:这条坐标的 section 已经是 scene。
expect(
result.current.layout.positions.find(
(position) => position.resourceId === 'resource-shift',
),
).toMatchObject({ section: 'scene', x: 100, y: 100 });
// 窗口内拖动这张卡:乐观布局必须立刻跟上(匹配落空的话这里会停在 100,100)。
act(() => {
result.current.commitPosition('resource-shift', 'scene', 300, 400);
});
expect(
result.current.layout.positions.find(
(position) => position.resourceId === 'resource-shift',
),
).toMatchObject({
section: 'scene',
x: 300,
y: 400,
manuallyPlaced: true,
});
await act(async () => {
releaseHeldWrite();
await Promise.resolve();
});
// 落盘也是一笔带新栏目与手动标记的坐标:没有"被跳过、还没提示"的静默路径。
await waitFor(() => expect(result.current.settled).toBe(true));
const manualWrites = updates.filter((positions) =>
positions.some(
(position) =>
position.resourceId === 'resource-shift' &&
position.x === 300 &&
position.y === 400 &&
position.manuallyPlaced,
),
);
expect(manualWrites).toHaveLength(1);
expect(
manualWrites[0]!.find(
(position) => position.resourceId === 'resource-shift',
),
).toMatchObject({ section: 'scene', x: 300, y: 400 });
});
it('没有可整理栏目时不产生任何写入', async () => {
const documentA = resource('resource-doc-a');
const { updates } = typeLayoutHarness([position('resource-doc-a', 600, 40)]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA],
}),
);
await settleInitialWrites(result, updates);
await act(async () => {
result.current.organizeNow([]);
await Promise.resolve();
});
expect(updates).toEqual([]);
expect(result.current.saving).toBe(false);
});
it('批量手动写入只排一笔 CAS,并按下发的标记落盘', async () => {
const documentA = resource('resource-doc-a');
const documentB = resource('resource-doc-b');
const { invoke, updates } = typeLayoutHarness([
automaticPosition('resource-doc-a', 10, 20),
automaticPosition('resource-doc-b', 30, 40),
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA, documentB],
}),
);
await settleInitialWrites(result, updates);
await act(async () => {
result.current.commitPositions([
{
resourceId: 'resource-doc-a',
section: 'document',
x: 110,
y: 30,
manuallyPlaced: true,
},
{
resourceId: 'resource-doc-b',
section: 'document',
x: 210,
y: 60,
manuallyPlaced: false,
},
]);
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(1));
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(1);
expect(updates[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-doc-a',
x: 110,
y: 30,
manuallyPlaced: true,
}),
expect.objectContaining({
resourceId: 'resource-doc-b',
x: 210,
y: 60,
manuallyPlaced: false,
}),
]),
);
});
it('只有手动标记变化(撤销整理)时仍然落盘,完全没变化时不写', async () => {
const documentA = resource('resource-doc-a');
const { updates } = typeLayoutHarness([
automaticPosition('resource-doc-a', 10, 20),
]);
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA],
}),
);
await settleInitialWrites(result, updates);
await act(async () => {
result.current.commitPositions([
{
resourceId: 'resource-doc-a',
section: 'document',
x: 10,
y: 20,
manuallyPlaced: false,
},
]);
await Promise.resolve();
});
// 坐标与标记都没变:不产生无意义的一笔。
expect(updates).toEqual([]);
await act(async () => {
result.current.commitPositions([
{
resourceId: 'resource-doc-a',
section: 'document',
x: 10,
y: 20,
manuallyPlaced: true,
},
]);
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(1));
expect(updates[0]?.[0]).toMatchObject({
resourceId: 'resource-doc-a',
x: 10,
y: 20,
manuallyPlaced: true,
});
});
it('整理写入失败时保留失败提示,不伪报保存成功', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const documentA = resource('resource-doc-a');
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('type', 7, [
position('resource-doc-a', 600, 40),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
throw new Error('layout write rejected');
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'type',
resources: [documentA],
}),
);
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => result.current.organizeNow(['document']));
await waitFor(() =>
expect(result.current.notice).toBe('布局保存失败,已保留当前会话布局'),
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(1);
});
});