分类改动不再丢弃已落盘的画布坐标:分区不一致时归并到资源当前分类并保留 x / y
- 原缺陷(3082c3e85 的连带副作用):`kind:"UI"` 的资产投影分类由「待归类」修到「UI 交互」后,它们落盘的坐标 section 仍是现行栏目值 `unclassified`,于是 resolveResourceCanvasSection 走「现行栏目值 != 资源当前分类 ⇒ null」直接判丢弃,8 条用户手摆的坐标没能跟过来、被自动排布重算。 - resourceCanvasSectionMapping.ts:现行栏目值改为返回**资源当前分类**("这张卡属于哪一栏"的唯一真源本来就是 resource.category,协调时也按它分组),与旧栏目归并支路同一条语义 —— 只改写 section,x / y / manuallyPlaced 原样保留。判据:坐标是栏目内局部坐标(reconcileResourceCanvasLayout 先按 section 分组再算栏目带原点),所以换栏目保留 x / y 得到的正是"这张卡在新栏目里的同一个槽位";而"丢弃 + 自动重排"会连 manuallyPlaced 一起抹掉,是用户可见的损失。 - 判断与理由:**保坐标重写 section 可行**。同一函数对更难的旧栏目 case(旧 `art` 可能归并到 4 个不同栏目)已经在做同样的事,对 `unclassified → ui-interaction` 反而更窄;拒绝它是自相矛盾。 - 丢弃口径收窄:resolveResourceCanvasSection 只在**确实无从归并**时返回 null —— 无法识别的分区值,以及旧栏目归并目标集合不含资源当前分类(如旧 `code` 遇到已归为 `document` 的资源)。useProjectResourceCanvasLayout.ts 的 droppedSectionMismatch 文档与读盘统计注释按此更新(用户可见文案未变,它对新口径仍然成立)。 - 断言:resourceCanvasSectionMapping.test.ts 把原「沿用既有行为丢弃该坐标」用例改写为守新契约(归并 + changed=true),并新增 x / y / manuallyPlaced 逐项不变用例;resourceCanvasLayoutModel.test.ts 原「drops a persisted position…」改写为「rewrites … keeping the coordinates」;useProjectResourceCanvasLayout.test.ts 的读盘统计由 normalized 1 / droppedSectionMismatch 1 改为 normalized 2 / droppedSectionMismatch 0,并新增"归并必须连坐标一起保住"的断言,另补一条用例钉住 droppedSectionMismatch 只剩"无从归并"两类来源;appSurface/project-development.suite.ts 的提示文案与 data-* 计数同步,并新增写盘坐标断言。 - 变异验证(提交前已跑):把 resolveResourceCanvasSection 还原成 `section === resourceCategory ? section : null` → 6 条用例变红(映射 2 条、布局模型 1 条、读盘统计 2 条、appSurface 1 条),还原后复跑 83 passed + appSurface 该条 passed。 - 门禁:resourceCanvasSectionMapping 9 passed、resourceCanvasLayoutModel 47 passed、useProjectResourceCanvasLayout 27 passed。
This commit is contained in:
+18
-5
@@ -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: {
|
||||
|
||||
+11
-5
@@ -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;
|
||||
|
||||
@@ -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: '知道了' }));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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('不猜测无法识别的分区取值和未知资源', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user