30648e6b93
- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理 - 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名 - 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去 - 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化 - 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY - 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
2184 lines
72 KiB
TypeScript
2184 lines
72 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: 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);
|
||
});
|
||
});
|