Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_layout_test.go
AetherNo2332 56598108d4 feat(canvas): 云端 Agent 画布布局 - 新增 canvas_arrange_nodes 与新增节点自动落位
云端 Agent 此前只能新建节点与连线,既不会排版也不能移动已有节点:模型只能用几十个
update_node 手算坐标,或者让用户自己拖。

- 新增 backend/internal/canvas/layout:把前端既有的画布布局算法(web/src/lib/canvas/
  canvas-layout.ts,本 PR 不改该文件)按同一套常量移植到服务端,提供 Kahn 拓扑分层 flow、
  媒体泳道 byType、row/column/grid、对齐与等距、分组分带、AABB 重叠检测与空位搜索。
- 新增写工具 canvas_arrange_nodes:snapshotHash 必需,mode∈{auto,flow,byType,row,column,
  grid},可传 nodeIds、groups(只做横向位置分带,不建容器、不改层级)、align、gap、dryRun。
  只改坐标,不改内容、不建连线、不增删节点;一次最多 50 个节点 / 12 组。锁定节点、背板等
  容器、隐藏批次子节点与已归属背板的子节点保持不动并在回执里说明;整批复用现有 mutation
  记录与事件管线,审批与撤销走同一条路径。
- add_node 省略坐标时不再落到 (0,0):优先贴合本批连线的对端(输入在左、输出在右),否则
  追加到同泳道同类节点之后,冲突则扫描空位;只给一个轴时保留该轴。agentCanvasOp.X/Y 改为
  指针以区分"没给坐标"与"坐标 0"。
- update_node 的 patch 新增 x/y(PatchField.Limit 校验 ±1e6),供模型微调单个节点。
- 系统行为策略新增「布局与整理」一节并升版到 8;docs 补工具说明与工具全集;前端同步审批
  预览联合类型、面板文案与徽标。
- 工具 schema 预算按实测从 24000 上调到 25000(21 个工具 24788 字节),并改正注释里
  "上游约 23.4 KB"的说法(上游 20 个工具实测 22777 字节)。
2026-09-22 01:32:20 +08:00

339 lines
12 KiB
Go
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.
package app
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/model"
)
// layoutFixture 造一张用于整理测试的画布:文本 / 图片 / 视频三类节点挤在同一个角落。
func layoutFixture(t *testing.T, nodes []map[string]any) (*Service, map[string]any) {
t.Helper()
s, db, _, _ := creationTestService(t)
doc := map[string]any{"nodes": nodes, "connections": []any{}}
raw, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
if err := db.Create(&model.CanvasProject{ID: "layout-canvas", UserID: "user", PayloadJSON: string(raw)}).Error; err != nil {
t.Fatal(err)
}
return s, doc
}
func layoutNode(id, nodeType string, x, y float64, metadata ...map[string]any) map[string]any {
meta := map[string]any{"content": "内容-" + id}
if len(metadata) > 0 {
for key, value := range metadata[0] {
meta[key] = value
}
}
return map[string]any{
"id": id, "type": nodeType, "title": "节点-" + id,
"position": map[string]any{"x": x, "y": y},
"width": 300.0, "height": 200.0,
"metadata": meta,
}
}
func arrangeCall(t *testing.T, args map[string]any) cloudAgentCall {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
call := cloudAgentCall{ID: "arrange-1"}
call.Function.Name = "canvas_arrange_nodes"
call.Function.Arguments = string(raw)
return call
}
func canvasPositions(t *testing.T, s *Service) map[string]layout.Position {
t.Helper()
canvas, err := s.repo.CanvasProjectForUser("user", "layout-canvas")
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
positions := map[string]layout.Position{}
for _, node := range creationMaps(doc["nodes"]) {
x, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["x"])
y, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["y"])
xf, _ := x.(float64)
yf, _ := y.(float64)
positions[stringValue(node["id"])] = layout.Position{X: xf, Y: yf}
}
return positions
}
func TestCloudAgentArrangeNodesLaysOutByLane(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("text-2", "text", 10, 10),
layoutNode("image-1", "image", 20, 20),
layoutNode("video-1", "video", 30, 30),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "byType",
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
// 布局以当前左上角为锚点:已经在锚点上的 text-1 不需要移动,moved 只统计真正变动的节点。
if moved, _ := receipt["moved"].(int); moved < 3 {
t.Fatalf("整理节点数 = %v,期望至少 3:%+v", receipt["moved"], receipt)
}
positions := canvasPositions(t, s)
if positions["text-1"].Y >= positions["image-1"].Y || positions["image-1"].Y >= positions["video-1"].Y {
t.Fatalf("未按媒体泳道分区:%+v", positions)
}
if positions["text-1"].X != positions["image-1"].X {
t.Fatalf("各泳道应左对齐:%+v", positions)
}
applied := []layout.Node{
{ID: "text-1", X: positions["text-1"].X, Y: positions["text-1"].Y, Width: 300, Height: 200},
{ID: "text-2", X: positions["text-2"].X, Y: positions["text-2"].Y, Width: 300, Height: 200},
{ID: "image-1", X: positions["image-1"].X, Y: positions["image-1"].Y, Width: 300, Height: 200},
{ID: "video-1", X: positions["video-1"].X, Y: positions["video-1"].Y, Width: 300, Height: 200},
}
if pairs := layout.Overlaps(applied, 0); len(pairs) != 0 {
t.Fatalf("整理后仍有重叠:%+v", pairs)
}
if hash, _ := receipt["snapshotHash"].(string); hash == "" || hash == receipt["beforeSnapshotHash"] {
t.Fatalf("整理后快照应变化:%+v", receipt)
}
}
func TestCloudAgentArrangeNodesKeepsLockedAndContainers(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("text-2", "text", 5, 5),
layoutNode("locked-1", "text", 400, 400, map[string]any{"locked": true}),
layoutNode("frame-1", "frame", 800, 800),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "byType",
}), policy); err != nil {
t.Fatal(err)
}
positions := canvasPositions(t, s)
if positions["locked-1"] != (layout.Position{X: 400, Y: 400}) {
t.Fatalf("锁定节点被移动:%+v", positions["locked-1"])
}
if positions["frame-1"] != (layout.Position{X: 800, Y: 800}) {
t.Fatalf("背板被移动:%+v", positions["frame-1"])
}
if positions["text-2"].Y == 5 && positions["text-2"].X == 5 {
t.Fatalf("可整理节点没有移动:%+v", positions)
}
}
func TestCloudAgentArrangeNodesGroupsMakeBands(t *testing.T) {
nodes := []map[string]any{
layoutNode("a-1", "image", 0, 0),
layoutNode("a-2", "image", 10, 10),
layoutNode("b-1", "video", 20, 20),
layoutNode("b-2", "video", 30, 30),
layoutNode("left-out", "text", 900, 900),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"groups": []any{
map[string]any{"label": "A 场景", "nodeIds": []any{"a-1", "a-2"}, "mode": "grid"},
map[string]any{"label": "B 场景", "nodeIds": []any{"b-1", "b-2"}, "mode": "grid"},
},
"gap": 120,
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
groups, _ := receipt["groups"].([]string)
if len(groups) != 2 || groups[0] != "A 场景" {
t.Fatalf("分组信息缺失:%+v", receipt["groups"])
}
positions := canvasPositions(t, s)
if positions["b-1"].Y <= positions["a-1"].Y {
t.Fatalf("两组未分带:a=%+v b=%+v", positions["a-1"], positions["b-1"])
}
if positions["left-out"] != (layout.Position{X: 900, Y: 900}) {
t.Fatalf("未参与分组的节点被移动:%+v", positions["left-out"])
}
}
func TestCloudAgentArrangeNodesDryRunDoesNotWrite(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "row",
"dryRun": true,
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
if wouldMove, _ := receipt["wouldMove"].(int); wouldMove < 1 {
t.Fatalf("预演结果 = %+v", receipt)
}
if moved, _ := receipt["moved"].(int); moved != 0 {
t.Fatalf("预演不应写入:%+v", receipt)
}
positions := canvasPositions(t, s)
if positions["text-2"] != (layout.Position{X: 5, Y: 5}) {
t.Fatalf("预演不应写入画布:%+v", positions)
}
}
func TestCloudAgentArrangeNodesRejectsStaleSnapshotAndTooManyNodes(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)}
s, _ := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
// 过期快照按上游的字段错误口径归类(issue=stale_snapshot),运行期据此把它当可纠正的
// 工具结果交回模型重新读取,而不是终止整轮。
var staleErr *cloudAgentFieldArgumentError
if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": "stale-hash",
"mode": "row",
}), policy); !errors.As(err, &staleErr) || staleErr.Issue != "stale_snapshot" || staleErr.Field != "snapshotHash" {
t.Fatalf("陈旧快照应被归类为过期快照:%v", err)
}
many := make([]map[string]any, 0, cloudAgentArrangeMaxNodes+1)
for index := 0; index <= cloudAgentArrangeMaxNodes; index++ {
many = append(many, layoutNode(fmt.Sprintf("text-%d", index), "text", float64(index), 0))
}
s2, doc2 := layoutFixture(t, many)
if _, err := applyCloudAgentArrangeNodes(s2.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc2),
"mode": "row",
}), mustRuntimePolicy(t, s2)); err == nil || !strings.Contains(err.Error(), "最多整理") {
t.Fatalf("超限应被拒绝:%v", err)
}
}
// add_node 不带坐标时由服务端按泳道自动落位(作为生成输入时排到目标左侧):
// agentCanvasOp.X/Y 的指针语义保证"不传坐标"与"坐标 0"是两件事。
func TestCloudAgentAddedNodeWithoutCoordinatesLandsOrdered(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("image-1", "image", 0, 400),
}
_, doc := layoutFixture(t, nodes)
// 不带坐标:服务端按泳道落位,不能落在 (0,0) 与已有节点重叠。
ops := []agentCanvasOp{
{Type: "add_node", ID: "text-new", NodeType: "text", Title: stringPtr("新增文本")},
}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
added := nodeByID(t, doc, "text-new")
position := positionOf(t, added)
if position.X == 0 && position.Y == 0 {
t.Fatalf("新增节点落在原点:%+v", position)
}
if position.X == 0 && position.Y == 400 {
t.Fatalf("新增节点与已有图片重叠:%+v", position)
}
// 带连线:新节点作为生成输入时放在目标左侧。
ops = []agentCanvasOp{
{Type: "add_node", ID: "text-input", NodeType: "text", Title: stringPtr("输入")},
{Type: "connect_nodes", ID: "edge-new", FromNodeID: "text-input", ToNodeID: "image-1"},
}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
input := positionOf(t, nodeByID(t, doc, "text-input"))
if input.X >= 0 {
t.Fatalf("作为生成输入的新节点应排到目标左侧:%+v", input)
}
if input.Y != 400 {
t.Fatalf("新节点应与目标同高:%+v", input)
}
// 显式坐标优先。
x, y := 1234.0, 5678.0
ops = []agentCanvasOp{{Type: "add_node", ID: "text-fixed", NodeType: "text", X: &x, Y: &y}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
if got := positionOf(t, nodeByID(t, doc, "text-fixed")); got != (layout.Position{X: 1234, Y: 5678}) {
t.Fatalf("显式坐标未被采用:%+v", got)
}
}
func TestCloudAgentUpdateNodePositionPatch(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0)}
_, doc := layoutFixture(t, nodes)
ops := []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 480.0, "y": 260.0}}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
if got := positionOf(t, nodeByID(t, doc, "text-1")); got != (layout.Position{X: 480, Y: 260}) {
t.Fatalf("坐标未写入:%+v", got)
}
ops = []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 1e9}}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err == nil {
t.Fatal("越界坐标应被拒绝")
}
}
func nodeByID(t *testing.T, doc map[string]any, id string) map[string]any {
t.Helper()
for _, node := range creationMaps(doc["nodes"]) {
if stringValue(node["id"]) == id {
return node
}
}
t.Fatalf("节点 %s 不存在", id)
return nil
}
func positionOf(t *testing.T, node map[string]any) layout.Position {
t.Helper()
position, _ := node["position"].(map[string]any)
x, _ := cloudAgentSafeNumber(position["x"])
y, _ := cloudAgentSafeNumber(position["y"])
xf, _ := x.(float64)
yf, _ := y.(float64)
return layout.Position{X: xf, Y: yf}
}
// mustRuntimePolicy 读取运行时策略,供整理工具落库时使用。
func mustRuntimePolicy(t *testing.T, s *Service) RuntimePolicySetting {
t.Helper()
policy, err := s.RuntimePolicy()
if err != nil {
t.Fatal(err)
}
return policy
}