Files
moteva/server/internal/infrastructure/piagent/design_agent_test.go
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:15:37 +08:00

447 lines
14 KiB
Go

package piagent
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"testing"
"unicode/utf8"
"img_infinite_canvas/internal/domain/design"
)
type fakeRunner struct{}
func (fakeRunner) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
return design.AgentPlan{
Message: design.Message{ID: "message-1", Role: "assistant", Title: "done", Content: "done"},
GeneratedNodes: []design.Node{
{
ID: "node-1",
Type: design.NodeTypeFrame,
Title: "Website Hero",
Width: 520,
Height: 300,
Content: req.Prompt,
},
{
ID: "mock-image-1",
Type: design.NodeTypeImage,
Title: "Mood x Color",
Width: 520,
Height: 640,
Content: req.Prompt,
},
{
ID: "mock-image-2",
Type: design.NodeTypeImage,
Title: "Mobile Page",
Width: 260,
Height: 460,
Content: req.Prompt,
},
},
}, nil
}
func (fakeRunner) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) {
return design.AgentReplay{ProjectID: project.ID, Messages: project.Messages}, nil
}
type fakeImageGenerator struct {
mu sync.Mutex
called bool
prompt string
opts ImageRequestOptions
calls int
options []ImageRequestOptions
}
type failingImageGenerator struct {
calls int
err error
}
type partialFailImageGenerator struct {
mu sync.Mutex
calls int
}
func (g *failingImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.calls++
return GeneratedImage{}, g.err
}
func (g *partialFailImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.mu.Lock()
g.calls++
call := g.calls
g.mu.Unlock()
if call == 1 {
return GeneratedImage{URL: "https://example.com/generated-partial.png", URLs: []string{"https://example.com/generated-partial.png"}}, nil
}
return GeneratedImage{}, errors.New("upstream response did not return image")
}
func (g *fakeImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.mu.Lock()
defer g.mu.Unlock()
g.called = true
g.prompt = prompt
g.opts = opts
g.calls++
callIndex := imageOptionIndex(prompt, g.calls)
g.options = append(g.options, opts)
count := opts.Count
if count < 1 {
count = 1
}
urls := make([]string, 0, count)
for index := 0; index < count; index++ {
urls = append(urls, "https://example.com/generated-"+strconv.Itoa(callIndex+index)+".png")
}
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
func imageOptionIndex(prompt string, fallback int) int {
for index := 1; index <= 10; index++ {
if strings.Contains(prompt, "option "+strconv.Itoa(index)+" of") {
return index
}
}
return fallback
}
func (g *fakeImageGenerator) callCount() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.calls
}
type fakeCreativeAgent struct{}
func (fakeCreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
return design.AgentDecision{}, nil
}
func (fakeCreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return "", nil
}
func (fakeCreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
return "这张图已经生成。建议下一步检查首屏构图、商品露出和转化按钮层级。", nil
}
func (fakeCreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
return design.AgentImagePrompt{
Title: "Still Day 首页升级",
Prompt: "基于 still day 现有首页方向,保留高饱和撞色,升级电商首屏、商品网格和品牌页脚。",
}, nil
}
type longCreativeAgent struct{}
func (longCreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
return design.AgentDecision{}, nil
}
func (longCreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return "", nil
}
func (longCreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
return "", nil
}
func (longCreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
return design.AgentImagePrompt{
Title: "精简生图指令",
Prompt: strings.Repeat("大量上下文不应该直接进入 gpt-image-2。", 240),
}, nil
}
func TestDesignAgentGeneratesImageThroughPiAgent(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "image",
ImageModel: "gpt-image-2",
ImageSize: "1536x1024",
})
if err != nil {
t.Fatal(err)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if !containsAll(imageGenerator.prompt, []string{"基于 still day 现有首页方向", "高饱和撞色"}) {
t.Fatalf("unexpected prompt: %s", imageGenerator.prompt)
}
if imageGenerator.opts.Model != "gpt-image-2" || imageGenerator.opts.Size != "1536x1024" {
t.Fatalf("unexpected image options: %#v", imageGenerator.opts)
}
if imageGenerator.opts.Count != 1 {
t.Fatalf("expected one image request, got %d", imageGenerator.opts.Count)
}
if len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one node, got %d", len(plan.GeneratedNodes))
}
node := plan.GeneratedNodes[0]
if node.Type != design.NodeTypeImage {
t.Fatalf("expected image node, got %s", node.Type)
}
if node.Content != "https://example.com/generated-1.png" {
t.Fatalf("expected generated image url, got %s", node.Content)
}
if node.Title != "Still Day 首页升级" {
t.Fatalf("expected generated image title, got %s", node.Title)
}
if node.Width != 1536 || node.Height != 1024 {
t.Fatalf("expected node size from request image size, got %.0fx%.0f", node.Width, node.Height)
}
if plan.Message.Title != "Still Day 首页升级 已生成" {
t.Fatalf("expected contextual completion title, got %s", plan.Message.Title)
}
if plan.Message.Content == "" {
t.Fatal("expected image generation summary")
}
if !containsAll(plan.Message.Content, []string{"首屏构图", "商品露出", "转化按钮"}) {
t.Fatalf("expected creative agent summary, got %s", plan.Message.Content)
}
}
func TestDesignAgentUsesDefaultImageSizeWithoutExplicitRequest(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "design",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.opts.Size != "1024x1024" {
t.Fatalf("expected default image size, got %q", imageGenerator.opts.Size)
}
if len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one generated node, got %d", len(plan.GeneratedNodes))
}
if plan.GeneratedNodes[0].Width != 1024 || plan.GeneratedNodes[0].Height != 1024 {
t.Fatalf("expected default generated image size, got %.0fx%.0f", plan.GeneratedNodes[0].Width, plan.GeneratedNodes[0].Height)
}
}
func TestDesignAgentUsesExplicitPromptImageRatio(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 16:9 的 still day 官网首页图片",
Mode: "design",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.opts.Size != "2048x1152" {
t.Fatalf("expected explicit 16:9 image size, got %q", imageGenerator.opts.Size)
}
if len(plan.GeneratedNodes) != 1 || plan.GeneratedNodes[0].Width != 2048 || plan.GeneratedNodes[0].Height != 1152 {
t.Fatalf("expected node size from explicit ratio, got %#v", plan.GeneratedNodes)
}
}
func TestDesignAgentGeneratesModelPlannedImageTasks(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "设计二次元水墨人物形象,输出角色正面、侧面、细节和色彩方案。",
Mode: "image",
ImageModel: "gpt-image-2",
ImageSize: "1024x1024",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 3,
ImageTasks: []design.AgentImageTask{
{Title: "水墨角色_正面", Brief: "正面全身立绘"},
{Title: "水墨角色_侧面", Brief: "侧面全身立绘"},
{Title: "水墨角色_细节", Brief: "局部细节拆解"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating", X: 10},
{ID: "slot-2", Type: design.NodeTypeImage, Status: "generating", X: 20},
{ID: "slot-3", Type: design.NodeTypeImage, Status: "generating", X: 30},
},
},
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.callCount() != 3 {
t.Fatalf("expected 3 image calls, got %d", imageGenerator.callCount())
}
idempotencyKeys := make(map[string]bool)
for _, opts := range imageGenerator.options {
if opts.Count != 1 {
t.Fatalf("expected individual image request count 1, got %#v", imageGenerator.options)
}
if opts.IdempotencyKey == "" {
t.Fatalf("expected idempotency key for image request, got %#v", imageGenerator.options)
}
if idempotencyKeys[opts.IdempotencyKey] {
t.Fatalf("expected unique idempotency keys, got %#v", imageGenerator.options)
}
idempotencyKeys[opts.IdempotencyKey] = true
}
if len(plan.GeneratedNodes) != 3 {
t.Fatalf("expected 3 generated nodes, got %#v", plan.GeneratedNodes)
}
for index, node := range plan.GeneratedNodes {
wantID := "slot-" + strconv.Itoa(index+1)
if node.ID != wantID || node.Content == "" || node.Status != "success" {
t.Fatalf("unexpected generated node %d: %#v", index, node)
}
}
}
func TestDesignAgentGeneratesWhenTaskPlanIntentIsImageEvenInAgentMode(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Prompt: "来一版",
Mode: "agent",
ImageModel: "gpt-image-2",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 1,
ImageTasks: []design.AgentImageTask{
{Title: "画面方案", Brief: "生成一个高级电商主视觉,强调产品层次和留白。"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating", X: 10},
},
},
}, nil)
if err != nil {
t.Fatal(err)
}
if imageGenerator.callCount() != 1 {
t.Fatalf("expected image generation to run from task plan intent, got %d calls", imageGenerator.callCount())
}
if len(plan.GeneratedNodes) != 1 || plan.GeneratedNodes[0].Content == "" {
t.Fatalf("expected generated image node, got %#v", plan.GeneratedNodes)
}
}
func TestDesignAgentReturnsPartialFailureWhenPlannedImageIsMissing(t *testing.T) {
imageGenerator := &partialFailImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
var emitted []design.Node
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Prompt: "生成两张视觉",
Mode: "image",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 2,
ImageTasks: []design.AgentImageTask{
{Title: "方案一", Brief: "生成第一张视觉"},
{Title: "方案二", Brief: "生成第二张视觉"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating"},
{ID: "slot-2", Type: design.NodeTypeImage, Status: "generating"},
},
},
}, func(result design.AgentImageGenerationResult) error {
emitted = append(emitted, result.Node)
return nil
})
if err == nil {
t.Fatal("expected partial generation failure")
}
if !strings.Contains(err.Error(), "only 1 of 2 planned images returned") {
t.Fatalf("expected partial failure count, got %v", err)
}
if len(emitted) != 1 || len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one successful node to be preserved, emitted=%#v plan=%#v", emitted, plan.GeneratedNodes)
}
}
func TestGeneratedImageSlotsIgnoreCanvasActionPlaceholders(t *testing.T) {
slots := generatedImageSlots(design.AgentRequest{
Project: design.Project{
Nodes: []design.Node{
{ID: "background-placeholder", Type: design.NodeTypeImage, Status: "generating", LayerRole: "background-removed"},
{ID: "agent-placeholder", Type: design.NodeTypeImage, Status: "generating"},
},
},
}, nil, 1)
if len(slots) != 1 || slots[0].ID != "agent-placeholder" {
t.Fatalf("expected agent placeholder slot, got %#v", slots)
}
}
func TestDesignAgentDoesNotRunFallbackAfterImageTimeout(t *testing.T) {
imageGenerator := &failingImageGenerator{err: context.DeadlineExceeded}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "image",
ImageModel: "gpt-image-2",
})
if err == nil {
t.Fatal("expected image timeout error")
}
if imageGenerator.calls != 1 {
t.Fatalf("expected timeout to skip fallback generation, got %d calls", imageGenerator.calls)
}
}
func TestDesignAgentCompactsPromptBeforeGPTImage(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, longCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "根据当前画布生成一张电商主视觉",
Mode: "image",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if utf8.RuneCountInString(imageGenerator.prompt) > imageGenerationPromptTooLargeRunes {
t.Fatalf("expected compact image prompt, got %d runes", utf8.RuneCountInString(imageGenerator.prompt))
}
if containsAll(imageGenerator.prompt, []string{"任务类型", "主体描述", "风格定义", "技术参数", "输出规格"}) {
t.Fatalf("expected compact prompt without forced framework, got %s", imageGenerator.prompt)
}
}
func containsAll(value string, needles []string) bool {
for _, needle := range needles {
if !strings.Contains(value, needle) {
return false
}
}
return true
}