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 }