拒收提示不再被"进入项目"那一次清除吃掉:只在真的换项目时清
- 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无; - 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记; - 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉); - 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s; - 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user