Files
AgentLab/backend/tests/test_layout.py
T
ThearchyHelios 75be5f485e 画布上的线不再叠在一起:排版按车道留走廊,走线不再交给 smoothstep
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。

后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。

前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。

顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。

新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。

285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
2026-09-24 22:58:54 +08:00

181 lines
6.8 KiB
Python
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.
"""自动排版。
以前只有"按最长路径分层、同层按 id 字典序竖排"两条规则:并行的分支谁上谁下
是随机的(`out_no` 排在 `out_yes` 上面,因为 "n" < "y"),层间距写死 300,
两个节点扇出到同一个目标时连线还会叠在一起。这些测试守的是重写之后的四条
不变量:并行同层、分支按语义顺序、同列不重叠、走廊按车道需求留宽。
"""
from __future__ import annotations
from app.engine.layout import NODE_W, auto_layout
from app.engine.schema import GraphSpec
def _graph(nodes, edges=()):
return GraphSpec.model_validate({
"nodes": [
{"id": n["id"], "type": n["type"], "position": {"x": 0, "y": 0},
"data": {"label": n.get("label", ""), "config": n.get("config", {})}}
for n in nodes
],
"edges": [
{"source": e[0], "target": e[1], **({"sourceHandle": e[2]} if len(e) > 2 else {})}
for e in edges
],
})
def _parallel_graph():
"""一个入口扇出三条并行的 agent,再汇进同一个节点。"""
return _graph(
[
{"id": "in", "type": "input"},
{"id": "a1", "type": "agent"},
{"id": "a2", "type": "agent"},
{"id": "a3", "type": "agent"},
{"id": "join", "type": "llm"},
],
[("in", "a1"), ("in", "a2"), ("in", "a3"), ("a1", "join"), ("a2", "join"), ("a3", "join")],
)
def test_parallel_branches_share_a_column() -> None:
"""并行的三条支路必须落在同一列:它们之间没有依赖,谁也不该排到谁后面。"""
spec = auto_layout(_parallel_graph())
xs = {n.id: n.position.x for n in spec.nodes}
assert xs["a1"] == xs["a2"] == xs["a3"]
assert xs["in"] < xs["a1"] < xs["join"]
def test_parallel_branches_are_stacked_without_overlap() -> None:
spec = auto_layout(_parallel_graph())
ys = sorted(n.position.y for n in spec.nodes if n.id.startswith("a"))
assert len(set(ys)) == 3
# 同列相邻节点的纵向间距要够一个节点高度,否则卡片会叠在一起
assert ys[1] - ys[0] >= 60
assert ys[2] - ys[1] >= 60
def test_branch_cases_keep_their_declared_order() -> None:
"""分支的 yes/no 是语义顺序,不能按 id 字典序排("no" < "yes" 会反过来)。"""
spec = _graph(
[
{"id": "gate", "type": "branch",
"config": {"cases": [{"key": "yes"}, {"key": "no"}]}},
{"id": "out_no", "type": "output"},
{"id": "out_yes", "type": "output"},
],
[("gate", "out_yes", "yes"), ("gate", "out_no", "no")],
)
laid = auto_layout(spec)
pos = {n.id: n.position for n in laid.nodes}
assert pos["out_yes"].y < pos["out_no"].y
assert pos["out_yes"].x == pos["out_no"].x
def test_loop_body_stays_right_of_the_loop_node() -> None:
"""回边不能被当成普通边参与分层——那样循环体会跑到循环节点左边去。"""
spec = _graph(
[
{"id": "start", "type": "input"},
{"id": "each", "type": "loop"},
{"id": "body", "type": "llm"},
{"id": "collect", "type": "transform"},
{"id": "done", "type": "output"},
],
[
("start", "each"),
("each", "body", "body"),
("body", "collect"),
("collect", "each"),
("each", "done", "done"),
],
)
laid = auto_layout(spec)
pos = {n.id: n.position for n in laid.nodes}
assert pos["start"].x < pos["each"].x < pos["body"].x < pos["collect"].x
# 循环体出口排在结束出口上面
assert pos["body"].y < pos["done"].y
def test_corridor_grows_with_lane_demand() -> None:
"""扇出的线越多,走廊越宽——宽度不够,前端的车道就铺不开,线会叠回去。"""
narrow = auto_layout(_parallel_graph())
wide = auto_layout(_graph(
[{"id": "in", "type": "input"}, {"id": "join", "type": "llm"}]
+ [{"id": f"m{i}", "type": "llm"} for i in range(8)],
[("in", f"m{i}") for i in range(8)] + [(f"m{i}", "join") for i in range(8)],
))
def corridor(spec) -> float:
xs = sorted({n.position.x for n in spec.nodes})
return min(b - a for a, b in zip(xs, xs[1:])) - NODE_W
assert corridor(wide) > corridor(narrow)
def test_long_edges_do_not_widen_a_single_corridor_forever() -> None:
"""走廊有上限。20 路扇出也不该把图撑到屏幕外面去。"""
spec = auto_layout(_graph(
[{"id": "in", "type": "input"}]
+ [{"id": f"m{i}", "type": "llm"} for i in range(20)],
[("in", f"m{i}") for i in range(20)],
))
xs = sorted({n.position.x for n in spec.nodes})
assert xs[1] - xs[0] <= NODE_W + 460
def test_same_column_never_overlaps() -> None:
"""一整列节点两两之间必须留出至少一个卡片的高度。"""
spec = auto_layout(_graph(
[{"id": "in", "type": "input"}] + [{"id": f"n{i}", "type": "llm"} for i in range(12)],
[("in", f"n{i}") for i in range(12)],
))
ys = sorted(n.position.y for n in spec.nodes if n.id != "in")
gaps = [b - a for a, b in zip(ys, ys[1:])]
assert gaps and min(gaps) >= 60
def test_layout_is_stable_and_idempotent() -> None:
"""同一张图排两次必须得到同一份坐标,否则保存后一刷新就跳。"""
first = auto_layout(_parallel_graph()).model_dump(mode="json")
second = auto_layout(GraphSpec.model_validate(first)).model_dump(mode="json")
assert [(n["id"], n["position"]) for n in first["nodes"]] == \
[(n["id"], n["position"]) for n in second["nodes"]]
def test_layout_does_not_touch_the_graph_itself() -> None:
"""排版只改坐标:节点、边、配置一个都不能丢,也不能多出来。"""
before = _parallel_graph()
ids = [(n.id, str(n.type)) for n in before.nodes]
edges = [(e.source, e.target, e.sourceHandle or "") for e in before.edges]
after = auto_layout(before)
assert [(n.id, str(n.type)) for n in after.nodes] == ids
assert [(e.source, e.target, e.sourceHandle or "") for e in after.edges] == edges
def test_cycle_does_not_hang_or_collapse() -> None:
"""没有任何入口的纯环图也得排完,不能死循环。"""
spec = _graph(
[{"id": "x", "type": "llm"}, {"id": "y", "type": "llm"}],
[("x", "y"), ("y", "x")],
)
laid = auto_layout(spec)
assert {n.id for n in laid.nodes} == {"x", "y"}
def test_dangling_edges_and_empty_graph_are_survivable() -> None:
assert auto_layout(GraphSpec()).nodes == []
spec = auto_layout(_graph(
[{"id": "a", "type": "llm"}],
[("a", "ghost"), ("ghost", "a")],
))
assert spec.nodes[0].position.x >= 0
def test_self_loop_is_kept() -> None:
spec = auto_layout(_graph([{"id": "a", "type": "llm"}], [("a", "a")]))
assert spec.nodes[0].position.x >= 0
assert len(spec.edges) == 1