首屏历史改按订阅回执锚定,回执到达前不读首屏
锚点闸门抽到 directHistoryAnchorGate:开闸、同项目复用、消费后不再等待三件事只有一份实现 App 订阅 effect 复用首屏开好的闸门,success 时 settle 成回执的 lastCompletedItemId,订阅不可用/失败/切项目/清理时 settle 成 null 首屏切片只在新端边界 throughItemId 有值时带上它,非 replace 模式才等闸门,/history 手动重读仍按文件尾 测试 harness 的切片桩支持 throughItemId,setDirectThreadHistory 同时同步回执用的 lastCompletedItemId 新增 directHistoryAnchorGate 单测覆盖复用/消费回退规则,App 集成用例断言首屏锚在回执那条、回执先于首屏读取
This commit is contained in:
@@ -232,6 +232,11 @@ import {
|
|||||||
} from './features/project-workspace/chatComposerQueue';
|
} from './features/project-workspace/chatComposerQueue';
|
||||||
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
|
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
|
||||||
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
||||||
|
import {
|
||||||
|
type DirectHistoryAnchorGate,
|
||||||
|
directHistoryAnchorGateToWaitFor,
|
||||||
|
reuseOrOpenDirectHistoryAnchorGate,
|
||||||
|
} from './features/project-workspace/directHistoryAnchorGate';
|
||||||
import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging';
|
import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging';
|
||||||
import {
|
import {
|
||||||
applyDirectThreadConsumeResult,
|
applyDirectThreadConsumeResult,
|
||||||
@@ -1472,6 +1477,9 @@ export function App({
|
|||||||
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
||||||
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
||||||
const directHistoryLoadingRef = useRef(false);
|
const directHistoryLoadingRef = useRef(false);
|
||||||
|
const directHistoryAnchorGateRef = useRef<DirectHistoryAnchorGate | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -1827,7 +1835,15 @@ export function App({
|
|||||||
}
|
}
|
||||||
const projectPath = localProject?.projectPath ?? null;
|
const projectPath = localProject?.projectPath ?? null;
|
||||||
const directInvoke = resolveTauriInvoke();
|
const directInvoke = resolveTauriInvoke();
|
||||||
|
// 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。
|
||||||
|
const anchorGate = reuseOrOpenDirectHistoryAnchorGate(
|
||||||
|
directHistoryAnchorGateRef.current,
|
||||||
|
projectPath ?? '',
|
||||||
|
);
|
||||||
|
directHistoryAnchorGateRef.current = anchorGate;
|
||||||
if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) {
|
if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) {
|
||||||
|
// 订阅不可用:首屏退化成"取文件尾",不阻塞加载。
|
||||||
|
anchorGate.settle(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1844,8 +1860,13 @@ export function App({
|
|||||||
'subscribe_direct_project_thread',
|
'subscribe_direct_project_thread',
|
||||||
{ projectPath },
|
{ projectPath },
|
||||||
);
|
);
|
||||||
if (disposed) return;
|
if (disposed) {
|
||||||
|
anchorGate.settle(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
subscriptionId = result.subscriptionId;
|
subscriptionId = result.subscriptionId;
|
||||||
|
// 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。
|
||||||
|
anchorGate.settle(result.lastCompletedItemId ?? null);
|
||||||
setDirectThreadChat((state) =>
|
setDirectThreadChat((state) =>
|
||||||
resolveDirectThreadBootstrap(state, result),
|
resolveDirectThreadBootstrap(state, result),
|
||||||
);
|
);
|
||||||
@@ -1906,7 +1927,8 @@ export function App({
|
|||||||
cleanup = unlisten;
|
cleanup = unlisten;
|
||||||
await bootstrap();
|
await bootstrap();
|
||||||
} catch {
|
} catch {
|
||||||
// 历史仍可使用;订阅失败不伪造忙碌态。
|
// 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。
|
||||||
|
anchorGate.settle(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 换项目就是换一份聊天:先回到初始态,再订阅新线程。
|
// 换项目就是换一份聊天:先回到初始态,再订阅新线程。
|
||||||
@@ -1915,6 +1937,8 @@ export function App({
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
|
// 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。
|
||||||
|
anchorGate.settle(null);
|
||||||
};
|
};
|
||||||
}, [directCodexProductRuntime, localProject?.projectPath]);
|
}, [directCodexProductRuntime, localProject?.projectPath]);
|
||||||
|
|
||||||
@@ -3500,6 +3524,22 @@ export function App({
|
|||||||
// 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer:
|
// 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer:
|
||||||
// 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。
|
// 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。
|
||||||
let loadedDirectHistoryItems: DirectThreadItem[] = [];
|
let loadedDirectHistoryItems: DirectThreadItem[] = [];
|
||||||
|
// 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目
|
||||||
|
// 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用
|
||||||
|
// 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目)
|
||||||
|
// 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。
|
||||||
|
let directHistoryThroughItemId: string | null = null;
|
||||||
|
if (directCodexProductRuntime && mode !== 'replace') {
|
||||||
|
const anchorGate = directHistoryAnchorGateToWaitFor(
|
||||||
|
directHistoryAnchorGateRef.current,
|
||||||
|
nextProjectPath,
|
||||||
|
);
|
||||||
|
if (anchorGate) {
|
||||||
|
directHistoryAnchorGateRef.current = anchorGate;
|
||||||
|
directHistoryThroughItemId = await anchorGate.anchor;
|
||||||
|
anchorGate.consumed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const projectConversation = directCodexProductRuntime
|
const projectConversation = directCodexProductRuntime
|
||||||
? (() => {
|
? (() => {
|
||||||
// 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染
|
// 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染
|
||||||
@@ -3519,6 +3559,9 @@ export function App({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
projectPath: nextProjectPath,
|
projectPath: nextProjectPath,
|
||||||
|
...(directHistoryThroughItemId
|
||||||
|
? { throughItemId: directHistoryThroughItemId }
|
||||||
|
: {}),
|
||||||
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
|
||||||
|
*
|
||||||
|
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
|
||||||
|
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
|
||||||
|
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
|
||||||
|
*
|
||||||
|
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
|
||||||
|
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
|
||||||
|
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DirectHistoryAnchorGate = {
|
||||||
|
projectPath: string;
|
||||||
|
anchor: Promise<string | null>;
|
||||||
|
settle: (anchor: string | null) => void;
|
||||||
|
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
|
||||||
|
consumed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
|
||||||
|
export function openDirectHistoryAnchorGate(
|
||||||
|
projectPath: string,
|
||||||
|
): DirectHistoryAnchorGate {
|
||||||
|
let settle: (anchor: string | null) => void = () => {};
|
||||||
|
const anchor = new Promise<string | null>((resolve) => {
|
||||||
|
settle = resolve;
|
||||||
|
});
|
||||||
|
return { projectPath, anchor, settle, consumed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
|
||||||
|
export function reuseOrOpenDirectHistoryAnchorGate(
|
||||||
|
current: DirectHistoryAnchorGate | null,
|
||||||
|
projectPath: string,
|
||||||
|
): DirectHistoryAnchorGate {
|
||||||
|
if (current && current.projectPath === projectPath) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return openDirectHistoryAnchorGate(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
|
||||||
|
*
|
||||||
|
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
|
||||||
|
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
|
||||||
|
*/
|
||||||
|
export function directHistoryAnchorGateToWaitFor(
|
||||||
|
current: DirectHistoryAnchorGate | null,
|
||||||
|
projectPath: string,
|
||||||
|
): DirectHistoryAnchorGate | null {
|
||||||
|
const sameProjectGate =
|
||||||
|
current && current.projectPath === projectPath ? current : null;
|
||||||
|
if (!sameProjectGate) {
|
||||||
|
return openDirectHistoryAnchorGate(projectPath);
|
||||||
|
}
|
||||||
|
return sameProjectGate.consumed ? null : sameProjectGate;
|
||||||
|
}
|
||||||
@@ -1023,19 +1023,34 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
return { events };
|
return { events };
|
||||||
}
|
}
|
||||||
if (command === 'read_direct_project_history_slice') {
|
if (command === 'read_direct_project_history_slice') {
|
||||||
// 生产口径:从文件尾反向取一屏可显示条目,锚点条目本身不进窗口;收满一屏之后
|
// 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条,
|
||||||
// 再见一条才算 `hasMore`,`firstItemId` 是本屏最老一条的 itemId。
|
// 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算
|
||||||
|
// `hasMore`,`firstItemId` 是本屏最老一条的 itemId。
|
||||||
const requestedLimit = Number(args?.limit ?? 20);
|
const requestedLimit = Number(args?.limit ?? 20);
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
|
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
|
const throughItemId =
|
||||||
|
typeof args?.throughItemId === 'string' && args.throughItemId
|
||||||
|
? args.throughItemId
|
||||||
|
: null;
|
||||||
const beforeItemId =
|
const beforeItemId =
|
||||||
typeof args?.beforeItemId === 'string' && args.beforeItemId
|
typeof args?.beforeItemId === 'string' && args.beforeItemId
|
||||||
? args.beforeItemId
|
? args.beforeItemId
|
||||||
: null;
|
: null;
|
||||||
let end = directThreadHistoryItems.length;
|
let end = directThreadHistoryItems.length;
|
||||||
if (beforeItemId) {
|
if (throughItemId) {
|
||||||
|
const anchorIndex = directThreadHistoryItems.findIndex(
|
||||||
|
(item) => String(item.itemId ?? '') === throughItemId,
|
||||||
|
);
|
||||||
|
if (anchorIndex < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`DirectProject 历史中不存在 item:${throughItemId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
end = anchorIndex + 1;
|
||||||
|
} else if (beforeItemId) {
|
||||||
const anchorIndex = directThreadHistoryItems.findIndex(
|
const anchorIndex = directThreadHistoryItems.findIndex(
|
||||||
(item) => String(item.itemId ?? '') === beforeItemId,
|
(item) => String(item.itemId ?? '') === beforeItemId,
|
||||||
);
|
);
|
||||||
@@ -1433,6 +1448,15 @@ function createProjectSupervisorRuntimeHarness({
|
|||||||
},
|
},
|
||||||
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
|
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
|
||||||
directThreadHistoryItems = [...items];
|
directThreadHistoryItems = [...items];
|
||||||
|
// 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示
|
||||||
|
// 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。
|
||||||
|
const newest = directThreadHistoryItems.at(-1);
|
||||||
|
directThreadLastCompletedItemId =
|
||||||
|
typeof newest?.itemId === 'string' ? newest.itemId : null;
|
||||||
|
},
|
||||||
|
/** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */
|
||||||
|
setDirectThreadLastCompletedItemId(itemId: string | null) {
|
||||||
|
directThreadLastCompletedItemId = itemId;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8338,6 +8338,8 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
// 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。
|
// 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。
|
||||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
projectPath,
|
projectPath,
|
||||||
|
// 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。
|
||||||
|
throughItemId: 'direct-codex:turn-persisted:assistant',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -8434,6 +8436,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull();
|
expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull();
|
||||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
projectPath,
|
projectPath,
|
||||||
|
throughItemId: 'direct-codex:turn-历史对话 26:user',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8452,6 +8455,103 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('anchors the first history page at the subscribe receipt instead of the file tail', async () => {
|
||||||
|
// 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才
|
||||||
|
// 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。
|
||||||
|
const projectPath = '/tmp/launcher-direct-history-anchor-game';
|
||||||
|
const manifest = createGameCreationAppManifest(
|
||||||
|
'local-project-draft',
|
||||||
|
'launcher-direct-history-anchor-game',
|
||||||
|
);
|
||||||
|
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
supervisorHarness.setDirectThreadHistory([
|
||||||
|
{
|
||||||
|
itemType: 'message',
|
||||||
|
itemId: 'turn-1-user',
|
||||||
|
role: 'user',
|
||||||
|
text: '历史对话 01',
|
||||||
|
at: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemType: 'message',
|
||||||
|
itemId: 'turn-2-user',
|
||||||
|
role: 'user',
|
||||||
|
text: '历史对话 02',
|
||||||
|
at: 1100,
|
||||||
|
},
|
||||||
|
// 订阅回执之后才落盘的两条:只应从运行态事件来。
|
||||||
|
{
|
||||||
|
itemType: 'function_call',
|
||||||
|
itemId: 'process-1',
|
||||||
|
name: 'exec_command',
|
||||||
|
arguments: 'npm run build',
|
||||||
|
at: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemType: 'message',
|
||||||
|
itemId: 'turn-3-user',
|
||||||
|
role: 'user',
|
||||||
|
text: '历史对话 03',
|
||||||
|
at: 1300,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user');
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
if (command === 'get_design_agent_runtime_mode') return null;
|
||||||
|
if (command === 'inspect_local_project_directory') {
|
||||||
|
return {
|
||||||
|
projectPath,
|
||||||
|
exists: true,
|
||||||
|
isDirectory: true,
|
||||||
|
isGameCreatorProject: true,
|
||||||
|
projectName: 'launcher-direct-history-anchor-game',
|
||||||
|
recentRunStatus: null,
|
||||||
|
recentRunStopReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === 'get_local_game_manifest') {
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
if (command === 'get_local_game_preview_status') {
|
||||||
|
return { status: 'stopped', url: null, port: null, root: null };
|
||||||
|
}
|
||||||
|
return supervisorHarness.invoke(command, args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: { invoke },
|
||||||
|
event: { listen: supervisorHarness.listen },
|
||||||
|
};
|
||||||
|
renderLauncherProjectsAt('/?launcher');
|
||||||
|
|
||||||
|
pickProjectFromLauncher(projectPath);
|
||||||
|
|
||||||
|
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
|
projectPath,
|
||||||
|
throughItemId: 'turn-2-user',
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。
|
||||||
|
expect(
|
||||||
|
await within(supervisorSurface).findByText('历史对话 02'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull();
|
||||||
|
// 锚点只能来自订阅回执:回执必须先于首屏读取发生。
|
||||||
|
const commands = invoke.mock.calls.map(([command]) => command);
|
||||||
|
expect(
|
||||||
|
commands.indexOf('subscribe_direct_project_thread'),
|
||||||
|
).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan(
|
||||||
|
commands.indexOf('read_direct_project_history_slice'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => {
|
it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => {
|
||||||
// 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里,
|
// 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里,
|
||||||
// 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。
|
// 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。
|
||||||
@@ -8516,6 +8616,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
projectPath,
|
projectPath,
|
||||||
|
throughItemId: 'process-50',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
directHistoryAnchorGateToWaitFor,
|
||||||
|
openDirectHistoryAnchorGate,
|
||||||
|
reuseOrOpenDirectHistoryAnchorGate,
|
||||||
|
} from '../src/features/project-workspace/directHistoryAnchorGate';
|
||||||
|
|
||||||
|
const projectA = '/tmp/项目A';
|
||||||
|
const projectB = '/tmp/项目B';
|
||||||
|
|
||||||
|
/** 订阅侧的回执:一次订阅只 settle 一次。 */
|
||||||
|
function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) {
|
||||||
|
gate.settle('turn-2-user');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('openDirectHistoryAnchorGate', () => {
|
||||||
|
it('默认不解析:回执到达前首屏必须等它', async () => {
|
||||||
|
const gate = openDirectHistoryAnchorGate(projectA);
|
||||||
|
const settled = { value: 'pending' as string | null | 'pending' };
|
||||||
|
void gate.anchor.then((anchor) => {
|
||||||
|
settled.value = anchor;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 让微任务跑一轮:没有回执就不该有结果。
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled.value).toBe('pending');
|
||||||
|
expect(gate.projectPath).toBe(projectA);
|
||||||
|
expect(gate.consumed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => {
|
||||||
|
const gate = openDirectHistoryAnchorGate(projectA);
|
||||||
|
subscribeReceipt(gate);
|
||||||
|
|
||||||
|
await expect(gate.anchor).resolves.toBe('turn-2-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => {
|
||||||
|
const gate = openDirectHistoryAnchorGate(projectA);
|
||||||
|
gate.settle(null);
|
||||||
|
|
||||||
|
await expect(gate.anchor).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reuseOrOpenDirectHistoryAnchorGate', () => {
|
||||||
|
it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => {
|
||||||
|
const opened = openDirectHistoryAnchorGate(projectA);
|
||||||
|
|
||||||
|
expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('换项目开一道新闸门,不复用旧项目的回执', () => {
|
||||||
|
const opened = openDirectHistoryAnchorGate(projectA);
|
||||||
|
const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB);
|
||||||
|
|
||||||
|
expect(next).not.toBe(opened);
|
||||||
|
expect(next.projectPath).toBe(projectB);
|
||||||
|
expect(next.consumed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('还没有闸门时新开一道', () => {
|
||||||
|
expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe(
|
||||||
|
projectA,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('directHistoryAnchorGateToWaitFor', () => {
|
||||||
|
it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => {
|
||||||
|
const gate = directHistoryAnchorGateToWaitFor(null, projectA);
|
||||||
|
|
||||||
|
expect(gate).not.toBeNull();
|
||||||
|
expect(gate?.projectPath).toBe(projectA);
|
||||||
|
subscribeReceipt(gate!);
|
||||||
|
await expect(gate!.anchor).resolves.toBe('turn-2-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => {
|
||||||
|
const opened = openDirectHistoryAnchorGate(projectA);
|
||||||
|
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA);
|
||||||
|
|
||||||
|
expect(toWaitFor).toBe(opened);
|
||||||
|
subscribeReceipt(opened);
|
||||||
|
await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => {
|
||||||
|
const opened = openDirectHistoryAnchorGate(projectA);
|
||||||
|
opened.consumed = true;
|
||||||
|
|
||||||
|
expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => {
|
||||||
|
const opened = openDirectHistoryAnchorGate(projectA);
|
||||||
|
subscribeReceipt(opened);
|
||||||
|
opened.consumed = true;
|
||||||
|
|
||||||
|
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB);
|
||||||
|
|
||||||
|
expect(toWaitFor).not.toBeNull();
|
||||||
|
expect(toWaitFor).not.toBe(opened);
|
||||||
|
expect(toWaitFor?.projectPath).toBe(projectB);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user