From a0d1fca30a7c5d919ef3d4b080328f1cf6b5900d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 20:22:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=92=E6=94=B6=E6=8F=90=E7=A4=BA=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB"=E8=BF=9B=E5=85=A5=E9=A1=B9=E7=9B=AE"?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E6=AC=A1=E6=B8=85=E9=99=A4=E5=90=83=E6=8E=89?= =?UTF-8?q?=EF=BC=9A=E5=8F=AA=E5=9C=A8=E7=9C=9F=E7=9A=84=E6=8D=A2=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=97=B6=E6=B8=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无; - 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记; - 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉); - 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s; - 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。 --- .../features/app-shell/WorkspaceLauncher.tsx | 15 ++ .../workspaceLauncherManifestMerge.test.tsx | 145 ++++++++++++------ 2 files changed, 112 insertions(+), 48 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index dfd5dae6d..70ada68da 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -161,7 +161,22 @@ export function WorkspaceLauncherShell({ * 也会让提示在几个阶段之间来回抖。换项目时提示与在飞标记一起清掉。 */ const manifestMergeRecoveryInFlightRef = useRef(null); + /** + * 上一次观察到的项目路径,用来区分"真的换项目"和"首次进入项目"。 + * + * 首次进入项目也要跑这个 effect(依赖从 `undefined` 变成路径),但那一刻**没有上一个 + * 项目的提示要清**;如果无条件 `setManifestMergeNotice(null)`,就会把「打开项目这一提交 + * 里刚落下的拒收提示」一起清掉 —— 而资产命令在打开项目后马上推送清单快照正是主场景, + * 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。 + */ + const manifestMergeNoticeScopeRef = useRef(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]); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index c5e50ab9b..b8c01b817 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -153,6 +153,42 @@ async function pushDivergentEqualRevisionSnapshot() { } describe('清单快照被拒收时的用户可见性与恢复', () => { + /** + * 每一轮都重新查一次节点。 + * + * 拒收提示会走过 `recovering → recovered / unresolved` 三个阶段,React 在阶段切换时 + * 可能替换掉节点;抓着第一次查到的引用去断言,会在负载高的时候拿到过期节点而瞬时变红。 + */ + function mergeNotice() { + return document.querySelector( + '[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( - '[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( - '[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( - '[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) => { + 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 }); + }); });