Files

456 lines
13 KiB
Go
Raw Permalink Normal View History

package application
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"sync"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
cacheinfra "img_infinite_canvas/internal/infrastructure/cache"
"img_infinite_canvas/internal/infrastructure/memory"
)
func TestNewEditableTextLayersUsesOCRGeometry(t *testing.T) {
target := design.Node{
ID: "image-1",
X: 100,
Y: 200,
Width: 500,
Height: 400,
Content: "https://example.com/source.png",
}
nodes := newEditableTextLayers(target, design.TextExtraction{
Layers: []design.ExtractedTextLayer{
{
Text: "SUMMER SALE",
X: 0.1,
Y: 0.2,
Width: 0.5,
Height: 0.1,
FontSize: 24,
FontFamily: "Inter",
FontWeight: "800",
Color: "#223344",
TextAlign: "center",
LineHeight: 1.05,
LetterSpacing: 1.5,
StrokeColor: "#ffffff",
StrokeWidth: 2,
Opacity: 0.86,
},
},
})
if len(nodes) != 1 {
t.Fatalf("expected one text node, got %d", len(nodes))
}
node := nodes[0]
if node.X != 150 || node.Y != 280 || node.Width != 250 || node.Height != 40 {
t.Fatalf("unexpected geometry: %#v", node)
}
if node.Content != "SUMMER SALE" {
t.Fatalf("unexpected content: %q", node.Content)
}
if node.ParentID != target.ID || node.LayerRole != "editable-text" || node.Tone != "extracted-text" {
t.Fatalf("unexpected layer metadata: %#v", node)
}
if node.FontSize != 24 || node.FontFamily != "Inter" || node.FontWeight != "800" || node.Color != "#223344" || node.TextAlign != "center" {
t.Fatalf("unexpected text style: %#v", node)
}
if node.LineHeight != 1.05 || node.LetterSpacing != 1.5 || node.StrokeColor != "#ffffff" || node.StrokeWidth != 2 || node.Opacity != 0.86 {
t.Fatalf("unexpected text rendering style: %#v", node)
}
}
func TestExtractNodeTextCachesOCRResult(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
extractor := &countingTextExtractor{
result: design.TextExtraction{
Layers: []design.ExtractedTextLayer{
{Text: "SUMMER SALE", X: 0.1, Y: 0.2, Width: 0.3, Height: 0.1},
},
},
}
service := NewDesignService(repo, nil, nil, nil, extractor)
service.SetTextExtractionCache(cacheinfra.NewMemoryStore(), 10*time.Minute)
project := design.Project{
ID: "project-ocr-cache",
Title: "OCR cache",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{
ID: "image-1",
Type: design.NodeTypeImage,
Content: "https://example.com/source.png",
Width: 1024,
Height: 1024,
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
first, err := service.ExtractNodeText(ctx, project.ID, "image-1", "extract editable text")
if err != nil {
t.Fatal(err)
}
second, err := service.ExtractNodeText(ctx, project.ID, "image-1", "extract editable text")
if err != nil {
t.Fatal(err)
}
if calls := extractor.callCount(); calls != 1 {
t.Fatalf("expected one extractor call, got %d", calls)
}
if len(second.Layers) != 1 || second.Layers[0].Text != first.Layers[0].Text {
t.Fatalf("expected cached OCR result, got %#v", second)
}
if second.Layers[0].OriginalText != "SUMMER SALE" {
t.Fatalf("expected cached result to keep normalized original text, got %#v", second.Layers[0])
}
}
func TestExtractNodeTextAsyncReturnsResultThroughProjectThread(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
extractor := &countingTextExtractor{
result: design.TextExtraction{
Layers: []design.ExtractedTextLayer{
{Text: "专利", X: 0.2, Y: 0.1, Width: 0.16, Height: 0.05},
},
},
}
service := NewDesignService(repo, nil, nil, nil, extractor)
service.SetTextExtractionCache(cacheinfra.NewMemoryStore(), 10*time.Minute)
project := design.Project{
ID: "project-ocr-async",
Title: "OCR async",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{
ID: "image-async",
Type: design.NodeTypeImage,
Title: "Patent",
Content: "https://example.com/patent.png",
Status: "success",
Width: 1768,
Height: 2556,
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
thread, err := service.ExtractNodeTextAsync(ctx, project.ID, "image-async", "eitdortxt")
if err != nil {
t.Fatal(err)
}
if thread.ThreadID == "" || thread.Project.Nodes[0].Status != "text-extracting" {
t.Fatalf("expected running text extraction thread, got %#v", thread)
}
saved := waitForTextExtractionResult(t, repo, project.ID)
if saved.Status != design.StatusReady {
t.Fatalf("expected project ready, got %s", saved.Status)
}
if saved.Nodes[0].Status != "success" || saved.Nodes[0].Title != "Patent" {
t.Fatalf("expected node restored, got %#v", saved.Nodes[0])
}
resultMessage := saved.Messages[len(saved.Messages)-1]
if resultMessage.Type != textExtractionResultMessageType || resultMessage.Name != "text_extraction" {
t.Fatalf("expected text extraction result message, got %#v", resultMessage)
}
if !strings.Contains(resultMessage.Content, `"text":"专利"`) {
t.Fatalf("expected serialized OCR result, got %s", resultMessage.Content)
}
if strings.Contains(resultMessage.Content, "先识别图片中所有可见文字") {
t.Fatalf("internal OCR prompt leaked into result message: %s", resultMessage.Content)
}
if calls := extractor.callCount(); calls != 1 {
t.Fatalf("expected one extractor call, got %d", calls)
}
}
func TestGetProjectRestoresNodeWhenTextExtractionAlreadyFinished(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-finished-ocr-status",
Title: "Finished OCR status",
Status: design.StatusReady,
UpdatedAt: time.Now(),
LastThreadID: "thread-finished",
Nodes: []design.Node{
{
ID: "image-finished",
Type: design.NodeTypeImage,
Title: "IMG_1225.JPG",
Content: "https://example.com/image.jpg",
Status: "text-extracting",
Width: 433,
Height: 769,
},
},
Messages: []design.Message{
{
ID: "message-result",
Role: "tool",
Type: textExtractionResultMessageType,
Name: "text_extraction",
Status: "success",
ThreadID: "thread-finished",
Content: `{"items":[]}`,
CreatedAt: time.Now(),
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
restored, err := service.GetProject(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if restored.Nodes[0].Status != "success" {
t.Fatalf("expected completed text extraction to restore node status, got %#v", restored.Nodes[0])
}
persisted, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if persisted.Nodes[0].Status != "success" {
t.Fatalf("expected restored status to be persisted, got %#v", persisted.Nodes[0])
}
}
func TestGetProjectRestoresStaleTextExtractionNode(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-stale-ocr-status",
Title: "Stale OCR status",
Status: design.StatusReady,
UpdatedAt: time.Now().Add(-textExtractionStaleAfter),
LastThreadID: "thread-stale",
Nodes: []design.Node{
{
ID: "image-stale",
Type: design.NodeTypeImage,
Content: "https://example.com/image.jpg",
Status: "text-extracting",
},
},
Messages: []design.Message{
{
ID: "message-running",
Role: "tool",
Type: "tool",
Name: "text_extraction",
Status: "running",
ThreadID: "thread-stale",
CreatedAt: time.Now().Add(-textExtractionStaleAfter - time.Second),
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
restored, err := service.GetProject(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if restored.Nodes[0].Status != "success" {
t.Fatalf("expected stale text extraction to restore node status, got %#v", restored.Nodes[0])
}
if restored.Messages[len(restored.Messages)-1].Status != "failed" || restored.Messages[len(restored.Messages)-1].Name != "text_extraction" {
t.Fatalf("expected stale extraction failure message, got %#v", restored.Messages[len(restored.Messages)-1])
}
}
func TestSaveCanvasDoesNotRePersistCompletedTextExtractionStatus(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-save-stale-ocr",
Title: "Save stale OCR",
Status: design.StatusReady,
UpdatedAt: time.Now(),
LastThreadID: "thread-complete",
Viewport: design.Viewport{K: 1},
Nodes: []design.Node{
{
ID: "image-save-stale",
Type: design.NodeTypeImage,
Content: "https://example.com/image.jpg",
Status: "success",
},
},
Messages: []design.Message{
{
ID: "message-complete",
Role: "tool",
Type: textExtractionResultMessageType,
Name: "text_extraction",
Status: "success",
ThreadID: "thread-complete",
Content: `{"items":[]}`,
CreatedAt: time.Now(),
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
staleClientNode := project.Nodes[0]
staleClientNode.Status = "text-extracting"
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{K: 1}, []design.Node{staleClientNode}, nil, "", "", "")
if err != nil {
t.Fatal(err)
}
if saved.Nodes[0].Status != "success" {
t.Fatalf("expected save to ignore stale client text-extracting status, got %#v", saved.Nodes[0])
}
}
func waitForTextExtractionResult(t *testing.T, repo *memory.ProjectRepository, projectID string) design.Project {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
project, err := repo.Get(context.Background(), projectID)
if err != nil {
t.Fatal(err)
}
for _, message := range project.Messages {
if message.Type == textExtractionResultMessageType {
return project
}
}
time.Sleep(10 * time.Millisecond)
}
project, _ := repo.Get(context.Background(), projectID)
t.Fatalf("timed out waiting for text extraction result: %#v", project.Messages)
return design.Project{}
}
func TestCreateTextRemovedImageRemovesTextWithoutBoxArtifact(t *testing.T) {
source := image.NewRGBA(image.Rect(0, 0, 120, 60))
for y := 0; y < 60; y++ {
for x := 0; x < 120; x++ {
source.SetRGBA(x, y, color.RGBA{R: 245, G: 246, B: 247, A: 255})
}
}
for y := 14; y < 28; y++ {
for x := 16; x < 68; x++ {
source.SetRGBA(x, y, color.RGBA{A: 255})
}
}
for y := 13; y < 29; y++ {
source.SetRGBA(15, y, color.RGBA{R: 105, G: 106, B: 107, A: 255})
source.SetRGBA(68, y, color.RGBA{R: 105, G: 106, B: 107, A: 255})
}
for x := 15; x < 69; x++ {
source.SetRGBA(x, 13, color.RGBA{R: 105, G: 106, B: 107, A: 255})
source.SetRGBA(x, 28, color.RGBA{R: 105, G: 106, B: 107, A: 255})
}
var sourceData bytes.Buffer
if err := png.Encode(&sourceData, source); err != nil {
t.Fatal(err)
}
service := NewDesignService(nil, nil, nil, nil, nil)
cleanURL, err := service.createTextRemovedImage(t.Context(), "project", design.Node{
ID: "image",
Type: design.NodeTypeImage,
Content: "data:image/png;base64," + base64.StdEncoding.EncodeToString(sourceData.Bytes()),
}, design.TextExtraction{Layers: []design.ExtractedTextLayer{
{Text: "stillday", X: 0.12, Y: 0.18, Width: 0.48, Height: 0.28, Color: "#000000"},
}})
if err != nil {
t.Fatal(err)
}
data, _, _, ok, err := decodeDataImage(cleanURL)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatalf("expected data url result, got %s", cleanURL)
}
cleaned, err := png.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
blackPixels := 0
for y := 14; y < 28; y++ {
for x := 16; x < 68; x++ {
pixel := color.RGBAModel.Convert(cleaned.At(x, y)).(color.RGBA)
if pixel.R < 30 && pixel.G < 30 && pixel.B < 30 {
blackPixels++
}
}
}
if blackPixels > 0 {
t.Fatalf("expected black text pixels to be removed, got %d", blackPixels)
}
edgePixels := 0
for y := 13; y < 29; y++ {
for x := 15; x < 69; x++ {
pixel := color.RGBAModel.Convert(cleaned.At(x, y)).(color.RGBA)
if pixel.R >= 80 && pixel.R <= 130 && pixel.G >= 80 && pixel.G <= 130 && pixel.B >= 80 && pixel.B <= 130 {
edgePixels++
}
}
}
if edgePixels > 0 {
t.Fatalf("expected antialias/shadow pixels to be removed, got %d", edgePixels)
}
unchanged := color.RGBAModel.Convert(cleaned.At(90, 40)).(color.RGBA)
if unchanged.R != 245 || unchanged.G != 246 || unchanged.B != 247 {
t.Fatalf("expected unrelated background to stay intact, got %#v", unchanged)
}
}
type countingTextExtractor struct {
mu sync.Mutex
calls int
result design.TextExtraction
}
func (f *countingTextExtractor) ExtractText(ctx context.Context, req design.TextExtractionRequest) (design.TextExtraction, error) {
if err := ctx.Err(); err != nil {
return design.TextExtraction{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
return f.result, nil
}
func (f *countingTextExtractor) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}