diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts index 59f0112f7..5cbc2f0a5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts @@ -27,7 +27,7 @@ export const LEGACY_RESOURCE_CANVAS_SECTION_TARGETS: Record< /** * 无法精确归并时回落到的栏目。当前分区不在目标集合内(例如资产后来被重新分类)时, - * 该坐标按今天「资源分区已变化」的既有行为丢弃,不做坐标迁移、不重置其余坐标。 + * 该坐标按"确实无从归并"处理:不做坐标迁移、不重置其余坐标。 */ export const LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK: Record< LegacyProjectResourceCanvasSection, @@ -43,15 +43,27 @@ export const LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK: Record< /** * 把磁盘上的 `section` 值归一到现行分区。 * - * 返回 `null` 表示该坐标既不属于资源的现行分区、也不在旧栏目白名单内(含无法识别的取值), - * 与今天「持久化坐标分区与资源分区不一致」的处理一致:丢弃该条坐标,不臆造新位置。 + * 两条归并语义都由**资源当前分类**收口,因为"这张卡属于哪一栏"的唯一真源本来就是 + * `resource.category`(协调时按它分组),持久化的 `section` 只是"它当初放在哪一栏"的记录: + * + * 1. **现行栏目值**:原样返回资源当前分类。旧实现里"现行值但不等于当前分类"直接判 null、 + * 整条坐标被丢弃 —— 那等于把用户手摆的位置换成自动排布。资源分类会变(例如 + * `kind:"UI"` 的资产从「待归类」被修到「UI 交互」),一改就静默丢掉落盘坐标,是用户可见的损失。 + * 2. **旧栏目值**:落在该旧栏目的归并目标集合内就归并到资源当前分类,否则判 null。 + * + * 两处都**只改写 `section`,x / y / manuallyPlaced 原样保留**;坐标是栏目内局部坐标 + * (相邻的 `reconcileResourceCanvasLayout` 按 section 分组后再算栏目带原点),所以换栏目 + * 保留 x / y 得到的正是"这张卡在新栏目里的同一个槽位"。 + * + * 返回 `null` 只留给**真正无从归并**的取值:无法识别的分区值,以及旧栏目目标的集合 + * 不含资源当前分类的情况(如旧 `code` 遇到已归为 `document` 的资源)。 */ export function resolveResourceCanvasSection( section: unknown, resourceCategory: ProjectResourceCanvasCategory, ): ProjectResourceCanvasCategory | null { if (isProjectResourceCanvasCategory(section)) { - return section === resourceCategory ? section : null; + return resourceCategory; } if (!isLegacyProjectResourceCanvasSection(section)) { return null; @@ -83,7 +95,8 @@ export type NormalizedResourceCanvasPositionResult = { /** * 读时兼容映射:把一条持久化坐标归一到现行分区,原样保留 `x / y / manuallyPlaced`。 * - * 返回 `null` 表示资源不存在或该坐标无法归并,调用方不应把它放进任何分区。 + * 返回 `null` 表示资源不存在或该坐标确实无从归并,调用方不应把它放进任何分区; + * **分区取值与资源当前分类不一致不属于"无从归并"**,见 [`resolveResourceCanvasSection`]。 */ export function normalizeResourceCanvasPosition( position: { 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 becf44ea8..2a2444f4b 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 @@ -252,10 +252,10 @@ function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) { /** * 读盘后按现行分区归并 / 丢弃坐标的只读统计。 * - * 读时归并(旧 `art` / `code` 分区 → 资源当前分区)会让协调路径判定 `changed`, - * 于是第一次打开项目就会把整份布局写回一次 sidecar;无法归并的坐标则被丢弃, - * 并且这次丢弃同样由那次写回固化。改前这两件事都完全静默,所以这里把它们数出来, - * 交给调用方给用户一次可见反馈。 + * 读时归并(旧 `art` / `code` 分区、以及"现行栏目值与资源当前分类不一致"→ 资源当前分区) + * 会让协调路径判定 `changed`,于是第一次打开项目就会把整份布局写回一次 sidecar;确实无从 + * 归并的坐标则被丢弃,并且这次丢弃同样由那次写回固化。改前这两件事都完全静默,所以这里 + * 把它们数出来,交给调用方给用户一次可见反馈。 */ export type ProjectResourceCanvasLayoutReadReport = { /** 项目作用域身份(不含排序模式):同一个项目的两份 sidecar 只提示一次。 */ @@ -265,7 +265,13 @@ export type ProjectResourceCanvasLayoutReadReport = { normalizedSections: number; /** 因 `resourceId` 在当前资源集合里查不到而被丢弃的坐标条数。 */ droppedMissingResource: number; - /** 因持久化分区与资源当前分类不匹配而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。 */ + /** + * 因分区取值**确实无从归并**而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。 + * + * 只有两种取值会落到这里:无法识别的分区值,以及旧栏目归并目标的集合不含资源当前分类 + * (如旧 `code` 遇到已归为 `document` 的资源)。**现行栏目值与资源当前分类不一致不再丢弃**: + * 那种情况下 `section` 被归并到资源当前分类、x / y 原样保留,计入 `normalizedSections`。 + */ droppedSectionMismatch: number; /** 上面两项之和:读盘时被丢弃的坐标总数。 */ dropped: number; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 3e802d5f7..18737bd92 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1714,6 +1714,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ]; let layoutRevision = 0; + let writtenPositions: ProjectResourceCanvasPosition[] = []; const storedPositions: ProjectResourceCanvasPosition[] = [ // 旧 `art` 分区 + 资源当前分类 `character`:打开项目时归并成 `character`。 { @@ -1723,7 +1724,7 @@ export function registerProjectWorkbenchFoundationTests() { y: 20, manuallyPlaced: true, }, - // 资源仍在,但持久化分区与资源当前分类不匹配:这条坐标被跳过。 + // 资源仍在,但持久化分区与资源当前分类不匹配:归并到资源当前分类,坐标保持不动。 { resourceId: 'asset:mismatched-audio', section: 'document', @@ -1757,6 +1758,7 @@ export function registerProjectWorkbenchFoundationTests() { } if (command === 'update_local_project_resource_canvas_layout') { layoutRevision += 1; + writtenPositions = args?.positions as ProjectResourceCanvasPosition[]; return { status: 'updated', layout: { @@ -1796,15 +1798,22 @@ export function registerProjectWorkbenchFoundationTests() { return element as HTMLElement; }); expect(notice.querySelector('span')?.textContent).toBe( - '已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)', + '已把 2 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 1 条坐标无法对齐已跳过(1 条资源已不在项目中)', ); // 排障口径:完全没丢是 0 / 0,丢的是哪一类由两个子计数分开。 - expect(notice.dataset.resourceCanvasLayoutNormalized).toBe('1'); - expect(notice.dataset.resourceCanvasLayoutDropped).toBe('2'); + // 「现行分区与资源分类不一致」不再丢弃 —— 它归并到资源当前分类、坐标保持不动。 + expect(notice.dataset.resourceCanvasLayoutNormalized).toBe('2'); + expect(notice.dataset.resourceCanvasLayoutDropped).toBe('1'); expect(notice.dataset.resourceCanvasLayoutDroppedMissingResource).toBe('1'); - expect(notice.dataset.resourceCanvasLayoutDroppedSectionMismatch).toBe('1'); + expect(notice.dataset.resourceCanvasLayoutDroppedSectionMismatch).toBe('0'); // 读时归并会当场写回一次 sidecar,提示说的就是这次写回。 await waitFor(() => expect(layoutRevision).toBeGreaterThan(0)); + // 用户可见判据:分类变过的那张卡必须**保住手摆的坐标**,不能被丢掉后自动重排。 + expect( + writtenPositions.find( + ({ resourceId }) => resourceId === 'asset:mismatched-audio', + ), + ).toMatchObject({ section: 'audio', x: 30, y: 40, manuallyPlaced: true }); // 一次性提示,不是常驻说明:关掉即从 DOM 消失。 fireEvent.click(within(notice).getByRole('button', { name: '知道了' })); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index 1129408e4..d0884197d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -209,7 +209,10 @@ describe('resource canvas layout model', () => { ).toBe(false); }); - it('drops a persisted position whose section no longer matches the resource', () => { + it('rewrites a persisted position whose section no longer matches, keeping the coordinates', () => { + // 旧行为是丢弃这条坐标、让资源按现行分区自动重排 —— 那等于把用户手摆的位置抹掉。 + // 资源分类本来就会变(`kind:"UI"` 的 8 条资产从「待归类」被修到「UI 交互」就是真机一例), + // 所以现在改成改写 section、原样保留 x / y / manuallyPlaced。 const layout = createEmptyResourceCanvasLayout('project-1', 'dependency'); layout.positions = [ { @@ -223,13 +226,14 @@ describe('resource canvas layout model', () => { const reconciled = reconcileResourceCanvasLayout(layout, [ resource('asset-a', 'scene'), ]); + expect(reconciled.changed).toBe(true); expect(reconciled.layout.positions).toEqual([ { resourceId: 'asset-a', section: 'scene', - x: 0, - y: 0, - manuallyPlaced: false, + x: 10, + y: 10, + manuallyPlaced: true, }, ]); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasSectionMapping.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasSectionMapping.test.ts index b5cbaff94..c7e49d9c7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasSectionMapping.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasSectionMapping.test.ts @@ -129,9 +129,16 @@ describe('资源画布分区轴与新旧栏目映射', () => { } }); - it('现役分区与资源分区不一致时沿用既有行为丢弃该坐标', () => { - expect(resolveResourceCanvasSection('scene', 'character')).toBeNull(); - expect(resolveResourceCanvasSection('version', 'document')).toBeNull(); + it('现役分区与资源分区不一致时归并到资源当前分类并保留坐标', () => { + // 旧实现把"现行栏目值但不等于资源当前分类"判 null、整条坐标丢弃 —— 那等于把用户手摆的 + // 位置换成自动排布。资源分类本来就会变(`kind:"UI"` 的 8 条资产从「待归类」被修到 + // 「UI 交互」就是真机一例),所以这里必须归并,语义与旧栏目归并支路完全一致。 + expect(resolveResourceCanvasSection('scene', 'character')).toBe( + 'character', + ); + expect(resolveResourceCanvasSection('version', 'document')).toBe( + 'document', + ); expect( normalizeResourceCanvasPosition( { @@ -143,7 +150,42 @@ describe('资源画布分区轴与新旧栏目映射', () => { }, categoryByResourceId('character'), ), - ).toBeNull(); + ).toEqual({ + position: { + resourceId: 'resource-1', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: true, + }, + changed: true, + }); + }); + + it('归并到资源当前分类时 x / y / manuallyPlaced 逐项不被改写', () => { + // 真机现场:`kind:"UI"` 资产落盘在「待归类」栏,分类被修到「UI 交互」之后 + // 它们的坐标必须跟过来,而不是被丢弃后由自动排布重算。 + expect( + normalizeResourceCanvasPosition( + { + resourceId: 'resource-1', + section: 'unclassified', + x: -24, + y: 96, + manuallyPlaced: true, + }, + categoryByResourceId('ui-interaction'), + ), + ).toEqual({ + position: { + resourceId: 'resource-1', + section: 'ui-interaction', + x: -24, + y: 96, + manuallyPlaced: true, + }, + changed: true, + }); }); it('不猜测无法识别的分区取值和未知资源', () => { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index d0eb28f1a..f00f6357b 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1194,7 +1194,7 @@ describe('useProjectResourceCanvasLayout', () => { y: 20, manuallyPlaced: true, }, - // 资源还在,但持久化分区与资源分类不匹配:坐标被跳过。 + // 资源还在,但持久化分区与资源分类不匹配:**归并到资源当前分类,坐标不动**。 { resourceId: 'asset:mismatched-audio', section: 'document', @@ -1243,13 +1243,13 @@ describe('useProjectResourceCanvasLayout', () => { expect(report).toMatchObject({ projectScopeKey: JSON.stringify([projectPath, projectId]), mode: 'type', - normalizedSections: 1, + normalizedSections: 2, droppedMissingResource: 1, - droppedSectionMismatch: 1, - dropped: 2, + droppedSectionMismatch: 0, + dropped: 1, }); expect(describeProjectResourceCanvasLayoutRead(report!)).toBe( - '已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)', + '已把 2 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 1 条坐标无法对齐已跳过(1 条资源已不在项目中)', ); // 归并与丢弃的结果确实被那一次写回固化:写盘内容里旧 `art` 与不匹配分区都不在了。 await waitFor(() => expect(updates).toHaveLength(1)); @@ -1257,6 +1257,16 @@ describe('useProjectResourceCanvasLayout', () => { '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 () => { @@ -1346,7 +1356,7 @@ describe('resource canvas layout read report', () => { return persistedLayout('type', 1, positions); } - it('separates realigned sections from both drop reasons', () => { + it('separates realigned sections from the drop reason that remains', () => { const counts = inspectProjectResourceCanvasLayoutRead( layoutWith([ { resourceId: 'a', section: 'art', x: 0, y: 0, manuallyPlaced: true }, @@ -1371,10 +1381,34 @@ describe('resource canvas layout read report', () => { ], ); + // 'b' 的现行分区与资源当前分类不一致,但**不是**"无从归并":它归并到 audio 并保留坐标, + // 因此计入 normalizedSections。剩下的丢弃理由只有"资源已不在项目中"。 expect(counts).toEqual({ - normalizedSections: 1, + normalizedSections: 2, droppedMissingResource: 1, - droppedSectionMismatch: 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, }); });