// 方向(FV0)逻辑测试:两种方向**共用同一套布局代码**(flow/layout.ts 的 `FlowOrientation`), // 这里钉住四条不能退让的事实: // ① 时序朝向 —— `newest-top` 最新的节点在**最上**,`newest-left` 最新的在**最左**; // ② 画布随图生长且**不裁切** —— 每个节点、每个泳道框都落在画布尺寸内; // ③ 连线正确性 —— 每条边仍连在**正确的两个节点**之间(逐边核对端点 id 与像素锚点), // 端点缺一的边如实丢弃、不伪造; // ④ 泳道仍成道 —— 泳道框罩住自己的成员,且落在主干**更新的一侧**。 // // 跑法(同 lanes.test.ts,用 vite 自带的 esbuild,不加依赖): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/flow/orientation.test.ts \ // --bundle --platform=node --format=esm \ // --outfile=/tmp/cyrene-flow-orientation.test.mjs \ // && node /tmp/cyrene-flow-orientation.test.mjs // // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import { GAP_X, GAP_Y, NODE_H, NODE_W, PAD, layoutFlow, type FlowLayout } from "./layout"; import { edgeShapes } from "./edges"; import type { FlowEdge, FlowGraph, FlowNode } 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}`); } function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode { return { id, label: `节点-${id}`, kind, status }; } function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph { return { id: "web-fv0", nodes, edges, sessionId: "web" }; } /** 一条「输入 → 思考 → 工具 → 回复」的主干链(4 拍,无泳道)。 */ function chain(extra = 0): FlowGraph { const nodes = [ node("input", "input", "done"), node("llm1", "llm", "done"), node("tool1", "tool", "done"), node("output", "output", "done"), ]; const edges: FlowEdge[] = [ { from: "input", to: "llm1" }, { from: "llm1", to: "tool1" }, { from: "tool1", to: "output" }, ]; for (let i = 0; i < extra; i += 1) { const prev = nodes[nodes.length - 1].id; const id = `output${i + 2}`; nodes.push(node(id, "output", "done")); edges.push({ from: prev, to: id }); } return graph(nodes, edges); } /** 主干 + 一条并行子代理泳道(泳道的每一步都连在泳道根之后)。 */ function withLane(): FlowGraph { return graph( [ node("input", "input", "done"), node("t1", "tool", "done"), node("subA", "subagent", "running"), node("llmA", "llm", "running"), node("toolA", "tool", "done"), node("output", "output", "done"), ], [ { from: "input", to: "t1" }, { from: "t1", to: "subA" }, { from: "subA", to: "llmA" }, { from: "llmA", to: "toolA" }, { from: "t1", to: "output" }, ], ); } /** 泳道只开了头、还没有步骤(实时图上很常见)——泳道框不能被画到画布外。 */ function idleLane(): FlowGraph { return graph( [node("input", "input", "done"), node("subA", "subagent", "running")], [{ from: "input", to: "subA" }], ); } function at(layout: FlowLayout): Map { return new Map(layout.nodes.map((p) => [p.node.id, { x: p.x, y: p.y }])); } /** fits asserts every card / band frame lies inside the canvas (nothing is clipped). */ function fits(layout: FlowLayout, what: string): void { layout.nodes.forEach((p) => { ok( p.x >= 0 && p.y >= 0 && p.x + NODE_W <= layout.width && p.y + NODE_H <= layout.height, `${what}: 节点 ${p.node.id} 必须整块落在画布内(不裁切)`, ); }); layout.bands.forEach((b) => { ok( b.x >= 0 && b.y >= 0 && b.x + b.width <= layout.width && b.y + b.height <= layout.height, `${what}: 泳道框 ${b.id ?? "主干"} 必须整块落在画布内(不裁切)`, ); }); } /** curve parses a cubic path back into its two anchors and two control points. */ function curve(d: string): { sx: number; sy: number; c1x: number; c1y: number; c2x: number; c2y: number; tx: number; ty: number; } { const [head, rest] = d.replace("M ", "").split(" C "); const [sx, sy] = head.split(" ").map(Number); const [c1, c2, tail] = rest.split(", "); const [c1x, c1y] = c1.split(" ").map(Number); const [c2x, c2y] = c2.split(" ").map(Number); const [tx, ty] = tail.split(" ").map(Number); return { sx, sy, c1x, c1y, c2x, c2y, tx, ty }; } // ── ① 时序方向:newest-top 最新在上;newest-left 最新在左 ────────────────────── { const g = chain(); const top = layoutFlow(g, "newest-top"); const left = layoutFlow(g, "newest-left"); same(top.orientation, "newest-top", "newest-top 布局回传自己的方向"); same(left.orientation, "newest-left", "newest-left 布局回传自己的方向"); // 两种方向画的是同一张图:节点集合/数量/顺序分毫不差(不丢节点、不造节点)。 same( top.nodes.map((p) => p.node.id), left.nodes.map((p) => p.node.id), "方向不改变节点集合与顺序", ); same(top.nodes.length, g.nodes.length, "方向不丢节点"); same(left.bands.length, top.bands.length, "方向不改变泳道/主干的分带"); const t = at(top); same( [t.get("input")!.y, t.get("llm1")!.y, t.get("tool1")!.y, t.get("output")!.y], [PAD + 3 * (NODE_H + GAP_Y), PAD + 2 * (NODE_H + GAP_Y), PAD + (NODE_H + GAP_Y), PAD], "newest-top:时序自下而上,最新的一拍贴画布上沿", ); ok( t.get("input")!.y > t.get("llm1")!.y && t.get("llm1")!.y > t.get("tool1")!.y && t.get("tool1")!.y > t.get("output")!.y, "newest-top:越新越靠上(最新一定在最上方)", ); same( [t.get("input")!.x, t.get("llm1")!.x, t.get("tool1")!.x, t.get("output")!.x], [PAD, PAD, PAD, PAD], "newest-top:单拍主干占一列(横向不铺开)", ); same( [top.width, top.height], [PAD * 2 + NODE_W, PAD * 2 + 4 * NODE_H + 3 * GAP_Y], "newest-top:画布 = 单列宽 × 四拍高(容器纵向自动拓高)", ); const l = at(left); same( [l.get("input")!.x, l.get("llm1")!.x, l.get("tool1")!.x, l.get("output")!.x], [PAD + 3 * (NODE_W + GAP_X), PAD + 2 * (NODE_W + GAP_X), PAD + (NODE_W + GAP_X), PAD], "newest-left:时序自右向左,最新的一拍贴画布左沿", ); ok( l.get("input")!.x > l.get("llm1")!.x && l.get("llm1")!.x > l.get("tool1")!.x && l.get("tool1")!.x > l.get("output")!.x, "newest-left:越新越靠左(容器默认露出最左边 ⇒ 先看到最新)", ); same( [l.get("input")!.y, l.get("llm1")!.y, l.get("tool1")!.y, l.get("output")!.y], [PAD, PAD, PAD, PAD], "newest-left:单拍主干占一行(保持横向渲染方式)", ); same( [left.width, left.height], [PAD * 2 + 4 * NODE_W + 3 * GAP_X, PAD * 2 + NODE_H], "newest-left:画布 = 四拍宽 × 单行高", ); fits(top, "newest-top 主干"); fits(left, "newest-left 主干"); } // ── ② 容器随图生长:多一拍就多一段高度,且永远装得下 ───────────────────────── { const four = layoutFlow(chain(), "newest-top"); const five = layoutFlow(chain(1), "newest-top"); same( five.height - four.height, NODE_H + GAP_Y, "newest-top:多一拍 ⇒ 画布高一个「节点高 + 行距」,不挤在固定框里", ); same(five.width, four.width, "newest-top:多一拍不改变横向尺寸(主干是一条竖列)"); fits(five, "newest-top 五拍"); fits(layoutFlow(chain(1), "newest-left"), "newest-left 五拍"); // 只开了头、还没有步骤的泳道:框也必须落在画布内(旧代码会把它画出画布右沿)。 fits(layoutFlow(idleLane(), "newest-top"), "newest-top 空泳道"); fits(layoutFlow(idleLane(), "newest-left"), "newest-left 空泳道"); } // ── ③ 连线正确性:逐边核对端点 id 与像素锚点(两个方向都核对) ─────────────── { const g = withLane(); (["newest-top", "newest-left"] as const).forEach((orientation) => { const layout = layoutFlow(g, orientation); const placed = at(layout); const shapes = edgeShapes(layout); same( shapes.map((s) => `${s.fromId}>${s.toId}`), g.edges.map((e) => `${e.from}>${e.to}`), `${orientation}:每条边都画出来,且顺序与图数据一致(不丢边、不重排)`, ); shapes.forEach((s) => { const from = placed.get(s.fromId); const to = placed.get(s.toId); ok(from !== undefined && to !== undefined, `${orientation}:边 ${s.fromId}>${s.toId} 的端点都在图里`); const c = curve(s.d); same([c.sx, c.sy], [s.sx, s.sy], `${orientation}:path 起点 = 声明起点`); if (orientation === "newest-top") { // 更早那拍在上方节点的**下沿**,更新那拍从**下沿**进 —— 箭头指向时间前进的方向。 same( [c.sx, c.sy], [from!.x + NODE_W / 2, from!.y], `${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的上边中点`, ); same( [c.tx, c.ty], [to!.x + NODE_W / 2, to!.y + NODE_H], `${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的下边中点`, ); ok(c.c1y < c.sy && c.c2y > c.ty, `${orientation}:箭头自下而上,指向更新的节点`); } else { same( [c.sx, c.sy], [from!.x, from!.y + NODE_H / 2], `${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的左边中点`, ); same( [c.tx, c.ty], [to!.x + NODE_W, to!.y + NODE_H / 2], `${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的右边中点`, ); ok(c.c1x < c.sx && c.c2x > c.tx, `${orientation}:箭头自右向左,指向更新的节点`); } }); // 泳道内的边仍然标成泳道边(虚线 + accent),主干边仍是实线。 const byKey = new Map(shapes.map((s) => [`${s.fromId}>${s.toId}`, s.inLane])); same(byKey.get("subA>llmA"), true, `${orientation}:泳道内步骤仍是泳道边`); same(byKey.get("t1>output"), false, `${orientation}:主干续接仍是主干边`); }); // 端点缺失的边如实丢弃:3 条边里 1 条悬空 ⇒ 只画 2 条(不伪造节点补上)。 const dangling = graph( [node("a", "llm", "done"), node("b", "output", "done")], [ { from: "a", to: "ghost" }, { from: "a", to: "b" }, ], ); same( edgeShapes(layoutFlow(dangling, "newest-top")).map((s) => `${s.fromId}>${s.toId}`), ["a>b"], "端点缺失的边被丢掉,不伪造", ); } // ── ④ 泳道仍成道:泳道框罩住自己的成员,且落在主干更新的一侧 ───────────────── { const g = withLane(); const top = layoutFlow(g, "newest-top"); same(top.lanes.map((l) => l.id), ["subA"], "子代理各自成道"); const framed = top.bands.filter((b) => b.framed); same(framed.length, 1, "泳道有独立的框"); const t = at(top); const frame = framed[0]; ok(frame.x > t.get("input")!.x, "newest-top:泳道在主干右侧(时序轴之外并排),不挤占时序轴"); ["llmA", "toolA"].forEach((id) => { const p = t.get(id)!; ok( p.x >= frame.x && p.x + NODE_W <= frame.x + frame.width && p.y >= frame.y && p.y + NODE_H <= frame.y + frame.height, `newest-top:${id} 落在自己的泳道框内(仍成道)`, ); }); ok(t.get("llmA")!.y < t.get("subA")!.y, "newest-top:泳道第一步比泳道根更新(更靠上)"); ok(t.get("toolA")!.y < t.get("llmA")!.y, "newest-top:泳道内链路继续向新的一端推进"); fits(top, "newest-top 带泳道"); const left = layoutFlow(g, "newest-left"); const l = at(left); const lane = left.bands.filter((b) => b.framed)[0]; ok(lane.y > l.get("input")!.y + NODE_H, "newest-left:泳道叠在主干下方,不与主干同拍混排"); ["llmA", "toolA"].forEach((id) => { const p = l.get(id)!; ok( p.x >= lane.x && p.x + NODE_W <= lane.x + lane.width && p.y >= lane.y && p.y + NODE_H <= lane.y + lane.height, `newest-left:${id} 落在自己的泳道框内(仍成道)`, ); }); ok(l.get("llmA")!.x < l.get("subA")!.x, "newest-left:泳道第一步比泳道根更新(更靠左)"); fits(left, "newest-left 带泳道"); // 没有两个节点落在同一个格子上(方向镜像不能让卡片重叠)。 ([top, left] as const).forEach((layout) => { const slots = new Set(layout.nodes.map((p) => `${p.x},${p.y}`)); same(slots.size, layout.nodes.length, "每个节点都有自己的格子(不重叠)"); }); } console.log(`flow/orientation.test.ts OK — ${checks} checks`);