44406b72db
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>
222 lines
6.8 KiB
Go
222 lines
6.8 KiB
Go
package application
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"testing"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
"img_infinite_canvas/internal/infrastructure/memory"
|
|
)
|
|
|
|
type fakeBackgroundRemover struct {
|
|
result design.BackgroundRemoval
|
|
err error
|
|
calls int
|
|
last design.BackgroundRemovalRequest
|
|
}
|
|
|
|
func (r *fakeBackgroundRemover) RemoveBackground(ctx context.Context, req design.BackgroundRemovalRequest) (design.BackgroundRemoval, error) {
|
|
r.calls++
|
|
r.last = req
|
|
if r.err != nil {
|
|
return design.BackgroundRemoval{}, r.err
|
|
}
|
|
return r.result, nil
|
|
}
|
|
|
|
func TestRunRemoveBackgroundCreatesAsyncPlaceholderBesideTarget(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
queue := &fakeJobQueue{}
|
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
|
service.SetJobQueue(queue)
|
|
|
|
source := testPNGDataURL(testColor(32, 96, 180, 255), 4, 3)
|
|
project := design.Project{
|
|
ID: "project-remove-bg-placeholder",
|
|
Title: "Remove background",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{{
|
|
ID: "image-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Source",
|
|
X: 100,
|
|
Y: 120,
|
|
Width: 200,
|
|
Height: 150,
|
|
Content: source,
|
|
Status: "success",
|
|
}},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
next, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{
|
|
Action: "remove-background",
|
|
Prompt: "去背景",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(next.Nodes) != 2 {
|
|
t.Fatalf("expected source plus placeholder, got %#v", next.Nodes)
|
|
}
|
|
sourceNode := next.Nodes[0]
|
|
placeholder := next.Nodes[1]
|
|
if sourceNode.Status != "success" || sourceNode.Title != "Source" {
|
|
t.Fatalf("expected source node to remain unchanged, got %#v", sourceNode)
|
|
}
|
|
if placeholder.Status != "generating" || placeholder.Title != "去背景中" {
|
|
t.Fatalf("expected generating placeholder, got %#v", placeholder)
|
|
}
|
|
if placeholder.X != sourceNode.X+sourceNode.Width+32 || placeholder.Y != sourceNode.Y {
|
|
t.Fatalf("expected placeholder beside source, got x=%f y=%f", placeholder.X, placeholder.Y)
|
|
}
|
|
if placeholder.Content != sourceNode.Content || placeholder.ParentID != sourceNode.ID {
|
|
t.Fatalf("expected placeholder to reference source image, got %#v", placeholder)
|
|
}
|
|
if placeholder.LayerRole != "background-removed" || placeholder.Tone != "transparent-image" {
|
|
t.Fatalf("expected transparent-image placeholder role, got %#v", placeholder)
|
|
}
|
|
if len(queue.jobs) != 1 {
|
|
t.Fatalf("expected one async job, got %#v", queue.jobs)
|
|
}
|
|
if queue.jobs[0].Kind != design.JobNodeActionGeneration || queue.jobs[0].Request.Action != "remove-background" {
|
|
t.Fatalf("expected remove-background job, got %#v", queue.jobs[0])
|
|
}
|
|
if queue.jobs[0].Request.Options[nodeActionResultNodeIDOption] != placeholder.ID {
|
|
t.Fatalf("expected job to carry result node id %s, got %#v", placeholder.ID, queue.jobs[0].Request.Options)
|
|
}
|
|
task, err := service.GeneratorTask(ctx, project.ID, next.LastThreadID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task.GeneratorName != removeBackgroundGeneratorName || task.OriginalInputArgs.Prompt != "" {
|
|
t.Fatalf("expected removeBG task without prompt, got generator=%q prompt=%q", task.GeneratorName, task.OriginalInputArgs.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestCompleteRemoveBackgroundWritesResultToPlaceholder(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
assets := newFakeAssetStorage()
|
|
queue := &fakeJobQueue{}
|
|
remover := &fakeBackgroundRemover{
|
|
result: design.BackgroundRemoval{
|
|
Image: testPNGBytes(testColor(32, 96, 180, 255), 4, 3),
|
|
ContentType: "image/png",
|
|
Width: 4,
|
|
Height: 3,
|
|
},
|
|
}
|
|
service := NewDesignService(repo, nil, assets, nil, nil)
|
|
service.SetJobQueue(queue)
|
|
service.SetBackgroundRemover(remover)
|
|
|
|
project := design.Project{
|
|
ID: "project-remove-bg-complete",
|
|
Title: "Remove background",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{{
|
|
ID: "image-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Source",
|
|
X: 100,
|
|
Y: 120,
|
|
Width: 200,
|
|
Height: 150,
|
|
Content: testPNGDataURL(testColor(32, 96, 180, 255), 4, 3),
|
|
Status: "success",
|
|
}},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
started, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{
|
|
Action: "remove-background",
|
|
Options: map[string]string{
|
|
"imageWidth": "1002",
|
|
"imageHeight": "1002",
|
|
"inputSize": "1024",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(queue.jobs) != 1 {
|
|
t.Fatalf("expected one queued job, got %#v", queue.jobs)
|
|
}
|
|
if err := service.ProcessJob(ctx, queue.jobs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
saved, err := repo.Get(ctx, started.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if remover.calls != 1 {
|
|
t.Fatalf("expected one background removal call, got %d", remover.calls)
|
|
}
|
|
if remover.last.Width != 1002 || remover.last.Height != 1002 || remover.last.InputSize != 1024 {
|
|
t.Fatalf("expected target dimensions in background removal request, got %#v", remover.last)
|
|
}
|
|
if len(saved.Nodes) != 2 {
|
|
t.Fatalf("expected source plus result node, got %#v", saved.Nodes)
|
|
}
|
|
if saved.Nodes[0].Status != "success" || saved.Nodes[0].Content != project.Nodes[0].Content {
|
|
t.Fatalf("expected source node unchanged, got %#v", saved.Nodes[0])
|
|
}
|
|
result := saved.Nodes[1]
|
|
if result.Status != "success" || result.Title != "Background Removed" {
|
|
t.Fatalf("expected completed result node, got %#v", result)
|
|
}
|
|
if result.LayerRole != "background-removed" || result.Tone != "transparent-image" {
|
|
t.Fatalf("expected transparent PNG result role, got %#v", result)
|
|
}
|
|
if result.Content != "http://localhost:19000/canvas-assets/project-remove-bg-complete-image-1-remove-background.webp" {
|
|
t.Fatalf("unexpected result URL %s", result.Content)
|
|
}
|
|
if saved.Status != design.StatusReady {
|
|
t.Fatalf("expected ready project, got %s", saved.Status)
|
|
}
|
|
if len(saved.Messages) == 0 || saved.Messages[len(saved.Messages)-1].Status != "success" {
|
|
t.Fatalf("expected success message, got %#v", saved.Messages)
|
|
}
|
|
select {
|
|
case upload := <-assets.uploaded:
|
|
if upload.ContentType != "image/webp" {
|
|
t.Fatalf("expected webp upload, got %s", upload.ContentType)
|
|
}
|
|
default:
|
|
t.Fatal("expected uploaded background removal asset")
|
|
}
|
|
}
|
|
|
|
func testColor(r uint8, g uint8, b uint8, a uint8) color.NRGBA {
|
|
return color.NRGBA{R: r, G: g, B: b, A: a}
|
|
}
|
|
|
|
func testPNGBytes(fill color.NRGBA, width int, height int) []byte {
|
|
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
img.SetNRGBA(x, y, fill)
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
panic(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|