package application import ( "context" "errors" "strings" "testing" "time" "img_infinite_canvas/internal/domain/design" "img_infinite_canvas/internal/infrastructure/memory" ) func TestRunVisualAnnotationCarriesCleanSourceAndGuideImage(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil) service.SetJobQueue(queue) project := design.Project{ ID: "project-visual-annotation", Title: "Annotated edit", Status: design.StatusReady, UpdatedAt: time.Now(), Nodes: []design.Node{{ ID: "image-1", Type: design.NodeTypeImage, Title: "Source", Width: 640, Height: 480, Content: "https://example.com/source.png", Status: "success", }}, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } _, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{ Action: "visual-annotation", Prompt: "1. Remove the logo", }) if !errors.Is(err, design.ErrInvalidInput) { t.Fatalf("expected missing guide image to be rejected, got %v", err) } selection := `{"annotations":[{"index":1,"type":"cross","instruction":"Remove the logo","points":[{"x":0.4,"y":0.3},{"x":0.6,"y":0.5}]}]}` guideURL := "https://example.com/annotation-guide.png" next, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{ Action: "visual_annotation", Prompt: "1. Cross: Remove the logo", Selection: selection, AnnotationImage: guideURL, }) if err != nil { t.Fatal(err) } if len(next.Nodes) != 2 { t.Fatalf("expected the source plus an annotated-edit copy, got %#v", next.Nodes) } sourceNode := next.Nodes[0] placeholder := next.Nodes[1] if sourceNode.Status != "success" || sourceNode.Title != "Source" || sourceNode.Content != project.Nodes[0].Content { t.Fatalf("expected the source image to remain unchanged, got %#v", sourceNode) } if placeholder.Status != "generating" || placeholder.Title != "标注改图中" || placeholder.ParentID != sourceNode.ID || placeholder.LayerRole != "visual-annotation-edit" { t.Fatalf("expected a generating annotated-edit copy, got %#v", placeholder) } if placeholder.X != sourceNode.X+sourceNode.Width+32 || placeholder.Y != sourceNode.Y || placeholder.Content != sourceNode.Content { t.Fatalf("expected the annotated-edit copy beside the source, got %#v", placeholder) } if len(queue.jobs) != 1 { t.Fatalf("expected one visual annotation job, got %#v", queue.jobs) } request := queue.jobs[0].Request if request.Action != "visual-annotation" || request.AnnotationImage != guideURL || request.Selection != selection { t.Fatalf("visual annotation request was not preserved in the job: %#v", request) } if request.Options[nodeActionResultNodeIDOption] != placeholder.ID { t.Fatalf("expected the job to target copied node %q, got %#v", placeholder.ID, request.Options) } task, err := service.GeneratorTask(ctx, project.ID, next.LastThreadID) if err != nil { t.Fatal(err) } if len(task.OriginalInputArgs.Image) != 2 || task.OriginalInputArgs.Image[0] != project.Nodes[0].Content || task.OriginalInputArgs.Image[1] != guideURL { t.Fatalf("expected clean source followed by annotation guide, got %#v", task.OriginalInputArgs.Image) } prompt := buildNodeActionPrompt(request, project.Nodes[0]) for _, fragment := range []string{"first image is the clean source", "second image is an annotated guide only", "never reproduce guide marks", selection} { if !strings.Contains(prompt, fragment) { t.Fatalf("expected visual annotation prompt to contain %q, got %q", fragment, prompt) } } }