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 TestMergeLayersPreservesTransparentPixels(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) transparentLayer := image.NewNRGBA(image.Rect(0, 0, 3, 2)) transparentLayer.SetNRGBA(1, 0, color.NRGBA{R: 255, A: 255}) project := design.Project{ ID: "project-merge-transparent", Title: "Merge transparent", Status: design.StatusReady, UpdatedAt: time.Now(), Nodes: []design.Node{ { ID: "transparent", Type: design.NodeTypeImage, Title: "Transparent", X: 0, Y: 0, Width: 3, Height: 2, Content: testPNGDataURLFromImage(transparentLayer), Status: "success", }, { ID: "blue", Type: design.NodeTypeImage, Title: "Blue", X: 2, Y: 1, Width: 1, Height: 1, Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 1, 1), Status: "success", }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } if _, err := service.MergeLayers(ctx, project.ID, []string{"transparent", "blue"}, "Transparent merge"); err != nil { t.Fatal(err) } merged := waitForMergedProject(t, repo, project.ID) node := merged.Nodes[0] if node.Tone != "transparent-image" { t.Fatalf("expected transparent merged image tone, got %q", node.Tone) } 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{}) assertPixel(t, decoded, 1, 0, color.NRGBA{R: 255, A: 255}) assertPixel(t, decoded, 2, 1, color.NRGBA{B: 255, A: 255}) } 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) } } return testPNGDataURLFromImage(img) } func testPNGDataURLFromImage(img image.Image) string { 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 }