package app import ( "encoding/json" "strings" "testing" "yingce/backend/internal/model" "yingce/backend/internal/repository" ) func cloudAgentStoryboardCall(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 cloudAgentStoryboardFixture(t *testing.T) (*Service, *model.CanvasProject) { t.Helper() s, db, _, _ := creationTestService(t) canvas := &model.CanvasProject{ID: "agent-canvas", UserID: "user", Title: "分镜测试", PayloadJSON: `{"nodes":[],"connections":[]}`} if err := db.Create(canvas).Error; err != nil { t.Fatal(err) } return s, canvas } func createCloudAgentStoryboardForTest(t *testing.T, s *Service, canvas *model.CanvasProject) []map[string]any { t.Helper() doc, err := creationDocument(canvas.PayloadJSON) if err != nil { t.Fatal(err) } call := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "create-storyboard", map[string]any{ "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "storyboard-1", "title": "追逐戏分镜", "x": 120, "y": 80, "rows": []map[string]any{ {"durationSeconds": 4.0, "plotDescription": "主角冲出巷口", "camera": "低机位跟拍"}, {"durationSeconds": 6.0, "videoMotionPrompt": "反派从屋顶跃下", "continuityOut": "主角转身看向屋顶"}, }, }) policy, err := s.RuntimePolicy() if err != nil { t.Fatal(err) } if _, err := applyCloudAgentStoryboardMutation(s.repo, "user", canvas.ID, call, policy); err != nil { t.Fatal(err) } stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) if err != nil { t.Fatal(err) } *canvas = *stored storedDoc, err := creationDocument(stored.PayloadJSON) if err != nil { t.Fatal(err) } _, _, rows, err := storyboardNodeFromDocument(storedDoc, "storyboard-1") if err != nil { t.Fatal(err) } return rows } func TestCloudAgentStoryboardAcceptsImagePromptAsDescription(t *testing.T) { if err := validateCloudAgentStoryboardRow(map[string]any{ "durationSeconds": float64(3), "imageGenerationPrompt": "首帧:雨夜街口,主角从巷中走出", }, true, "rows[0]"); err != nil { t.Fatalf("a standalone imageGenerationPrompt should describe the shot: %v", err) } if err := validateCloudAgentStoryboardRow(map[string]any{"durationSeconds": float64(3)}, true, "rows[0]"); err == nil || !strings.Contains(err.Error(), "首帧提示词") { t.Fatalf("missing shot description should explain accepted fields: %v", err) } else if fieldErr, ok := err.(*cloudAgentFieldArgumentError); !ok || fieldErr.Field != "rows[0]" || fieldErr.Issue != "required" { t.Fatalf("missing shot description should be a repairable field error, got %T: %v", err, err) } } func TestCloudAgentStoryboardArgumentFailureContinuesRun(t *testing.T) { s, canvas := cloudAgentStoryboardFixture(t) req := agentTestRequest() req.PermissionMode = "request_approval" root, err := s.CreateCloudAgentRun("user", req, "") if err != nil { t.Fatal(err) } doc, err := creationDocument(canvas.PayloadJSON) if err != nil { t.Fatal(err) } call := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "storyboard-invalid-call", map[string]any{ "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "storyboard-invalid", "title": "缺少描述的分镜", "rows": []map[string]any{{"durationSeconds": 4.0}}, }) run, err := s.repo.CloudAgent("user", root.ID) if err != nil { t.Fatal(err) } state, err := cloudAgentDecode(run) if err != nil { t.Fatal(err) } state.ActiveTaskID = "" state.Calls = []cloudAgentCall{call} state.CallIndex = 0 if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { return cloudAgentSave(current, &state) }); err != nil { t.Fatal(err) } run, err = s.repo.CloudAgent("user", run.ID) if err != nil { t.Fatal(err) } if err := s.advanceCloudAgentTool(run, &state); err != nil { t.Fatal(err) } stored, err := s.repo.CloudAgent("user", run.ID) if err != nil { t.Fatal(err) } state, err = cloudAgentDecode(stored) if err != nil { t.Fatal(err) } if stored.Status != "running" { t.Fatalf("repairable storyboard argument failure terminated run: status=%s message=%s", stored.Status, stored.FailureMessage) } var failed *CloudAgentEvent for i := range state.Events { if state.Events[i].Type == "tool_failed" { failed = &state.Events[i] } if state.Events[i].Type == "run_failed" { t.Fatalf("argument error emitted run_failed: %+v", state.Events[i]) } } if failed == nil { t.Fatal("missing tool_failed event") } result, _ := failed.Payload["result"].(map[string]any) if result["field"] != "rows[0]" || result["issue"] != "required" || result["requiredAction"] != "fix_arguments" { t.Fatalf("missing actionable storyboard field feedback: %+v", result) } storedCanvas, err := s.repo.CanvasProjectForUser("user", canvas.ID) if err != nil { t.Fatal(err) } if strings.Contains(storedCanvas.PayloadJSON, "storyboard-invalid") { t.Fatal("invalid storyboard arguments wrote to the canvas") } } func TestCloudAgentNodeCapabilityCardsExplainStoryboardTradeoffs(t *testing.T) { result := cloudAgentNodeTypes() if result["schemaVersion"] != 2 { t.Fatalf("unexpected capability schema version: %v", result["schemaVersion"]) } nodes := result["nodes"].([]map[string]any) byType := map[string]map[string]any{} for _, node := range nodes { byType[node["type"].(string)] = node } storyboard := byType["script"] for _, nodeType := range []string{"text", "markdown", "image", "video", "audio", "frame", "script"} { node := byType[nodeType] for _, key := range []string{"purpose", "goodFor", "notIdealFor", "tradeoffs"} { if value, exists := node[key]; !exists || value == nil { t.Fatalf("%s capability card lacks %s: %+v", nodeType, key, node) } } } for _, nodeType := range []string{"image", "video", "audio", "script"} { if actions, exists := byType[nodeType]["actions"]; !exists || actions == nil { t.Fatalf("%s capability card lacks actions: %+v", nodeType, byType[nodeType]) } } if storyboard["purpose"] == byType["text"]["purpose"] || storyboard["purpose"] == byType["markdown"]["purpose"] { t.Fatal("storyboard and lightweight nodes expose indistinguishable purposes") } videoGoodFor := strings.Join(byType["video"]["goodFor"].([]string), "\n") if !strings.Contains(videoGoodFor, "多图参考视频") { t.Fatalf("video capability card does not explain multi-image video: %s", videoGoodFor) } frame := byType["frame"] frameGoodFor := strings.Join(frame["goodFor"].([]string), "\n") frameNotIdealFor := strings.Join(frame["notIdealFor"].([]string), "\n") if !strings.Contains(frameGoodFor, "按场景或镜头组") || !strings.Contains(frameGoodFor, "制作") || !strings.Contains(frameNotIdealFor, "分镜脚本节点") { t.Fatalf("frame capability card does not explain its use cases and limits: %+v", frame) } capabilityGuide := cloudAgentCapabilityGuide() if !strings.Contains(capabilityGuide, "按场景或镜头组") || !strings.Contains(capabilityGuide, "可移动、可折叠") { t.Fatalf("Agent system guide does not explain when to use a canvas frame: %s", capabilityGuide) } guide := strings.Join(result["selectionGuide"].([]string), "\n") if !strings.Contains(guide, "多镜头") || !strings.Contains(guide, "model_list") || !strings.Contains(guide, "不要为了形式") { t.Fatalf("selection guide does not express soft routing: %s", guide) } } func TestCloudAgentStoryboardToolsAreScopedAndStructured(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_storyboard"] || readNames["canvas_create_storyboard"] || readNames["canvas_edit_storyboard"] { t.Fatalf("read-only storyboard 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 } for _, name := range []string{"canvas_read_storyboard", "canvas_create_storyboard", "canvas_edit_storyboard"} { if functions[name] == nil { t.Fatalf("missing storyboard tool %s", name) } } createRows := functions["canvas_create_storyboard"]["parameters"].(map[string]any)["properties"].(map[string]any)["rows"].(map[string]any) rowProperties := createRows["items"].(map[string]any)["properties"].(map[string]any) if _, leaksID := rowProperties["id"]; leaksID { t.Fatal("create storyboard schema lets the model forge row IDs") } editPatch := functions["canvas_edit_storyboard"]["parameters"].(map[string]any)["properties"].(map[string]any)["patch"].(map[string]any)["properties"].(map[string]any) for _, protected := range []string{"imageNodeId", "videoNodeId", "assetBindings", "status"} { if _, exists := editPatch[protected]; exists { t.Fatalf("protected field %s leaked into edit schema", protected) } } if !cloudAgentWrite("canvas_create_storyboard") || !cloudAgentWrite("canvas_edit_storyboard") { t.Fatal("storyboard mutations are not classified as writes") } supported := strings.Join(CloudAgentSupportedToolNames(), ",") if !strings.Contains(supported, "canvas_create_storyboard") || !strings.Contains(supported, "canvas_edit_storyboard") { t.Fatalf("supported tool list is stale: %s", supported) } } func TestCloudAgentCreatesAndReadsStructuredStoryboard(t *testing.T) { s, canvas := cloudAgentStoryboardFixture(t) doc, _ := creationDocument(canvas.PayloadJSON) call := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "create-preview", map[string]any{ "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "storyboard-preview", "title": "预览分镜", "rows": []map[string]any{{"durationSeconds": 3.0, "plotDescription": "雨夜开场"}}, }) plan, err := prepareCloudAgentStoryboardCreate(s.repo, "user", canvas.ID, call) if err != nil { t.Fatal(err) } if len(plan.Preview.Items) != 1 || plan.Preview.Items[0].Operation != "create_storyboard" || !strings.Contains(plan.Preview.Description, "1 个镜头") { t.Fatalf("approval preview is incomplete: %+v", plan.Preview) } rows := createCloudAgentStoryboardForTest(t, s, canvas) if len(rows) != 2 || stringValue(rows[0]["id"]) == "" || stringValue(rows[0]["id"]) == stringValue(rows[1]["id"]) { t.Fatalf("server did not create stable unique row IDs: %+v", rows) } if rows[0]["shotNumber"] != float64(1) || rows[1]["shotNumber"] != float64(2) { t.Fatalf("shot numbers are wrong: %+v", rows) } if rows[0]["status"] != "idle" || rows[0]["assetBindings"] == nil || rows[0]["characters"] == nil { t.Fatalf("created row lacks frontend-safe defaults: %+v", rows[0]) } state := &cloudAgentRuntime{Request: agentTestRequest()} readCall := cloudAgentStoryboardCall(t, "canvas_read_storyboard", "read-storyboard", map[string]any{"nodeId": "storyboard-1", "offset": 0}) result, err := cloudAgentReadTool(s.repo, "user", state, readCall) if err != nil { t.Fatal(err) } read := result.(map[string]any) storyboard := read["storyboard"].(map[string]any) readRows := storyboard["rows"].([]any) if len(readRows) != 1 || readRows[0].(map[string]any)["rowId"] != rows[0]["id"] || storyboard["nextOffset"] != 1 { t.Fatalf("paged storyboard read lost row ID or pagination: %+v", read) } } func TestCloudAgentStoryboardEditPreservesRowsAndRenumbers(t *testing.T) { s, canvas := cloudAgentStoryboardFixture(t) initialRows := createCloudAgentStoryboardForTest(t, s, canvas) firstID, secondID := stringValue(initialRows[0]["id"]), stringValue(initialRows[1]["id"]) policy, err := s.RuntimePolicy() if err != nil { t.Fatal(err) } apply := func(call cloudAgentCall) []map[string]any { t.Helper() if _, err := applyCloudAgentStoryboardMutation(s.repo, "user", canvas.ID, call, policy); err != nil { t.Fatal(err) } stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) if err != nil { t.Fatal(err) } *canvas = *stored doc, _ := creationDocument(canvas.PayloadJSON) _, _, rows, err := storyboardNodeFromDocument(doc, "storyboard-1") if err != nil { t.Fatal(err) } return rows } currentHash := func() string { doc, _ := creationDocument(canvas.PayloadJSON) return cloudAgentCanvasHash(doc) } rows := apply(cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "update-row", map[string]any{ "snapshotHash": currentHash(), "nodeId": "storyboard-1", "action": "update", "rowId": firstID, "patch": map[string]any{"dialogue": "快跑!", "durationSeconds": 5.0}, })) if rows[0]["dialogue"] != "快跑!" || rows[1]["id"] != secondID || rows[1]["videoMotionPrompt"] != "反派从屋顶跃下" { t.Fatalf("update did not preserve unrelated rows: %+v", rows) } rows = apply(cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "append-row", map[string]any{ "snapshotHash": currentHash(), "nodeId": "storyboard-1", "action": "append", "patch": map[string]any{"durationSeconds": 2.0, "plotDescription": "两人在街口对峙"}, })) if len(rows) != 3 || rows[2]["shotNumber"] != float64(3) || stringValue(rows[2]["id"]) == "" { t.Fatalf("append failed: %+v", rows) } rows = apply(cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "remove-row", map[string]any{ "snapshotHash": currentHash(), "nodeId": "storyboard-1", "action": "remove", "rowId": firstID, })) if len(rows) != 2 || rows[0]["id"] != secondID || rows[0]["shotNumber"] != float64(1) || rows[1]["shotNumber"] != float64(2) { t.Fatalf("remove did not preserve IDs and renumber rows: %+v", rows) } } func TestCloudAgentStoryboardRejectsUnsafeOrStaleMutations(t *testing.T) { s, canvas := cloudAgentStoryboardFixture(t) rows := createCloudAgentStoryboardForTest(t, s, canvas) doc, _ := creationDocument(canvas.PayloadJSON) hash := cloudAgentCanvasHash(doc) rowID := stringValue(rows[0]["id"]) for _, field := range []string{"imageNodeId", "videoNodeId", "assetBindings", "status", "resourceUrl", "unknown"} { t.Run(field, func(t *testing.T) { call := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "unsafe-"+field, map[string]any{ "snapshotHash": hash, "nodeId": "storyboard-1", "action": "update", "rowId": rowID, "patch": map[string]any{field: "forbidden"}, }) if _, err := prepareCloudAgentStoryboardEdit(s.repo, "user", canvas.ID, call); err == nil { t.Fatalf("unsafe field %s was accepted", field) } }) } stale := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "stale", map[string]any{ "snapshotHash": "stale", "nodeId": "storyboard-1", "action": "update", "rowId": rowID, "patch": map[string]any{"dialogue": "不应写入"}, }) if _, err := prepareCloudAgentStoryboardEdit(s.repo, "user", canvas.ID, stale); err == nil || !strings.Contains(err.Error(), "被修改过") { t.Fatalf("stale snapshot was not rejected: %v", err) } empty := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "empty", map[string]any{ "snapshotHash": hash, "nodeId": "empty-storyboard", "title": "空分镜", "rows": []any{}, }) if _, err := prepareCloudAgentStoryboardCreate(s.repo, "user", canvas.ID, empty); err == nil { t.Fatal("empty storyboard was accepted") } forged := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "forged", map[string]any{ "snapshotHash": hash, "nodeId": "forged-storyboard", "title": "伪造分镜", "rows": []map[string]any{{"id": "forged-row", "durationSeconds": 3.0, "plotDescription": "测试"}}, }) if _, err := prepareCloudAgentStoryboardCreate(s.repo, "user", canvas.ID, forged); err == nil { t.Fatal("model-supplied row ID was accepted") } textDoc := map[string]any{"nodes": []map[string]any{{"id": "text-1", "type": "text", "metadata": map[string]any{"content": "普通文本"}}}, "connections": []any{}} raw, _ := json.Marshal(textDoc) stored, _ := s.repo.CanvasProjectForUser("user", canvas.ID) before := stored.PayloadJSON stored.PayloadJSON = string(raw) if err := saveCreationCanvasWithHistory(s.repo, stored, before); err != nil { t.Fatal(err) } wrongType := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "wrong-type", map[string]any{ "snapshotHash": cloudAgentCanvasHash(textDoc), "nodeId": "text-1", "action": "update", "rowId": rowID, "patch": map[string]any{"dialogue": "不应写入"}, }) if _, err := prepareCloudAgentStoryboardEdit(s.repo, "user", canvas.ID, wrongType); err == nil || !strings.Contains(err.Error(), "不是分镜") { t.Fatalf("non-storyboard node was accepted: %v", err) } } func TestCloudAgentStoryboardCreateUsesRuntimeApprovalPath(t *testing.T) { s, canvas := cloudAgentStoryboardFixture(t) req := agentTestRequest() req.PermissionMode = "request_approval" req.IdempotencyKey = "storyboard-approval" root, err := s.CreateCloudAgentRun("user", req, "") if err != nil { t.Fatal(err) } doc, _ := creationDocument(canvas.PayloadJSON) call := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "storyboard-approved-call", map[string]any{ "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "approved-storyboard", "title": "待审批分镜", "rows": []map[string]any{{"durationSeconds": 4.0, "plotDescription": "审批后写入的镜头"}}, }) run, err := s.repo.CloudAgent("user", root.ID) if err != nil { t.Fatal(err) } state, err := cloudAgentDecode(run) if err != nil { t.Fatal(err) } state.ActiveTaskID = "" state.Calls = []cloudAgentCall{call} state.CallIndex = 0 if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { return cloudAgentSave(current, &state) }); err != nil { t.Fatal(err) } run, _ = s.repo.CloudAgent("user", run.ID) state, _ = cloudAgentDecode(run) if err := s.advanceCloudAgentTool(run, &state); err != nil { t.Fatal(err) } waiting, err := s.CloudAgentRun("user", run.ID) if err != nil { t.Fatal(err) } if waiting.Status != "waiting_approval" || waiting.Approval == nil || waiting.Approval.Preview.Items[0].Operation != "create_storyboard" { t.Fatalf("storyboard write bypassed or lost approval preview: %+v", waiting) } stored, _ := s.repo.CanvasProjectForUser("user", canvas.ID) if strings.Contains(stored.PayloadJSON, "approved-storyboard") { t.Fatal("storyboard was written before approval") } if err := s.DecideCloudAgentApproval("user", run.ID, waiting.Approval.ID, "approve", "确认创建"); err != nil { t.Fatal(err) } if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } stored, _ = s.repo.CanvasProjectForUser("user", canvas.ID) if !strings.Contains(stored.PayloadJSON, "approved-storyboard") || !strings.Contains(stored.PayloadJSON, "审批后写入的镜头") { t.Fatalf("approved storyboard was not written: %s", stored.PayloadJSON) } }