package app import ( "encoding/json" "strings" "testing" "infinite-canvas/backend/internal/model" ) func cloudAgentBatchTableCall(t *testing.T, name, callID string, args any) cloudAgentCall { t.Helper() raw, err := json.Marshal(args) if err != nil { t.Fatal(err) } call := cloudAgentCall{ID: callID} call.Function.Name = name call.Function.Arguments = string(raw) return call } func cloudAgentBatchTableFixture(t *testing.T) (*Service, *model.CanvasProject) { t.Helper() s, db, _, _ := creationTestService(t) descriptor, ok := cloudAgentNodeCapabilityForType("batch-table") if !ok { t.Fatal("batch-table capability is not registered") } nodes := []any{ map[string]any{"id": "image-1", "type": "image", "title": "模特", "position": map[string]any{"x": 0.0, "y": 0.0}, "width": 320.0, "height": 320.0, "metadata": map[string]any{"content": "data:image/png;base64,AA", "status": "success"}}, map[string]any{"id": "image-2", "type": "image", "title": "服装", "position": map[string]any{"x": 360.0, "y": 0.0}, "width": 320.0, "height": 320.0, "metadata": map[string]any{"storageKey": "asset:test", "status": "success"}}, map[string]any{"id": "batch-1", "type": "batch-table", "title": "电商换装批次", "position": map[string]any{"x": 720.0, "y": 0.0}, "width": descriptor.DefaultWidth, "height": descriptor.DefaultHeight, "metadata": descriptor.Metadata("")}, } payload, err := json.Marshal(map[string]any{"nodes": nodes, "connections": []any{}}) if err != nil { t.Fatal(err) } canvas := &model.CanvasProject{ID: "batch-agent-canvas", UserID: "user", Title: "批量表测试", PayloadJSON: string(payload)} if err := db.Create(canvas).Error; err != nil { t.Fatal(err) } return s, canvas } func TestCloudAgentBatchTableCapabilityMatchesCanvasComponent(t *testing.T) { descriptor, ok := cloudAgentNodeCapabilityForType("batch-table") if !ok { t.Fatal("batch-table capability missing") } if descriptor.Label != "批量创作表" || descriptor.DefaultWidth != 900 || descriptor.DefaultHeight != 520 || descriptor.InputKind != "text" || descriptor.ProjectionKind != "batch_table" || descriptor.ProjectionField != "batchTable" { t.Fatalf("batch-table capability differs from component contract: %+v", descriptor) } if err := descriptor.ValidateConnection("image"); err != nil { t.Fatalf("batch table rejected image reference: %v", err) } if err := descriptor.ValidateConnection("text"); err == nil { t.Fatal("batch table accepted a non-image reference") } metadata := descriptor.Metadata("") table := metadata["batchTable"].(map[string]any) if table["operation"] != "try_on" || table["concurrency"] != float64(10) || len(creationMaps(table["referenceColumns"])) != 3 || len(creationMaps(table["rows"])) != 0 { t.Fatalf("unexpected batch-table defaults: %+v", table) } if _, allowsOpaqueRewrite := descriptor.PatchFields["batchTable"]; allowsOpaqueRewrite { t.Fatal("generic canvas patch may not replace the whole batch table") } } func TestCloudAgentBatchTableToolsAreScopedAndStructured(t *testing.T) { readOnly := agentTestRequest() readOnly.ContextScope = []string{"canvas"} readNames := map[string]bool{} for _, tool := range cloudAgentTools(readOnly) { name := tool["function"].(map[string]any)["name"].(string) readNames[name] = true } if !readNames["canvas_read_batch_table"] || readNames["canvas_edit_batch_table"] || readNames["canvas_table_plan"] { t.Fatalf("read-only batch table tool scope is wrong: %+v", readNames) } writable := readOnly writable.PermissionMode = "auto" functions := map[string]map[string]any{} for _, tool := range cloudAgentTools(writable) { function := tool["function"].(map[string]any) functions[function["name"].(string)] = function } if functions["canvas_edit_batch_table"] == nil || !cloudAgentWrite("canvas_edit_batch_table") { t.Fatal("batch table edit tool is not registered as an approved write") } parameters := functions["canvas_edit_batch_table"]["parameters"].(map[string]any) properties := parameters["properties"].(map[string]any) patch := properties["patch"].(map[string]any)["properties"].(map[string]any) for _, field := range []string{"enabled", "inputNodeIds", "prompt"} { if patch[field] == nil { t.Fatalf("batch row patch schema missing %s", field) } } for _, forbidden := range []string{"outputNodeId", "taskId", "url", "storageKey", "metadata"} { if patch[forbidden] != nil { t.Fatalf("batch row patch schema exposes forbidden field %s", forbidden) } } } func TestCloudAgentCanReadAndEditBatchTableRows(t *testing.T) { s, canvas := cloudAgentBatchTableFixture(t) doc, err := creationDocument(canvas.PayloadJSON) if err != nil { t.Fatal(err) } call := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "append-row", map[string]any{ "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "batch-1", "action": "append", "patch": map[string]any{"inputNodeIds": []string{"image-1", "image-2"}, "prompt": "保持模特身份,替换为参考服装"}, }) plan, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, call) if err != nil { t.Fatal(err) } if plan.Preview.Title != "确认修改批量创作表" || strings.Contains(plan.Preview.Description, "生成任务已提交") { t.Fatalf("unexpected approval preview: %+v", plan.Preview) } policy, err := s.RuntimePolicy() if err != nil { t.Fatal(err) } result, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, call, policy) if err != nil { t.Fatal(err) } resultMap := result.(map[string]any) if resultMap["snapshotHash"] == "" { t.Fatal("batch table edit did not return a new snapshot") } state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: canvas.ID}} readCall := cloudAgentBatchTableCall(t, "canvas_read_batch_table", "read-batch", map[string]any{"nodeId": "batch-1", "offset": 0}) readResult, err := cloudAgentReadTool(s.repo, "user", state, readCall) if err != nil { t.Fatal(err) } read := readResult.(map[string]any) table := read["batchTable"].(map[string]any) columns := table["referenceColumns"].([]any) if columns[0].(map[string]any)["mentionToken"] != "@参考图1" || columns[1].(map[string]any)["mentionToken"] != "@参考图2" { t.Fatalf("batch table did not expose positional prompt mentions: %+v", columns) } rows := table["rows"].([]any) if len(rows) != 1 { t.Fatalf("expected one batch row, got %+v", table) } row := rows[0].(map[string]any) rowID := stringValue(row["rowId"]) if rowID == "" || row["prompt"] != "保持模特身份,替换为参考服装" { t.Fatalf("batch row was not projected with a real rowId: %+v", row) } preview := table["generationPreview"].(map[string]any) if preview["readyRows"] != 1 { t.Fatalf("batch generation preview did not recognize ready try-on row: %+v", preview) } appendInheritedCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "append-inherited-row", map[string]any{ "snapshotHash": read["snapshotHash"], "nodeId": "batch-1", "action": "append", "patch": map[string]any{"prompt": "沿用上一行参考图生成另一版"}, }) appendInheritedResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, appendInheritedCall, policy) if err != nil { t.Fatal(err) } appendInheritedSnapshot := appendInheritedResult.(map[string]any)["snapshotHash"] readInherited, err := cloudAgentReadTool(s.repo, "user", state, readCall) if err != nil { t.Fatal(err) } inheritedRows := readInherited.(map[string]any)["batchTable"].(map[string]any)["rows"].([]any) if len(inheritedRows) != 2 || len(inheritedRows[1].(map[string]any)["inputNodeIds"].([]any)) != 2 { t.Fatalf("agent append did not inherit the previous row references: %+v", inheritedRows) } updateCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "update-row", map[string]any{ "snapshotHash": appendInheritedSnapshot, "nodeId": "batch-1", "action": "update", "rowId": rowID, "patch": map[string]any{"enabled": false, "prompt": "暂停这条任务"}, }) if _, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, updateCall, policy); err != nil { t.Fatal(err) } } func TestCloudAgentBatchTableRejectsUnsafeAndStaleEdits(t *testing.T) { s, canvas := cloudAgentBatchTableFixture(t) doc, err := creationDocument(canvas.PayloadJSON) if err != nil { t.Fatal(err) } hash := cloudAgentCanvasHash(doc) unsafe := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "unsafe-row", map[string]any{ "snapshotHash": hash, "nodeId": "batch-1", "action": "append", "patch": map[string]any{"inputNodeIds": []string{"image-1"}, "outputNodeId": "forged-output"}, }) if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, unsafe); err == nil || !strings.Contains(err.Error(), "outputNodeId") { t.Fatalf("unsafe output binding was not rejected: %v", err) } nonImage := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "bad-reference", map[string]any{ "snapshotHash": hash, "nodeId": "batch-1", "action": "append", "patch": map[string]any{"inputNodeIds": []string{"batch-1"}}, }) if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, nonImage); err == nil || !strings.Contains(err.Error(), "图片节点") { t.Fatalf("non-image reference was not rejected: %v", err) } stale := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "stale-row", map[string]any{ "snapshotHash": "stale", "nodeId": "batch-1", "action": "set_concurrency", "concurrency": 5, }) if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "画布已变化") { t.Fatalf("stale snapshot was not rejected: %v", err) } }