From de4dfe85383be1c1583f0b3cb5bdf9cf9d927426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 18:42:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=8E=86=E5=8F=B2=E6=94=B9?= =?UTF-8?q?=E6=8C=89=E8=AE=A2=E9=98=85=E5=9B=9E=E6=89=A7=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E6=89=A7=E5=88=B0=E8=BE=BE=E5=89=8D=E4=B8=8D?= =?UTF-8?q?=E8=AF=BB=E9=A6=96=E5=B1=8F=20=E9=94=9A=E7=82=B9=E9=97=B8?= =?UTF-8?q?=E9=97=A8=E6=8A=BD=E5=88=B0=20directHistoryAnchorGate=EF=BC=9A?= =?UTF-8?q?=E5=BC=80=E9=97=B8=E3=80=81=E5=90=8C=E9=A1=B9=E7=9B=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E3=80=81=E6=B6=88=E8=B4=B9=E5=90=8E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E4=B8=89=E4=BB=B6=E4=BA=8B=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=E5=AE=9E=E7=8E=B0=20App=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=20effect=20=E5=A4=8D=E7=94=A8=E9=A6=96=E5=B1=8F=E5=BC=80?= =?UTF-8?q?=E5=A5=BD=E7=9A=84=E9=97=B8=E9=97=A8=EF=BC=8Csuccess=20?= =?UTF-8?q?=E6=97=B6=20settle=20=E6=88=90=E5=9B=9E=E6=89=A7=E7=9A=84=20las?= =?UTF-8?q?tCompletedItemId=EF=BC=8C=E8=AE=A2=E9=98=85=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8/=E5=A4=B1=E8=B4=A5/=E5=88=87=E9=A1=B9=E7=9B=AE/?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=97=B6=20settle=20=E6=88=90=20null=20?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=88=87=E7=89=87=E5=8F=AA=E5=9C=A8=E6=96=B0?= =?UTF-8?q?=E7=AB=AF=E8=BE=B9=E7=95=8C=20throughItemId=20=E6=9C=89?= =?UTF-8?q?=E5=80=BC=E6=97=B6=E5=B8=A6=E4=B8=8A=E5=AE=83=EF=BC=8C=E9=9D=9E?= =?UTF-8?q?=20replace=20=E6=A8=A1=E5=BC=8F=E6=89=8D=E7=AD=89=E9=97=B8?= =?UTF-8?q?=E9=97=A8=EF=BC=8C/history=20=E6=89=8B=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=AF=BB=E4=BB=8D=E6=8C=89=E6=96=87=E4=BB=B6=E5=B0=BE=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20harness=20=E7=9A=84=E5=88=87=E7=89=87?= =?UTF-8?q?=E6=A1=A9=E6=94=AF=E6=8C=81=20throughItemId=EF=BC=8CsetDirectTh?= =?UTF-8?q?readHistory=20=E5=90=8C=E6=97=B6=E5=90=8C=E6=AD=A5=E5=9B=9E?= =?UTF-8?q?=E6=89=A7=E7=94=A8=E7=9A=84=20lastCompletedItemId=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20directHistoryAnchorGate=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=A4=8D=E7=94=A8/=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E8=A7=84=E5=88=99=EF=BC=8CApp=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80=E9=A6=96=E5=B1=8F?= =?UTF-8?q?=E9=94=9A=E5=9C=A8=E5=9B=9E=E6=89=A7=E9=82=A3=E6=9D=A1=E3=80=81?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E5=85=88=E4=BA=8E=E9=A6=96=E5=B1=8F=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 47 +++++++- .../directHistoryAnchorGate.ts | 59 ++++++++++ .../tests/appSurface/harness.ts | 30 ++++- .../appSurface/project-development.suite.ts | 101 ++++++++++++++++ .../tests/directHistoryAnchorGate.test.ts | 108 ++++++++++++++++++ 5 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts create mode 100644 apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 476d760e9..b8ad876d8 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -232,6 +232,11 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; 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 { applyDirectThreadConsumeResult, @@ -1472,6 +1477,9 @@ export function App({ const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); const directHistoryLoadingRef = useRef(false); + const directHistoryAnchorGateRef = useRef( + null, + ); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -1827,7 +1835,15 @@ export function App({ } const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); + // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 + const anchorGate = reuseOrOpenDirectHistoryAnchorGate( + directHistoryAnchorGateRef.current, + projectPath ?? '', + ); + directHistoryAnchorGateRef.current = anchorGate; if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 + anchorGate.settle(null); return; } let disposed = false; @@ -1844,8 +1860,13 @@ export function App({ 'subscribe_direct_project_thread', { projectPath }, ); - if (disposed) return; + if (disposed) { + anchorGate.settle(null); + return; + } subscriptionId = result.subscriptionId; + // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 + anchorGate.settle(result.lastCompletedItemId ?? null); setDirectThreadChat((state) => resolveDirectThreadBootstrap(state, result), ); @@ -1906,7 +1927,8 @@ export function App({ cleanup = unlisten; await bootstrap(); } catch { - // 历史仍可使用;订阅失败不伪造忙碌态。 + // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 + anchorGate.settle(null); } }; // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 @@ -1915,6 +1937,8 @@ export function App({ return () => { disposed = true; cleanup?.(); + // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 + anchorGate.settle(null); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -3500,6 +3524,22 @@ export function App({ // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 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 ? (() => { // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 @@ -3519,6 +3559,9 @@ export function App({ } : { projectPath: nextProjectPath, + ...(directHistoryThroughItemId + ? { throughItemId: directHistoryThroughItemId } + : {}), limit: CONVERSATION_INITIAL_VISIBLE_COUNT, }, ), diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts new file mode 100644 index 000000000..ca67e196e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts @@ -0,0 +1,59 @@ +/** + * 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。 + * + * 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只 + * 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它, + * 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。 + * + * 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在 + * 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。 + * 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。 + */ + +export type DirectHistoryAnchorGate = { + projectPath: string; + anchor: Promise; + 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((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; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 2eebd7640..98436f59b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -1023,19 +1023,34 @@ function createProjectSupervisorRuntimeHarness({ return { events }; } if (command === 'read_direct_project_history_slice') { - // 生产口径:从文件尾反向取一屏可显示条目,锚点条目本身不进窗口;收满一屏之后 - // 再见一条才算 `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, + // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 + // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 const requestedLimit = Number(args?.limit ?? 20); const limit = Math.min( Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), 200, ); + const throughItemId = + typeof args?.throughItemId === 'string' && args.throughItemId + ? args.throughItemId + : null; const beforeItemId = typeof args?.beforeItemId === 'string' && args.beforeItemId ? args.beforeItemId : null; 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( (item) => String(item.itemId ?? '') === beforeItemId, ); @@ -1433,6 +1448,15 @@ function createProjectSupervisorRuntimeHarness({ }, setDirectThreadHistory(items: Array>) { 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; }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 6eddb981e..fd969ffef 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8338,6 +8338,8 @@ export function registerProjectSupervisorSurfaceTests() { // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + // 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。 + throughItemId: 'direct-codex:turn-persisted:assistant', limit: 20, }); expect( @@ -8434,6 +8436,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + throughItemId: 'direct-codex:turn-历史对话 26:user', 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) => { + 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 () => { // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 @@ -8516,6 +8616,7 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + throughItemId: 'process-50', limit: 20, }), ); diff --git a/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts new file mode 100644 index 000000000..524af7152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts @@ -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); + }); +});