diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1ff1f578f..d5c9bf98c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -359,17 +359,11 @@ export function registerClientHomeTests() { source: { kind: 'generated' }, }, ]; - let resolveStaleRefresh!: ( - manifest: typeof staleFirstManifest, - ) => void; + let resolveStaleRefresh!: (manifest: typeof staleFirstManifest) => void; const staleRefresh = new Promise((resolve) => { resolveStaleRefresh = resolve; }); let holdFirstRefresh = false; - let signalFirstRefreshStarted!: () => void; - const firstRefreshStarted = new Promise((resolve) => { - signalFirstRefreshStarted = resolve; - }); const runtimeHarness = createProjectSupervisorRuntimeHarness({ projectPath: firstProjectPath, }); @@ -393,7 +387,6 @@ export function registerClientHomeTests() { return secondManifest; } if (holdFirstRefresh) { - signalFirstRefreshStarted(); return staleRefresh; } return firstManifest; @@ -461,11 +454,30 @@ export function registerClientHomeTests() { }); fireEvent.click(screen.getByRole('button', { name: '打开' })); await screen.findByLabelText('项目开发工作台'); + await waitFor(() => { + expect(runtimeHarness.listen).toHaveBeenCalledWith( + 'game-creator-manifest-invalidated', + expect.any(Function), + ); + }); + const firstProjectManifestReadsBeforeRefresh = invoke.mock.calls.filter( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.projectPath === firstProjectPath, + ).length; holdFirstRefresh = true; act(() => { runtimeHarness.emitManifestInvalidated('code-prototype'); }); - await firstRefreshStarted; + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.projectPath === firstProjectPath, + ).length, + ).toBeGreaterThan(firstProjectManifestReadsBeforeRefresh); + }); fireEvent.click(screen.getByRole('button', { name: '项目组' })); fireEvent.change(screen.getByLabelText('项目目录'), { @@ -481,8 +493,10 @@ export function registerClientHomeTests() { await screen.findByRole('button', { name: /second\.png/ }), ).not.toBeNull(); - act(() => resolveStaleRefresh(staleFirstManifest)); - await Promise.resolve(); + await act(async () => { + resolveStaleRefresh(staleFirstManifest); + await staleRefresh; + }); expect( screen.queryByRole('button', { name: /stale-first\.png/ }), ).toBeNull(); diff --git a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md index a2a7667a8..cada040a1 100644 --- a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md +++ b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md @@ -1,6 +1,6 @@ # React 组件测试准则 -更新时间:`2026-06-26` +更新时间:`2026-08-07` ## 背景 @@ -36,6 +36,7 @@ - `data-testid` 名称必须描述用户或稳定渲染边界,例如 `image-canvas-editor-snap-guide-vertical`;不要描述 React 私有 state 名称。 - 测试用 fixture 只包含本行为需要的字段。演化中的 payload 使用 `expect.objectContaining(...)` 或 helper 生成默认对象,避免一处契约加字段导致大量无关用例碎裂。 - 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。 +- 测试 React effect 中注册的事件监听时,触发事件前先用可观测的 listener 调用确认注册已完成;异步请求已开始应用有界 `waitFor` 断言确认,不要用无界手工 Promise 等待一次性信号。解除挂起请求时将 Promise 收尾纳入异步 `act`,确保后续 React 更新在断言前已冲刷。 ## 试点调整