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>
191 lines
5.3 KiB
Go
191 lines
5.3 KiB
Go
package application
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"testing"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
"img_infinite_canvas/internal/infrastructure/memory"
|
|
)
|
|
|
|
func TestMergeLayersFlattensImagesInProjectLayerOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
|
project := design.Project{
|
|
ID: "project-merge-layers",
|
|
Title: "Merge layers",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "red",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Red",
|
|
X: 0,
|
|
Y: 0,
|
|
Width: 2,
|
|
Height: 2,
|
|
Content: testPNGDataURL(color.NRGBA{R: 255, A: 255}, 2, 2),
|
|
Status: "success",
|
|
},
|
|
{
|
|
ID: "blue",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Blue",
|
|
X: 1,
|
|
Y: 0,
|
|
Width: 2,
|
|
Height: 2,
|
|
Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 2, 2),
|
|
Status: "success",
|
|
},
|
|
},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
merged, err := service.MergeLayers(ctx, project.ID, []string{"blue", "red"}, "Merged")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(merged.Nodes) != 3 {
|
|
t.Fatalf("expected source layers plus async placeholder, got %#v", merged.Nodes)
|
|
}
|
|
placeholder := merged.Nodes[2]
|
|
if placeholder.Status != "generating" || placeholder.LayerRole != "merge-layer" || placeholder.Content != "" {
|
|
t.Fatalf("expected async merge placeholder, got %#v", merged.Nodes)
|
|
}
|
|
|
|
merged = waitForMergedProject(t, repo, project.ID)
|
|
if len(merged.Nodes) != 1 {
|
|
t.Fatalf("expected one merged node, got %d", len(merged.Nodes))
|
|
}
|
|
node := merged.Nodes[0]
|
|
if node.Title != "Merged" || node.X != 0 || node.Y != 0 || node.Width != 3 || node.Height != 2 {
|
|
t.Fatalf("unexpected merged node geometry: %#v", node)
|
|
}
|
|
|
|
imageData, _, _, ok, err := decodeDataImage(node.Content)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("expected merged content data URL, got %s", node.Content)
|
|
}
|
|
decoded, err := png.Decode(bytes.NewReader(imageData))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertPixel(t, decoded, 0, 0, color.NRGBA{R: 255, A: 255})
|
|
assertPixel(t, decoded, 1, 0, color.NRGBA{B: 255, A: 255})
|
|
assertPixel(t, decoded, 2, 0, color.NRGBA{B: 255, A: 255})
|
|
}
|
|
|
|
func TestMergeLayersFlattensTextOverImage(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
|
project := design.Project{
|
|
ID: "project-merge-text",
|
|
Title: "Merge text",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{
|
|
{ID: "image", Type: design.NodeTypeImage, Content: testPNGDataURL(color.NRGBA{R: 255, G: 255, B: 255, A: 255}, 120, 60), Width: 120, Height: 60, Status: "success"},
|
|
{ID: "text", Type: design.NodeTypeText, Content: "123", X: 0, Y: 0, Width: 120, Height: 60, Status: "draft", FontSize: 28, Color: "#000000", TextAlign: "center"},
|
|
},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := service.MergeLayers(ctx, project.ID, []string{"image", "text"}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
merged := waitForMergedProject(t, repo, project.ID)
|
|
imageData, _, _, ok, err := decodeDataImage(merged.Nodes[0].Content)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("expected merged content data URL, got %s", merged.Nodes[0].Content)
|
|
}
|
|
decoded, err := png.Decode(bytes.NewReader(imageData))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if countNonWhitePixels(decoded) == 0 {
|
|
t.Fatal("expected text layer to be painted into merged image")
|
|
}
|
|
}
|
|
|
|
func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
|
|
t.Helper()
|
|
deadline := time.After(2 * time.Second)
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for async layer merge")
|
|
case <-ticker.C:
|
|
project, err := repo.Get(context.Background(), projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if project.Status == design.StatusReady && len(project.Nodes) == 1 && project.Nodes[0].Status == "success" {
|
|
return project
|
|
}
|
|
if project.Status == design.StatusFailed {
|
|
t.Fatalf("merge failed: %#v", project.Messages)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func testPNGDataURL(fill color.NRGBA, width int, height int) string {
|
|
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 "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
|
}
|
|
|
|
func assertPixel(t *testing.T, img image.Image, x int, y int, expected color.NRGBA) {
|
|
t.Helper()
|
|
got := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
|
|
if got != expected {
|
|
t.Fatalf("pixel(%d,%d): got %#v, want %#v", x, y, got, expected)
|
|
}
|
|
}
|
|
|
|
func countNonWhitePixels(img image.Image) int {
|
|
bounds := img.Bounds()
|
|
count := 0
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
pixel := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
|
|
if pixel.A > 0 && (pixel.R < 245 || pixel.G < 245 || pixel.B < 245) {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|