拒收提示不再被"进入项目"那一次清除吃掉:只在真的换项目时清

- 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无;
- 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记;
- 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉);
- 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s;
- 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。
This commit is contained in:
2026-09-11 20:22:52 +08:00
parent d3b341948a
commit a0d1fca30a
2 changed files with 112 additions and 48 deletions
@@ -161,7 +161,22 @@ export function WorkspaceLauncherShell({
* 也会让提示在几个阶段之间来回抖。换项目时提示与在飞标记一起清掉。
*/
const manifestMergeRecoveryInFlightRef = useRef<string | null>(null);
/**
* 上一次观察到的项目路径,用来区分"真的换项目"和"首次进入项目"。
*
* 首次进入项目也要跑这个 effect(依赖从 `undefined` 变成路径),但那一刻**没有上一个
* 项目的提示要清**;如果无条件 `setManifestMergeNotice(null)`,就会把「打开项目这一提交
* 里刚落下的拒收提示」一起清掉 —— 而资产命令在打开项目后马上推送清单快照正是主场景,
* 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。
*/
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
useEffect(() => {
const projectPath = currentProjectContext?.projectPath ?? null;
const previousProjectPath = manifestMergeNoticeScopeRef.current;
manifestMergeNoticeScopeRef.current = projectPath;
if (projectPath !== null && previousProjectPath === null) {
return;
}
manifestMergeRecoveryInFlightRef.current = null;
setManifestMergeNotice(null);
}, [currentProjectContext?.projectPath]);
@@ -153,6 +153,42 @@ async function pushDivergentEqualRevisionSnapshot() {
}
describe('清单快照被拒收时的用户可见性与恢复', () => {
/**
* 每一轮都重新查一次节点。
*
* 拒收提示会走过 `recovering → recovered / unresolved` 三个阶段,React 在阶段切换时
* 可能替换掉节点;抓着第一次查到的引用去断言,会在负载高的时候拿到过期节点而瞬时变红。
*/
function mergeNotice() {
return document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
}
/** 恢复要等一次磁盘重读(两个 revision + 一次清单,全是真实 promise),负载下留足超时。 */
async function waitForMergeNoticeStage(
stage: 'recovering' | 'recovered' | 'unresolved',
) {
await waitFor(
() =>
expect(mergeNotice()?.getAttribute('data-manifest-merge-stage')).toBe(
stage,
),
{ timeout: 4000 },
);
}
async function waitForRejectionNotice() {
await waitFor(
() =>
expect(
mergeNotice()?.getAttribute('data-manifest-merge-decision'),
).toBe('revision-conflict'),
{ timeout: 4000 },
);
return mergeNotice()!;
}
beforeEach(() => {
diskManifest = manifestWithAsset(null);
captured.supervisorProps = null;
@@ -171,32 +207,19 @@ describe('清单快照被拒收时的用户可见性与恢复', () => {
await pushDivergentEqualRevisionSnapshot();
// 拒收不再静默:提示条 + 排障观察点都要出现。
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
expect(notice.getAttribute('data-manifest-merge-decision')).toBe(
'revision-conflict',
);
expect(notice.getAttribute('data-manifest-merge-source')).toBe(
await waitForRejectionNotice();
expect(mergeNotice()!.getAttribute('data-manifest-merge-source')).toBe(
'asset-command',
);
expect(notice.getAttribute('data-manifest-merge-held-revision')).toBe(
String(HELD_REVISION),
);
expect(notice.getAttribute('data-manifest-merge-snapshot-revision')).toBe(
String(HELD_REVISION),
);
expect(
mergeNotice()!.getAttribute('data-manifest-merge-held-revision'),
).toBe(String(HELD_REVISION));
expect(
mergeNotice()!.getAttribute('data-manifest-merge-snapshot-revision'),
).toBe(String(HELD_REVISION));
// 恢复:重读磁盘的 revision + 清单,并把新素材真的带进项目上下文。
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'recovered',
),
);
await waitForMergeNoticeStage('recovered');
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
@@ -206,7 +229,7 @@ describe('清单快照被拒收时的用户可见性与恢复', () => {
(asset) => asset.id,
),
).toEqual(['asset-new-art']);
expect(notice.textContent).toContain('已按磁盘清单重新对齐');
expect(mergeNotice()!.textContent).toContain('已按磁盘清单重新对齐');
});
it('reports an unresolved rejection instead of dropping it when the reread fails', async () => {
@@ -224,19 +247,9 @@ describe('清单快照被拒收时的用户可见性与恢复', () => {
await pushDivergentEqualRevisionSnapshot();
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'unresolved',
),
);
expect(notice.textContent).toContain('重新打开项目');
await waitForRejectionNotice();
await waitForMergeNoticeStage('unresolved');
expect(mergeNotice()!.textContent).toContain('重新打开项目');
});
it('never moves the held revision backwards when disk reports an older one', async () => {
@@ -255,18 +268,8 @@ describe('清单快照被拒收时的用户可见性与恢复', () => {
await pushDivergentEqualRevisionSnapshot();
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'unresolved',
),
);
await waitForRejectionNotice();
await waitForMergeNoticeStage('unresolved');
expect(captured.supervisorProps?.initialProjectManifest?.assets).toEqual(
[],
);
@@ -300,4 +303,50 @@ describe('清单快照被拒收时的用户可见性与恢复', () => {
),
).toHaveLength(callsBefore);
});
it('clears the rejection notice when the user switches to another project', async () => {
// 反向钉子:进入项目那一次不许清(同一提交窗口里落下的拒收提示必须留下),
// 但真的换了项目,上一个项目的提示必须跟着走掉。
const otherPath = '/tmp/manifest-merge-notice-other-project';
const invoke = installInvokeMock();
await openProjectThroughLauncher();
await pushDivergentEqualRevisionSnapshot();
await waitForRejectionNotice();
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return otherPath;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: otherPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '另一个项目',
godotProjectRoot: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'other-project-draft',
'另一个项目',
);
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw new Error(
`unexpected invoke ${command} ${JSON.stringify(args ?? {})}`,
);
},
);
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
await waitFor(() => expect(mergeNotice()).toBeNull(), { timeout: 4000 });
});
});