Files
Cyrene_v2/frontend/web/src/admin/modules/flow/orientation.test.ts
T
AskaEth 14e76b6480
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。

- 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动
- 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃
- FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字
- 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改)
- 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外)
- 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」
- 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
2026-10-05 19:51:45 +08:00

338 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 方向(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<T>(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<string, { x: number; y: number }> {
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`);