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>
456 lines
13 KiB
Go
456 lines
13 KiB
Go
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
|
|
}
|