画布修复:重算意图各占队列槽、撤销不跨排序模式、整理判据与落盘同源

- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理
- 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名
- 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去
- 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化
- 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY
- 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
This commit is contained in:
2026-09-17 18:11:06 +08:00
parent 9dd1052374
commit 30648e6b93
6 changed files with 455 additions and 119 deletions
@@ -179,6 +179,7 @@ import {
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
type ResourceCanvasHistory,
type ResourceCanvasLayoutSnapshot,
undoResourceCanvasHistory,
} from '../../features/resource-canvas/resourceCanvasHistoryModel';
@@ -419,6 +420,14 @@ export function resolveResourceCardDragMoves({
return moves.length > 0 ? moves : [moveFor(resource)];
}
/**
*
*
* `canUndo / canRedo`
*
*/
const EMPTY_RESOURCE_CANVAS_HISTORY = createResourceCanvasHistory();
function isPlatformAuthenticationRequired(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
@@ -1367,8 +1376,6 @@ function ResourceBookScene({
width: number;
height: number;
rotation: number;
dragX: number;
dragY: number;
},
) => ReactNode;
/** 渲染在画本世界坐标系里的覆盖层(框选矩形等)。 */
@@ -1678,9 +1685,49 @@ export default function ProjectDevelopmentView({
const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState<CanvasMarqueeState | null>(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
const [resourceCanvasHistory, setResourceCanvasHistory] = useState(
createResourceCanvasHistory,
/**
*
*
* + sidecar
* sidecar
*
*
*/
const resourceCanvasHistoryScopeKey = JSON.stringify([
projectPath,
manifest.projectId,
sortMode,
]);
const [resourceCanvasHistoryState, setResourceCanvasHistoryState] = useState<{
scopeKey: string;
history: ResourceCanvasHistory;
}>(() => ({
scopeKey: resourceCanvasHistoryScopeKey,
history: createResourceCanvasHistory(),
}));
const resourceCanvasHistory =
resourceCanvasHistoryState.scopeKey === resourceCanvasHistoryScopeKey
? resourceCanvasHistoryState.history
: EMPTY_RESOURCE_CANVAS_HISTORY;
const setResourceCanvasHistory = useCallback(
(
update:
| ResourceCanvasHistory
| ((history: ResourceCanvasHistory) => ResourceCanvasHistory),
) => {
setResourceCanvasHistoryState((current) => ({
scopeKey: resourceCanvasHistoryScopeKey,
history:
typeof update === 'function'
? update(
current.scopeKey === resourceCanvasHistoryScopeKey
? current.history
: createResourceCanvasHistory(),
)
: update,
}));
},
[resourceCanvasHistoryScopeKey],
);
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
@@ -6447,8 +6494,6 @@ export default function ProjectDevelopmentView({
width: number;
height: number;
rotation: number;
dragX: number;
dragY: number;
},
) => {
const previewIdentity = resourceCardPreviews.identityByResourceId.get(
@@ -34,8 +34,6 @@ export type ResourceBookCardLayout = {
y: number;
width: number;
height: number;
dragX: number;
dragY: number;
rotation: number;
};
@@ -265,33 +263,6 @@ export function resolveResourceBookAllLayout(
return { key, layout: buildResourceBookAllLayout(input) };
}
/**
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
*
* 展开态按分带铺卡片,卡片画的是"局部坐标 + 带原点";而布局 sidecar 只认栏目内局部坐标
* `resource-layouts/{dependency,type}.json` 的 `x / y``section` 是资源当前分类)。
* 因此拖动提交前减掉带原点:写回的是**这张卡在它自己栏目里的同一个槽位**,栏目页立刻看到
* 同一位置,不存在第二份空间。带不存在(栏目页、或该栏目此刻没有分带)时减 0,
* 与栏目页本来就有的口径完全一致。
*/
export function resourceBookAllBandLocalPoint({
layout,
category,
x,
y,
}: {
layout: ResourceBookAllLayout | null;
category: ResourceBookCategory;
x: number;
y: number;
}) {
const band = layout?.bandByCategory.get(category) ?? null;
return {
x: x - (band?.originX ?? 0),
y: y - (band?.originY ?? 0),
};
}
export function groupResourceBookResourcesByCategory(
resources: readonly ProjectResource[],
) {
@@ -373,8 +344,6 @@ export function resourceBookOverviewCardLayout({
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
width: 92,
height: 64,
dragX: 0,
dragY: 0,
rotation: overviewStackRotations[rotationIndex] ?? 0,
};
}
@@ -396,8 +365,6 @@ export function resourceBookChildCardLayout({
y,
width: size.width,
height: size.height,
dragX: x,
dragY: y,
rotation: 0,
};
}
@@ -502,8 +469,8 @@ export function buildResourceBookScenePlan({
? (allLayout?.bandByCategory.get(category) ?? null)
: null;
/**
* 子画布布局 + 可选带偏移。偏移同时写进 `x / y` 与 `dragX / dragY`:后者是拖动起点,
* 必须与画出来的世界坐标同系;提交时再减回带原点(`resourceBookAllBandLocalPoint`)。
* 子画布布局 + 可选带偏移。偏移写进 `x / y`:卡片画出来的就是世界坐标,拖动按同一份世界
* 位移换算成栏目内局部坐标落盘(位移在带原点冻结的一次拖动内逐卡拉平)。
*/
const childLayoutFor = (
category: ResourceBookTarget,
@@ -519,7 +486,7 @@ export function buildResourceBookScenePlan({
}
const x = layout.x + band.originX;
const y = layout.y + band.originY;
return { ...layout, x, y, dragX: x, dragY: y };
return { ...layout, x, y };
};
return visibleCategoryOrder.map((category) => {
const presentation = resourceBookCategoryCardPresentation(state, category);
@@ -66,7 +66,8 @@ type ManualLayoutWriteIntent = {
};
/**
* 自动坐标重派生请求。
* 自动坐标重派生请求。两种重算**各占一个队列槽**(按 `kind` 区分):显式整理是用户动作、
* 「关系图首次就绪」是系统动作,后者不得就地改写前者已经按下、还没落盘的那一笔。
*
* - `automatic`:只丢自动坐标,手动卡原地不动(关系图首次就绪那一次)。
* - `organize`:用户显式「整理画布」。目标栏目内的坐标**连手动一起丢**再重算,结果一律是
@@ -591,21 +592,28 @@ export function useProjectResourceCanvasLayout({
return;
}
const signature = resourceSignatureRef.current;
const queued = writeQueueRef.current.find(
/**
* 队列里可能同时排着两笔重算(显式整理与关系图首次就绪各占一个槽):资源签名是作用域级
* 的事实,排队中的每一笔都要跟上,否则先落盘的那笔会把后一笔的签名留成过期值、凭空多排
* 一次资源同步。
*/
const queued = writeQueueRef.current.filter(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scopeEpoch &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
if (queued.resourceSignature !== signature) {
queued.resourceSignature = signature;
queued.conflictRetries = 0;
} else {
queued.conflictRetries = Math.min(
queued.conflictRetries,
conflictRetries,
);
if (queued.length > 0) {
for (const intent of queued) {
if (intent.resourceSignature !== signature) {
intent.resourceSignature = signature;
intent.conflictRetries = 0;
} else {
intent.conflictRetries = Math.min(
intent.conflictRetries,
conflictRetries,
);
}
}
} else {
writeQueueRef.current.push({
@@ -1127,7 +1135,7 @@ export function useProjectResourceCanvasLayout({
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive !== null &&
intent.rederive?.kind === 'automatic' &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
@@ -1174,17 +1182,42 @@ export function useProjectResourceCanvasLayout({
if (sections !== null && sections.length === 0) {
return false;
}
// 先按同一套重算看结果:与当前画面逐值相同(含手动标记)就什么都不做。
const previous = layoutRef.current;
const next = rebuildOptimisticLayout(scope.epoch, 'rederive', sections);
if (!next || positionsEqual(previous.positions, next.positions)) {
/**
* 「这一按到底会不会改变布局」按落盘口径判,而不是看乐观视图:
*
* - 主判据:整理结果 vs 已落盘布局(含手动标记)。与落盘那一步同源,两处判据不再打架。
* - 补判据:目标栏目里还有排队中/在途的手动落点。这些落点会先落盘、随后被这次整理
* 覆盖掉(整理丢的就是目标栏目的**全部**坐标,含手动),所以它本身就是一次真实变化。
* 少了这一条,「刚拖完就按整理」会判成"什么都没变":用户的点击被静默吞掉,接下来
* 落盘的手动结果反客为主。基准取乐观视图同样不行——那里叠着这批落点,等于自己抵消。
*/
const organized = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
'rederive',
topologyRef.current,
sections,
);
const hasPendingManualDropInScope = writeQueueRef.current.some(
(intent) =>
intent.kind === 'manual' &&
intent.scopeEpoch === scope.epoch &&
intent.positions.some(
(write) => sections === null || sections.includes(write.section),
),
);
if (!organized.changed && !hasPendingManualDropInScope) {
return false;
}
// 乐观视图按同一套重算落到画布上;排队中的手动落点仍在它之后落盘。
if (!rebuildOptimisticLayout(scope.epoch, 'rederive', sections)) {
return false;
}
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive !== null &&
intent.rederive?.kind === 'organize' &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
@@ -7,8 +7,6 @@ import {
resolveResourceBookAllLayout,
RESOURCE_BOOK_ALL_BAND_GAP,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
type ResourceBookAllBand,
resourceBookAllBandLocalPoint,
type ResourceBookAllLayout,
resourceBookAllLayoutKey,
resourceBookOverviewCardLayout,
@@ -270,8 +268,6 @@ describe('buildResourceBookScenePlan', () => {
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
@@ -288,8 +284,6 @@ describe('buildResourceBookScenePlan', () => {
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
@@ -464,12 +458,11 @@ describe('buildResourceBookScenePlan', () => {
// 世界原点叠成一摞(用户报的正是这个)。
expect(artGroup.titlebar).toBe(false);
expect(artGroup.titlebarActive).toBe(false);
// 画出来的是"栏目内局部坐标 + 带原点"拖动起点与它同系(提交时再减回带原点)。
// 画出来的是"栏目内局部坐标 + 带原点"拖动按同一份世界位移换算成栏目内局部坐标落盘,
// 所以画出来的世界坐标必须等于"sidecar 里的局部坐标 + 带原点"。
const first = artGroup.cards[0]!;
expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX);
expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY);
expect(first.layout.dragX).toBe(first.layout.x);
expect(first.layout.dragY).toBe(first.layout.y);
// 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。
const sceneCard = plan
@@ -643,56 +636,6 @@ describe('buildResourceBookAllLayout', () => {
});
});
describe('resourceBookAllBandLocalPoint', () => {
const band: ResourceBookAllBand = {
category: 'document',
originX: 40,
originY: 100,
width: 620,
height: 200,
};
const layout: ResourceBookAllLayout = {
bands: [band],
bandByCategory: new Map([['document', band]]),
bounds: { x: 0, y: 0, width: 620, height: 300 },
};
/**
* 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点"
* 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。
*/
it('subtracts the band origin so the world point lands back on the column-local point', () => {
expect(
resourceBookAllBandLocalPoint({
layout,
category: 'document',
x: 140,
y: 260,
}),
).toEqual({ x: 100, y: 160 });
});
it('passes the point through for the column page and for a category without a band', () => {
// 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。
expect(
resourceBookAllBandLocalPoint({
layout: null,
category: 'document',
x: 140,
y: 260,
}),
).toEqual({ x: 140, y: 260 });
expect(
resourceBookAllBandLocalPoint({
layout,
category: 'audio',
x: 7,
y: 9,
}),
).toEqual({ x: 7, y: 9 });
});
});
/**
* 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。
*
@@ -1824,4 +1824,89 @@ describe('资源画布多选拖动与整理范围', () => {
).toEqual([]);
await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([]));
});
/**
* 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。
*
* 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把
* type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的
* 布局。
*/
it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => {
const { manager, tauri } = await mountPointerWorkbench('character', {
layoutByMode: {
type: [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 400,
y: 300,
manuallyPlaced: false,
},
],
dependency: [
{
resourceId: 'asset:pointer-a',
section: 'character',
x: 700,
y: 700,
manuallyPlaced: false,
},
{
resourceId: 'asset:pointer-b',
section: 'character',
x: 900,
y: 900,
manuallyPlaced: false,
},
],
},
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(),
);
await settleFocusChain();
// 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。
const cardA = cardIn(manager, 'asset:pointer-a');
fireEvent.pointerDown(cardA, {
pointerId: 61,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(cardA, {
pointerId: 61,
buttons: 1,
clientX: 180,
clientY: 140,
});
fireEvent.pointerUp(cardA, {
pointerId: 61,
button: 0,
clientX: 180,
clientY: 140,
});
await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0));
await settleFocusChain();
// 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await settleFocusChain();
const dependencyWritesBefore = dependencyWrites(tauri).length;
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
await settleFocusChain();
// 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。
expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore);
});
});
@@ -1595,6 +1595,53 @@ describe('资源画布整理与批量坐标写入', () => {
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 };
@@ -1741,6 +1788,222 @@ describe('资源画布整理与批量坐标写入', () => {
);
});
/**
* 排队中的「整理画布」不能被「关系图首次就绪」那一次重算覆盖。
*
* 两种重算共用同一条写队列:显式整理是用户动作、首次就绪是系统动作,各自占一个队列槽。
* 系统请求若就地改写用户已经按下、还没落盘的那一笔,用户会看到整理生效、落盘却把手动卡
* 留在原地(整理被悄悄降级成"只丢自动坐标")。
*/
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)]);