246 lines
12 KiB
Go
246 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
"yingce/backend/internal/model"
|
||
)
|
||
|
||
func seedCloudAgentCharacter(t *testing.T, db *gorm.DB, canvasID string) map[string]any {
|
||
t.Helper()
|
||
node := map[string]any{
|
||
"id": "character-card", "type": "text", "title": "扶颦",
|
||
"metadata": map[string]any{
|
||
"workflowKind": "character", "characterAssetId": "character-asset",
|
||
"characterVersionPolicy": "current", "characterDefinition": map[string]any{"appearance": "UNTRUSTED_METADATA"},
|
||
},
|
||
}
|
||
for _, item := range []any{
|
||
&model.Project{ID: "character-project", UserID: "user", Name: "短剧"},
|
||
&model.Asset{ID: "character-asset", UserID: "user", Category: model.AssetCategoryCharacter, Kind: "entity", Title: "扶颦", Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: "character-v2"},
|
||
&model.ProjectAssetLink{ID: "character-link", ProjectID: "character-project", AssetID: "character-asset"},
|
||
&model.AssetVersion{ID: "character-v1", AssetID: "character-asset", Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: `{"appearance":"第一版红衣"}`},
|
||
&model.AssetVersion{ID: "character-v2", AssetID: "character-asset", Version: 2, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: `{"role":"女主角","appearance":"三岁幼童,乌黑大眼睛","clothing":"黑红小裙","voiceTimbre":"稚嫩童声"}`},
|
||
&model.AssetRepresentation{ID: "character-representation", AssetVersionID: "character-v2", ResourceID: "character-image", MediaType: "image", Role: "turnaround_sheet"},
|
||
&model.Resource{ID: "character-image", UserID: "user", Kind: "image", Status: model.ResourceStatusReady, MimeType: "image/png", Size: 123, Width: 720, Height: 720},
|
||
&model.VoiceProfile{ID: "character-voice", UserID: "user", Name: "扶颦声音", Provider: "openai_compatible", VoiceKey: "test-voice", Status: "active", Timbre: "稚嫩童声", CompatibleModelsJSON: `[]`},
|
||
&model.CharacterVoiceBinding{ID: "character-voice-binding", AssetVersionID: "character-v2", VoiceProfileID: "character-voice", Instructions: "轻声说话"},
|
||
} {
|
||
if err := db.Create(item).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", canvasID).Update("project_id", "character-project").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return node
|
||
}
|
||
|
||
func TestCloudAgentCharacterReadUsesAuthorizedAssetNotEmptyTextContent(t *testing.T) {
|
||
service, db, _, _ := creationTestService(t)
|
||
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{}`}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
node := seedCloudAgentCharacter(t, db, "agent-canvas")
|
||
doc := map[string]any{"nodes": []any{node}, "connections": []any{}}
|
||
view, err := cloudAgentCanvasState(service.repo, "user", "agent-canvas", doc, 0, []string{"character-card"}, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
character := view.(map[string]any)["nodes"].([]any)[0].(map[string]any)["character"].(map[string]any)
|
||
definition := character["definition"].(map[string]any)
|
||
if character["versionId"] != "character-v2" || character["visualStatus"] != "ready" || character["voiceStatus"] != "ready" || definition["appearance"] != "三岁幼童,乌黑大眼睛" {
|
||
t.Fatalf("character read missed project asset: %#v", character)
|
||
}
|
||
if character["imageReference"].(map[string]any)["ready"] != true || character["audioReference"].(map[string]any)["ready"] != true {
|
||
t.Fatalf("character media references are not ready: %#v", character)
|
||
}
|
||
representations := character["representations"].([]map[string]any)
|
||
if len(representations) != 1 || representations[0]["resourceId"] != "character-image" {
|
||
t.Fatalf("character representation details missing: %#v", representations)
|
||
}
|
||
encoded, _ := json.Marshal(view)
|
||
if strings.Contains(string(encoded), "UNTRUSTED_METADATA") {
|
||
t.Fatal("read exposed untrusted canvas metadata")
|
||
}
|
||
meta := node["metadata"].(map[string]any)
|
||
meta["characterVersionPolicy"], meta["characterVersionId"] = "pinned", "character-v1"
|
||
pinned, err := cloudAgentResolveCharacter(service.repo, "user", "character-project", node)
|
||
if err != nil || pinned.Card.VersionID != "character-v1" || pinned.Card.Definition["appearance"] != "第一版红衣" {
|
||
t.Fatalf("pinned read = %#v, %v", pinned, err)
|
||
}
|
||
meta["characterVersionId"] = "foreign-v1"
|
||
if err := db.Create(&model.AssetVersion{ID: "foreign-v1", AssetID: "other-asset", Version: 1, DefinitionJSON: `{}`}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := cloudAgentResolveCharacter(service.repo, "user", "character-project", node); err == nil {
|
||
t.Fatal("cross-asset pinned version accepted")
|
||
}
|
||
meta["characterVersionPolicy"] = "current"
|
||
if _, err := cloudAgentResolveCharacter(service.repo, "other-user", "character-project", node); err == nil {
|
||
t.Fatal("cross-user read accepted")
|
||
}
|
||
}
|
||
|
||
func TestCloudAgentCharacterDraftUsesCanvasRolesAndProviderImageSlots(t *testing.T) {
|
||
service, db, args := agentMediaFixture(t)
|
||
node := seedCloudAgentCharacter(t, db, "agent-canvas")
|
||
canvas, err := service.repo.CanvasProjectForUser("user", "agent-canvas")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc["nodes"] = append(doc["nodes"].([]any), node)
|
||
raw, _ := json.Marshal(doc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", canvas.ID).Update("payload_json", string(raw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
args.ReferenceNodeIDs = []string{"character-card"}
|
||
args.Prompt = "Keep the face and clothing consistent with @图片1."
|
||
args.SnapshotHash = cloudAgentCanvasHash(doc)
|
||
run, state := agentMediaRun(t, service, args, "request_approval")
|
||
request, _, err := service.prepareCloudAgentMedia(run, &state, agentMediaCall(args))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.advanceCloudAgentByID("user", run.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, err := service.repo.CanvasProjectForUser("user", canvas.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
storedDoc, _ := creationDocument(stored.PayloadJSON)
|
||
nodes, _ := creationObjects(storedDoc["nodes"])
|
||
metadata := nodes[args.NodeID]["metadata"].(map[string]any)
|
||
if !strings.Contains(stringValue(metadata["composerContent"]), "@角色1") || strings.Contains(stringValue(metadata["composerContent"]), "@图片1") {
|
||
t.Fatalf("character draft kept provider slot labels: %#v", metadata["composerContent"])
|
||
}
|
||
if !strings.Contains(request.Prompt, "@图片1") || strings.Contains(request.Prompt, "@角色1") {
|
||
t.Fatalf("provider slot labels changed: %q", request.Prompt)
|
||
}
|
||
approveAgentMediaDraft(t, service, run.ID)
|
||
storedRun, err := service.repo.CloudAgent("user", run.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
approvedState, err := cloudAgentDecode(storedRun)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
task, err := service.repo.TaskForUser("user", approvedState.MediaTaskID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(task.Prompt, "@图片1") || strings.Contains(task.Prompt, "@角色1") {
|
||
t.Fatalf("approved task lost provider image slots: %q", task.Prompt)
|
||
}
|
||
}
|
||
|
||
func TestCloudAgentCharacterReferenceUsesDefinitionAndTurnaround(t *testing.T) {
|
||
service, db, args := agentMediaFixture(t)
|
||
node := seedCloudAgentCharacter(t, db, "agent-canvas")
|
||
canvas, err := service.repo.CanvasProjectForUser("user", "agent-canvas")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc["nodes"] = append(doc["nodes"].([]any), node)
|
||
raw, _ := json.Marshal(doc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", canvas.ID).Update("payload_json", string(raw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
args.ReferenceNodeIDs = []string{"character-card"}
|
||
args.SnapshotHash = cloudAgentCanvasHash(doc)
|
||
run, state := agentMediaRun(t, service, args, "request_approval")
|
||
request, plan, err := service.prepareCloudAgentMedia(run, &state, agentMediaCall(args))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
images := creationMaps(request.Input["referenceImages"])
|
||
if len(images) != 1 || images[0]["storageKey"] != "resource:character-image" || images[0]["id"] != "character-card" {
|
||
t.Fatalf("missing real image reference: %#v", images)
|
||
}
|
||
if !strings.Contains(request.Prompt, "三岁幼童,乌黑大眼睛") || strings.Contains(request.Prompt, "UNTRUSTED_METADATA") || plan.Args.CharacterVersions["character-card"] != "character-v2" {
|
||
t.Fatalf("missing real character definition or version: %q %#v", request.Prompt, plan.Args.CharacterVersions)
|
||
}
|
||
if intent, err := service.cloudAgentModelIntent("user", canvas.ID, `{"mode":"video","referenceNodeIds":["character-card"]}`); err != nil || intent == nil {
|
||
t.Fatalf("model list did not recognize character image: %#v, %v", intent, err)
|
||
}
|
||
textOnly := args
|
||
textOnly.ReferenceNodeIDs = nil
|
||
textOnly.SourceNodeID = "character-card"
|
||
textRequest, _, err := service.prepareCloudAgentMedia(run, &state, agentMediaCall(textOnly))
|
||
if err != nil || !strings.Contains(textRequest.Prompt, "三岁幼童,乌黑大眼睛") || len(creationMaps(textRequest.Input["referenceImages"])) != 0 {
|
||
t.Fatalf("text-only character reference = %q, %v", textRequest.Prompt, err)
|
||
}
|
||
prepared := &cloudAgentPreparedMedia{Version: 1, Hash: "test", DependencyHash: "", CharacterVersions: plan.Args.CharacterVersions, Quote: cloudAgentMediaQuote{ExpiresAt: time.Now().Add(time.Minute)}}
|
||
prepared.DependencyHash, err = cloudAgentMediaDependencyHash(doc, plan.Args)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&model.Asset{}).Where("id = ?", "character-asset").Update("primary_version_id", "character-v1").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := validateCloudAgentPreparedAdmission(service.repo, "user", prepared, &model.Task{ProjectID: canvas.ID}, nil, doc, plan.Args); err == nil || !strings.Contains(err.Error(), "角色卡版本已变化") {
|
||
t.Fatalf("approved character version drift not rejected: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCloudAgentCharacterCardIsRegisteredCapability(t *testing.T) {
|
||
node := map[string]any{"id": "hero", "type": "text", "title": "扶颦", "metadata": map[string]any{"workflowKind": "character", "characterAssetId": "character-asset"}}
|
||
descriptor, ok := cloudAgentNodeCapabilityForNode(node)
|
||
if !ok || descriptor.Type != "character" || descriptor.InputKind != "character" || !descriptor.Connection.CanReference {
|
||
t.Fatalf("character node did not resolve to character capability: %#v, %v", descriptor, ok)
|
||
}
|
||
if plain, _ := cloudAgentNodeCapabilityForNode(map[string]any{"id": "note", "type": "text"}); plain.Type != "text" {
|
||
t.Fatalf("plain text node resolved to %q", plain.Type)
|
||
}
|
||
if _, creatable := cloudAgentNodeCapabilityForType("character"); creatable {
|
||
t.Fatal("character variant must not be resolvable as a creatable node type")
|
||
}
|
||
if err := validateCreationOps([]CreationCanvasOp{{Type: "add_node", ID: "new", NodeType: "character"}}); err == nil {
|
||
t.Fatal("agent must not create character cards from nothing")
|
||
}
|
||
|
||
nodes := []map[string]any{node, {"id": "video", "type": "video"}, {"id": "image", "type": "image"}, {"id": "audio", "type": "audio"}, {"id": "note", "type": "text"}}
|
||
for _, target := range []string{"video", "image", "audio"} {
|
||
if err := validateCloudAgentConnection(nodes, "hero", target); err != nil {
|
||
t.Fatalf("character card cannot feed %s: %v", target, err)
|
||
}
|
||
}
|
||
if err := validateCloudAgentConnection(nodes, "hero", "note"); err == nil {
|
||
t.Fatal("character card connected to a non-generation text node")
|
||
}
|
||
// 角色卡设定只随角色资产版本变化,Agent 不能把节点正文或标题当成角色设定改写。
|
||
if err := descriptor.ValidatePatch(map[string]any{"content": "改写设定"}); err == nil {
|
||
t.Fatal("character card accepted content patch")
|
||
}
|
||
if err := descriptor.ValidatePatch(map[string]any{"x": 10.0, "y": 20.0}); err != nil {
|
||
t.Fatalf("character card cannot move: %v", err)
|
||
}
|
||
|
||
guide := cloudAgentCapabilityGuide()
|
||
if !strings.Contains(guide, "角色卡(character)") || !strings.Contains(guide, "workflowKind=character") {
|
||
t.Fatalf("capability guide does not introduce character cards: %s", guide)
|
||
}
|
||
view, err := cloudAgentCanvasState(nil, "user", "agent-canvas", map[string]any{"nodes": []any{node}, "connections": []any{}}, 0, nil, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
item := view.(map[string]any)["nodes"].([]any)[0].(map[string]any)
|
||
if item["kind"] != "character" || item["agentSupported"] == false {
|
||
t.Fatalf("canvas read does not expose character kind: %#v", item)
|
||
}
|
||
}
|