// Logic test for the **per-lane** flow buckets (切片 FV2): a real parallel turn // (`chat.parallel`) publishes one graph per 路 (leg), each carrying its own // `turnId`; the store must hold them as separate swimlanes instead of letting the // later frame overwrite the earlier one (the reported symptom: 「一路的节点长在另 // 一路的图上」, design/15 §2.5.10-1). // // It drives the real frame decoder (`dispatchFrame` in lib/ws.ts) and the real // store, and throws on the first failure. The project has no frontend test runner // and no jsdom, so this is a dependency-free plain-node check; the banner supplies // the `window.localStorage` the store's persist middleware probes (absent under node): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/store/flowLanes.test.ts \ // --bundle --platform=node --format=esm \ // --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ // --outfile=/tmp/cyrene-flow-lanes.test.mjs \ // && node /tmp/cyrene-flow-lanes.test.mjs // // `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it // imports nothing from node:*. import { dispatchFrame } from "../lib/ws"; import { useAppStore } from "./useAppStore"; import { MAIN_SESSION_ID, MAX_LANES } from "./sessionCache"; import { flowPageData } from "../admin/modules/flow/flowSession"; import type { FlowGraph, SessionMeta } from "../lib/types"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: T, want: T, what: string): void { const g = JSON.stringify(got); const w = JSON.stringify(want); ok(g === w, `${what} — got ${g}, want ${w}`); } /** 一帧 `graph_update`(真后端发的形状:图自带 sessionId / turnId / round)。 */ function laneFrame(id: string, turnId: string | undefined, round: number | undefined, label: string): void { dispatchFrame({ type: "graph_update", data: { id, sessionId: MAIN_SESSION_ID, ...(turnId === undefined ? {} : { turnId }), ...(round === undefined ? {} : { round }), nodes: [{ id: "input", label, kind: "input", status: "done" }], edges: [], }, }); } /** 当前会话的路桶(最旧的在前)。 */ function lanesOf(sessionId: string): FlowGraph[] { return useAppStore.getState().flowLanes[sessionId] ?? []; } // ① 两路帧**交错**到达 ⇒ 两条泳道,而不是一条(这是本切片的核心断言)。 { laneFrame("web-1#turn-a", "turn-a", 1, "标记A"); laneFrame("web-2#turn-b", "turn-b", 1, "标记B"); // 两路各自继续推进(同一个 turnId 再发一帧):只能就地更新,绝不能新开一格。 laneFrame("web-1#turn-a", "turn-a", 1, "标记A(更新)"); laneFrame("web-2#turn-b", "turn-b", 1, "标记B(更新)"); same( lanesOf(MAIN_SESSION_ID).map((g) => g.turnId), ["turn-a", "turn-b"], "两条路各占一格(交错到达也绝不互相覆盖)", ); same( lanesOf(MAIN_SESSION_ID).map((g) => g.nodes[0]?.label), ["标记A(更新)", "标记B(更新)"], "同一条路的后续帧就地更新自己那一格", ); same( useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id, "web-2#turn-b", "兼容字段(会话桶)仍是最近那一张 —— 老读法不受影响", ); } // ② 新一轮(round 更大)到达 ⇒ 上一轮的路退场;单路(无 turnId)也只占一格。 { laneFrame("web-5", undefined, 2, "单路的下一轮"); same( lanesOf(MAIN_SESSION_ID).map((g) => g.turnId ?? ""), [""], "新一轮开始后只留本轮的路(上一轮的两条路已退场)", ); laneFrame("web-9", undefined, undefined, "旧后端的帧(无 round)"); same( lanesOf(MAIN_SESSION_ID).map((g) => g.id), ["web-9"], "无轮次号 / 无路标识的旧帧按空路键就地更新,绝不多开一格", ); } // ③ 路数有硬上限:绝不随帧无界增长。 { for (let i = 0; i < MAX_LANES + 3; i += 1) { laneFrame(`web-x${i}`, `turn-x${i}`, 3, `路${i}`); } same( lanesOf(MAIN_SESSION_ID).length, MAX_LANES, "路桶有硬上限(不随并行路数无界增长)", ); same( lanesOf(MAIN_SESSION_ID).map((g) => g.turnId), ["turn-x3", "turn-x4", "turn-x5", "turn-x6"], "超限时丢掉最旧的路", ); } // ④ Flow 页取数:≥2 路时 `flowPageData` 交出每一路;只有单张图时回落到那一条。 { const sessions: Record = {}; const page = flowPageData( sessions, useAppStore.getState().flowGraphs, MAIN_SESSION_ID, useAppStore.getState().flowLanes, ); same(page.sessionId, MAIN_SESSION_ID, "取数会话 = 选中会话"); same(page.lanes.length, MAX_LANES, "页面拿到本轮每一路(供并列泳道渲染)"); const single: Record = { [MAIN_SESSION_ID]: { id: "web-1", nodes: [], edges: [], sessionId: MAIN_SESSION_ID }, }; const fallback = flowPageData(sessions, single, MAIN_SESSION_ID, {}); same( fallback.lanes.map((g) => g.id), ["web-1"], "路桶为空时回落为那一张图(单路 / 旧后端 / 快照补拉前)", ); } // ⑤ 会话隔离:另一会话的路桶不会被本会话的帧写脏(裁决 16.4.2 的口径不变)。 { dispatchFrame({ type: "graph_update", data: { id: "sess-backthink-1", sessionId: "sess-backthink", turnId: "turn-bt", round: 1, nodes: [], edges: [], }, }); same( lanesOf("sess-backthink").map((g) => g.turnId), ["turn-bt"], "别的会话的路进它自己的桶", ); ok( lanesOf(MAIN_SESSION_ID).every((g) => g.turnId !== "turn-bt"), "别的会话的路绝不混进本会话", ); } console.log(`flow lanes logic test: ${checks} checks passed`);