Files
moteva/server/internal/application/layer_merge_test.go
T
root 07aa33d30e feat(image): preserve transparency for alpha images end to end
Skip WebP re-encoding for PNG/AVIF/BMP uploads and any bitmap that
actually contains alpha, tag transparency-capable canvas uploads with
the transparent-image tone, and detect alpha in merged layers so the
composed node keeps a transparent tone.

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

260 lines
7.2 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 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
}