311 lines
14 KiB
Go
311 lines
14 KiB
Go
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 != 1280 || descriptor.DefaultHeight != 560 || 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)
|
|
}
|
|
}
|
|
if properties["globalPrompt"] == nil {
|
|
t.Fatal("batch table edit schema missing globalPrompt")
|
|
}
|
|
actionEnum, _ := properties["action"].(map[string]any)["enum"].([]string)
|
|
foundGlobalPrompt := false
|
|
for _, action := range actionEnum {
|
|
if action == "set_global_prompt" {
|
|
foundGlobalPrompt = true
|
|
break
|
|
}
|
|
}
|
|
if !foundGlobalPrompt {
|
|
t.Fatalf("batch table edit schema missing set_global_prompt: %+v", actionEnum)
|
|
}
|
|
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 TestCloudAgentCanSetBatchTableGlobalPrompt(t *testing.T) {
|
|
s, canvas := cloudAgentBatchTableFixture(t)
|
|
doc, err := creationDocument(canvas.PayloadJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
policy, err := s.RuntimePolicy()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appendCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "append-empty-prompt", map[string]any{
|
|
"snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "batch-1", "action": "append",
|
|
"patch": map[string]any{"inputNodeIds": []string{"image-1", "image-2"}, "prompt": " "},
|
|
})
|
|
appendResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, appendCall, policy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: canvas.ID}}
|
|
readCall := cloudAgentBatchTableCall(t, "canvas_read_batch_table", "read-before-global", map[string]any{"nodeId": "batch-1", "offset": 0})
|
|
before, err := cloudAgentReadTool(s.repo, "user", state, readCall)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforePreview := before.(map[string]any)["batchTable"].(map[string]any)["generationPreview"].(map[string]any)
|
|
if beforePreview["readyRows"] != 0 || beforePreview["missingPromptRows"] != 1 {
|
|
t.Fatalf("empty row prompt should not be ready before global prompt: %+v", beforePreview)
|
|
}
|
|
|
|
setCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "set-global", map[string]any{
|
|
"snapshotHash": appendResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
|
|
"globalPrompt": " 全局换装提示词 ",
|
|
})
|
|
setResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, setCall, policy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := cloudAgentReadTool(s.repo, "user", state, readCall)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
table := after.(map[string]any)["batchTable"].(map[string]any)
|
|
if table["globalPrompt"] != "全局换装提示词" && table["globalPrompt"] != " 全局换装提示词 " {
|
|
t.Fatalf("global prompt was not projected: %+v", table)
|
|
}
|
|
preview := table["generationPreview"].(map[string]any)
|
|
if preview["readyRows"] != 1 || preview["missingPromptRows"] != 0 {
|
|
t.Fatalf("global prompt should make the row ready: %+v", preview)
|
|
}
|
|
|
|
clearCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "clear-global", map[string]any{
|
|
"snapshotHash": setResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
|
|
"globalPrompt": " ",
|
|
})
|
|
if _, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, clearCall, policy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleared, err := cloudAgentReadTool(s.repo, "user", state, readCall)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clearedTable := cleared.(map[string]any)["batchTable"].(map[string]any)
|
|
if _, exists := clearedTable["globalPrompt"]; exists {
|
|
t.Fatalf("empty global prompt should be cleared: %+v", clearedTable)
|
|
}
|
|
clearedPreview := clearedTable["generationPreview"].(map[string]any)
|
|
if clearedPreview["readyRows"] != 0 {
|
|
t.Fatalf("cleared global prompt should restore missing prompt: %+v", clearedPreview)
|
|
}
|
|
|
|
reject := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "reject-global-row", map[string]any{
|
|
"snapshotHash": cleared.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
|
|
"rowId": "row-should-not-be-accepted", "globalPrompt": "x",
|
|
})
|
|
if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, reject); err == nil || !strings.Contains(err.Error(), "不接受其他修改参数") {
|
|
t.Fatalf("set_global_prompt should reject row-level parameters: %v", 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)
|
|
}
|
|
}
|