c162e1de44
增加 expectedProjectId 身份栅栏并在写入关键阶段复核项目身份 在 revision 耗尽时失败关闭并保持乐观锁 CAS 契约 折叠同资源连续拖动并解除旧 scope 请求对新项目的阻塞 补齐多窗口并发、异步迟到响应和极端边界回归测试 修复目标分支格式、前端门禁及负载敏感测试时序 同步更新资源画布契约、决策记录和竞态风险说明
543 lines
18 KiB
TypeScript
543 lines
18 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 type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
|
|
import { useProjectResourceCanvasLayout } from '../src/view/project-development/useProjectResourceCanvasLayout';
|
|
|
|
const projectId = 'layout-hook-project';
|
|
const projectPath = '/tmp/layout-hook-project';
|
|
|
|
function resource(id: string): ResourceCanvasItem {
|
|
return {
|
|
id,
|
|
category: 'document',
|
|
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,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
window.__TAURI__ = undefined;
|
|
});
|
|
|
|
describe('useProjectResourceCanvasLayout', () => {
|
|
it('uses the latest resource snapshot when the initial scope read resolves', async () => {
|
|
const resourceA = resource('resource-a');
|
|
const resourceB = resource('resource-b');
|
|
let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null =
|
|
null;
|
|
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) => {
|
|
resolveRead = 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(resolveRead).not.toBeNull());
|
|
|
|
rerender({ resources: [resourceA, resourceB] });
|
|
await act(async () => {
|
|
resolveRead?.(
|
|
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 alive and serializes resource sync behind its revision', async () => {
|
|
const resourceA = resource('resource-a');
|
|
const resourceB = resource('resource-b');
|
|
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, 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);
|
|
});
|
|
|
|
it('coalesces repeated queued drags 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('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);
|
|
});
|
|
});
|