From 77401adfe8d1d58642ee33bb87b0dd9ad9f8aad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:52:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=97=A9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=8A=A0=E8=BD=BD=E8=A2=AB=E6=97=A7=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8F=90=E5=89=8D=E5=85=B3=E9=97=AD=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadEarlierHistory 的 finally 补上项目归属守卫,旧项目在飞的读取落地时不再清掉新项目的加载态与并发闸门 - 新增控制器回归用例:切项目后旧读取落地,新项目的加载态保持到它自己收口 Co-authored-by: Junie --- .../useDirectProjectChatController.test.tsx | 131 ++++++++++++++++++ .../useDirectProjectChatController.ts | 9 +- 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx new file mode 100644 index 000000000..7aa8e06fc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx @@ -0,0 +1,131 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { HistorySlice } from '../generated/HistorySlice'; +import type { ThreadItem } from '../generated/ThreadItem'; +import { useDirectProjectChatController } from './useDirectProjectChatController'; + +type SliceRequest = { + projectPath: string; + beforeItemId: string | undefined; + resolve: (slice: HistorySlice) => void; +}; + +function userItem(itemId: string): ThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function slice( + items: ThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): HistorySlice { + return { items, hasMore, firstItemId }; +} + +/** + * 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地。 + * + * `resolveTauriInvoke` 只读 `window.__TAURI__.core.invoke`,这里就是控制器唯一的外部接缝。 + */ +function installDeferredHistoryInvoke() { + const requests: SliceRequest[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'read_direct_project_history_slice') return undefined; + return new Promise((resolve) => { + requests.push({ + projectPath: String(args?.projectPath ?? ''), + beforeItemId: args?.beforeItemId as string | undefined, + resolve, + }); + }); + }, + ); + window.__TAURI__ = { core: { invoke } }; + return { + /** 取出一条挂起的读取;没有就等到出现为止。 */ + async take( + projectPath: string, + beforeItemId?: string, + ): Promise { + let found: SliceRequest | undefined; + await waitFor(() => { + found = requests.find( + (item) => + item.projectPath === projectPath && + item.beforeItemId === beforeItemId, + ); + expect(found).toBeDefined(); + }); + return requests.splice(requests.indexOf(found!), 1)[0]!; + }, + }; +} + +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +describe('更早历史加载态的项目归属', () => { + it('切项目后旧项目在飞的读取才落地,不能清掉新项目正在进行的加载态', async () => { + const tauri = installDeferredHistoryInvoke(); + const allowed = async () => true; + const { result, rerender } = renderHook( + ({ projectPath }: { projectPath: string }) => + useDirectProjectChatController({ + enabled: true, + ensureConversationReadAllowed: allowed, + ensureConversationWriteAllowed: allowed, + onRuntimeError: () => undefined, + projectId: 'project-1', + projectPath, + refreshManifest: () => undefined, + }), + { initialProps: { projectPath: '/projects/a' } }, + ); + + // 首屏:/a 的第一页落地,拿到「还有更早」。 + const firstScreenA = await tauri.take('/projects/a'); + await act(async () => { + firstScreenA.resolve(slice([userItem('a2')], true, 'a2')); + }); + await waitFor(() => expect(result.current.historyHasMore).toBe(true)); + + // 用户滚到顶:/a 的更早历史读取挂起。 + await act(async () => { + void result.current.loadEarlierHistory(); + }); + const staleRead = await tauri.take('/projects/a', 'a2'); + expect(result.current.historyLoading).toBe(true); + + // 切到 /b:控制器重置加载态并重新拉首屏。 + rerender({ projectPath: '/projects/b' }); + const firstScreenB = await tauri.take('/projects/b'); + await act(async () => { + firstScreenB.resolve(slice([userItem('b2')], true, 'b2')); + }); + await waitFor(() => expect(result.current.historyHasMore).toBe(true)); + + // /b 也发起更早历史的读取:加载态再次打开。 + await act(async () => { + void result.current.loadEarlierHistory(); + }); + const currentRead = await tauri.take('/projects/b', 'b2'); + expect(result.current.historyLoading).toBe(true); + + // 旧项目的读取此刻才落地:它属于 /a,不能清掉 /b 的加载态。 + await act(async () => { + staleRead.resolve(slice([userItem('a1')], true, 'a1')); + }); + expect(result.current.historyLoading).toBe(true); + + // /b 自己的读取落地后,加载态由它自己收口。 + await act(async () => { + currentRead.resolve(slice([userItem('b1')], false, 'b1')); + }); + expect(result.current.historyLoading).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 7f9bc87d5..9662f80f2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -711,8 +711,13 @@ export function useDirectProjectChatController({ historyErrorRef.current = message; setHistoryError(message); } finally { - historyLoadingRef.current = false; - setHistoryLoading(false); + // 与成功 / 失败路径同样按项目守卫:切项目后在飞的旧读取落地时,不能关掉新项目自己那次 + // 加载的加载态——否则加载行提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开, + // 同一个项目上就能再并起第二次读取。 + if (projectPathRef.current === nextProjectPath) { + historyLoadingRef.current = false; + setHistoryLoading(false); + } } }