diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 27b8867ad..80c67a98f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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(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( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index 454c05448..4f2d85c16 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -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); diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 31f4ce99d..074fda6f3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -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) { diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index cd3cfe204..e2099510f 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -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 反馈)。 * diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index dff9a10a1..dc734438a 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -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); + }); }); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 54062db15..ab6201a00 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -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) => { + 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((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)]);