读时丢弃的坐标不再静默:提示条带条数并在 DOM 上分开三类计数

- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。

- 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。

- 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。

- 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。

- 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。

- 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
This commit is contained in:
2026-09-11 19:18:12 +08:00
parent 18361f8585
commit e15456ea14
4 changed files with 120 additions and 26 deletions
@@ -1396,8 +1396,8 @@ export default function ProjectDevelopmentView({
const autoOpenedUiWorkflowResourceRef = useRef<string | null>(null);
const [resourceWorkbenchNotice, setResourceWorkbenchNotice] = useState('');
/**
* 打开项目时读盘归并旧分区是当场写回 sidecar 的副作用,改前完全静默。
* 这条提示只在项目刚打开时出现一次,可手动关掉,不是常驻说明。
* 打开项目时读盘归并旧分区 / 跳过无法对齐的坐标都是当场写回 sidecar 的副作用,
* 改前完全静默。这条提示只在项目刚打开时出现一次,可手动关掉,不是常驻说明。
*/
const [resourceLayoutReadNotice, setResourceLayoutReadNotice] = useState<{
text: string;
@@ -2099,7 +2099,7 @@ export default function ProjectDevelopmentView({
const resourceLayoutNotice = activeResourceLayout.notice;
const resourceLayoutSaving = activeResourceLayout.saving;
/**
* 读时归并发生在「打开项目」这一刻,且结果会被当场写回的 sidecar 固化,
* 读时归并 / 读时丢弃都发生在「打开项目」这一刻,且结果会被当场写回的 sidecar 固化,
* 所以必须让用户看见一次。两个排序模式各读一份 sidecar,这里只取先到的那份、
* 每个项目只提示一次,避免两条提示互相覆盖;切项目后上一次的提示必须清掉。
*/
@@ -6066,6 +6066,15 @@ export default function ProjectDevelopmentView({
data-resource-canvas-layout-normalized={
resourceLayoutReadNotice.report.normalizedSections
}
data-resource-canvas-layout-dropped={
resourceLayoutReadNotice.report.dropped
}
data-resource-canvas-layout-dropped-missing-resource={
resourceLayoutReadNotice.report.droppedMissingResource
}
data-resource-canvas-layout-dropped-section-mismatch={
resourceLayoutReadNotice.report.droppedSectionMismatch
}
>
<span>{resourceLayoutReadNotice.text}</span>
<button
@@ -232,11 +232,12 @@ function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
}
/**
* 读盘后按现行分区归并坐标的只读统计。
* 读盘后按现行分区归并 / 丢弃坐标的只读统计。
*
* 读时归并(旧 `art` / `code` 分区 → 资源当前分区)会让协调路径判定 `changed`,
* 于是第一次打开项目就会把整份布局写回一次 sidecar。改前这件事完全静默
* 所以这里把它数出来,交给调用方给用户一次可见反馈。
* 于是第一次打开项目就会把整份布局写回一次 sidecar;无法归并的坐标则被丢弃
* 并且这次丢弃同样由那次写回固化。改前这两件事都完全静默,所以这里把它数出来,
* 交给调用方给用户一次可见反馈。
*/
export type ProjectResourceCanvasLayoutReadReport = {
/** 项目作用域身份(不含排序模式):同一个项目的两份 sidecar 只提示一次。 */
@@ -244,6 +245,12 @@ export type ProjectResourceCanvasLayoutReadReport = {
mode: ProjectResourceCanvasLayoutMode;
/** 旧栏目坐标被读时归并到资源当前分区的条数。 */
normalizedSections: number;
/** 因 `resourceId` 在当前资源集合里查不到而被丢弃的坐标条数。 */
droppedMissingResource: number;
/** 因持久化分区与资源当前分类不匹配而被丢弃的坐标条数(资源仍在,会按现行分区重新落位)。 */
droppedSectionMismatch: number;
/** 上面两项之和:读盘时被丢弃的坐标总数。 */
dropped: number;
};
export type ProjectResourceCanvasLayoutReadCounts = Omit<
@@ -253,8 +260,8 @@ export type ProjectResourceCanvasLayoutReadCounts = Omit<
/**
* 读盘统计。判据直接复用 `normalizeResourceCanvasPosition`——也就是
* `reconcileResourceCanvasLayout` 读盘用的同一个函数,所以计数与真正落盘的结果逐条一致;
* 这里只统计,判定与写回语义都不动。
* `reconcileResourceCanvasLayout` 丢失坐标用的同一个函数,所以三种计数与真正落盘的
* 结果逐条一致;这里只统计,判定与写回语义都不动。
*/
export function inspectProjectResourceCanvasLayoutRead(
layout: ProjectResourceCanvasLayout,
@@ -264,28 +271,59 @@ export function inspectProjectResourceCanvasLayoutRead(
resources.map((resource) => [resource.id, resource.category]),
);
let normalizedSections = 0;
let droppedMissingResource = 0;
let droppedSectionMismatch = 0;
for (const position of layout.positions) {
const normalized = normalizeResourceCanvasPosition(
position,
categoryByResourceId,
);
if (normalized?.changed) {
normalizedSections += 1;
if (normalized) {
if (normalized.changed) {
normalizedSections += 1;
}
continue;
}
if (categoryByResourceId.has(position.resourceId)) {
droppedSectionMismatch += 1;
} else {
droppedMissingResource += 1;
}
}
return { normalizedSections };
return {
normalizedSections,
droppedMissingResource,
droppedSectionMismatch,
dropped: droppedMissingResource + droppedSectionMismatch,
};
}
/**
* 读盘统计的用户可见文案。只描述已经发生的事:归并会当场写回一次 sidecar、坐标位置不变
* 这里不写长期规则,也不承诺后续行为。
* 读盘统计的用户可见文案。只描述已经发生的事:归并会当场写回一次 sidecar、坐标位置不变
* 无法归并的坐标会被跳过。这里不写长期规则,也不承诺后续行为。
*/
export function describeProjectResourceCanvasLayoutRead(
report: ProjectResourceCanvasLayoutReadCounts,
): string {
return report.normalizedSections > 0
? `已把 ${report.normalizedSections} 条旧分区坐标对齐到新分区并写回,坐标位置未变`
: '';
const sentences: string[] = [];
if (report.normalizedSections > 0) {
sentences.push(
`已把 ${report.normalizedSections} 条旧分区坐标对齐到新分区并写回,坐标位置未变`,
);
}
if (report.dropped > 0) {
const reasons: string[] = [];
if (report.droppedMissingResource > 0) {
reasons.push(`${report.droppedMissingResource} 条资源已不在项目中`);
}
if (report.droppedSectionMismatch > 0) {
reasons.push(`${report.droppedSectionMismatch} 条分区与资源分类不匹配`);
}
sentences.push(
`${report.normalizedSections > 0 ? '另有 ' : '打开项目时有 '}${report.dropped} 条坐标无法对齐已跳过(${reasons.join('')}`,
);
}
return sentences.join('');
}
function reconcileLayout(
@@ -789,9 +827,9 @@ export function useProjectResourceCanvasLayout({
).changed
) {
enqueueResourceSyncRef.current(epoch);
// 有归并才会有这次写回;单纯补新资源落位的写回不报读时统计,
// 有归并或丢弃才会有这次写回;单纯补新资源落位的写回不报读时统计,
// 否则提示会声称「已对齐旧分区」而实际上一条都没对齐。
if (readCounts.normalizedSections > 0) {
if (readCounts.normalizedSections > 0 || readCounts.dropped > 0) {
setReadReport({
...readCounts,
projectScopeKey: createProjectScopeKey(projectPath, projectId),
@@ -1790,22 +1790,26 @@ export function registerProjectWorkbenchFoundationTests() {
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'.game-resource-book-notices [data-resource-canvas-layout-normalized]',
'.game-resource-book-notices [data-resource-canvas-layout-dropped]',
);
expect(element).not.toBeNull();
return element as HTMLElement;
});
expect(notice.querySelector('span')?.textContent).toBe(
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变',
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)',
);
// 排障口径:完全没丢是 0 / 0,丢的是哪一类由两个子计数分开。
expect(notice.dataset.resourceCanvasLayoutNormalized).toBe('1');
expect(notice.dataset.resourceCanvasLayoutDropped).toBe('2');
expect(notice.dataset.resourceCanvasLayoutDroppedMissingResource).toBe('1');
expect(notice.dataset.resourceCanvasLayoutDroppedSectionMismatch).toBe('1');
// 读时归并会当场写回一次 sidecar,提示说的就是这次写回。
await waitFor(() => expect(layoutRevision).toBeGreaterThan(0));
// 一次性提示,不是常驻说明:关掉即从 DOM 消失。
fireEvent.click(within(notice).getByRole('button', { name: '知道了' }));
expect(
document.querySelector('[data-resource-canvas-layout-normalized]'),
document.querySelector('[data-resource-canvas-layout-dropped]'),
).toBeNull();
}, 20_000);
@@ -1877,7 +1881,7 @@ export function registerProjectWorkbenchFoundationTests() {
// 读盘已经落地:此刻既没有读时提示,也没有任何写回。
await act(async () => {});
expect(
document.querySelector('[data-resource-canvas-layout-normalized]'),
document.querySelector('[data-resource-canvas-layout-dropped]'),
).toBeNull();
expect(document.querySelector('.game-resource-live-notice')).toBeNull();
}, 20_000);
@@ -1244,9 +1244,12 @@ describe('useProjectResourceCanvasLayout', () => {
projectScopeKey: JSON.stringify([projectPath, projectId]),
mode: 'type',
normalizedSections: 1,
droppedMissingResource: 1,
droppedSectionMismatch: 1,
dropped: 2,
});
expect(describeProjectResourceCanvasLayoutRead(report!)).toBe(
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变',
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)',
);
// 归并与丢弃的结果确实被那一次写回固化:写盘内容里旧 `art` 与不匹配分区都不在了。
await waitFor(() => expect(updates).toHaveLength(1));
@@ -1332,6 +1335,9 @@ describe('useProjectResourceCanvasLayout', () => {
describe('resource canvas layout read report', () => {
const emptyCounts = {
normalizedSections: 0,
droppedMissingResource: 0,
droppedSectionMismatch: 0,
dropped: 0,
};
function layoutWith(
@@ -1340,13 +1346,20 @@ describe('resource canvas layout read report', () => {
return persistedLayout('type', 1, positions);
}
it('counts the coordinates realigned onto the current sections', () => {
it('separates realigned sections from both drop reasons', () => {
const counts = inspectProjectResourceCanvasLayoutRead(
layoutWith([
{ resourceId: 'a', section: 'art', x: 0, y: 0, manuallyPlaced: true },
{
resourceId: 'b',
section: 'audio',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: true,
},
{
resourceId: 'ghost',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: true,
@@ -1358,7 +1371,12 @@ describe('resource canvas layout read report', () => {
],
);
expect(counts).toEqual({ normalizedSections: 1 });
expect(counts).toEqual({
normalizedSections: 1,
droppedMissingResource: 1,
droppedSectionMismatch: 1,
dropped: 2,
});
});
it('describes each read-report shape', () => {
@@ -1369,5 +1387,30 @@ describe('resource canvas layout read report', () => {
normalizedSections: 112,
}),
).toBe('已把 112 条旧分区坐标对齐到新分区并写回,坐标位置未变');
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
normalizedSections: 112,
droppedMissingResource: 66,
droppedSectionMismatch: 16,
dropped: 82,
}),
).toBe(
'已把 112 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 82 条坐标无法对齐已跳过(66 条资源已不在项目中,16 条分区与资源分类不匹配)',
);
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
droppedMissingResource: 3,
dropped: 3,
}),
).toBe('打开项目时有 3 条坐标无法对齐已跳过(3 条资源已不在项目中)');
expect(
describeProjectResourceCanvasLayoutRead({
...emptyCounts,
droppedSectionMismatch: 2,
dropped: 2,
}),
).toBe('打开项目时有 2 条坐标无法对齐已跳过(2 条分区与资源分类不匹配)');
});
});