修复更早历史加载被旧项目读取提前关闭加载态
- loadEarlierHistory 的 finally 补上项目归属守卫,旧项目在飞的读取落地时不再清掉新项目的加载态与并发闸门 - 新增控制器回归用例:切项目后旧读取落地,新项目的加载态保持到它自己收口 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+131
@@ -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<string, unknown>) => {
|
||||
if (command !== 'read_direct_project_history_slice') return undefined;
|
||||
return new Promise<HistorySlice>((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<SliceRequest> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+7
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user