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>
235 lines
8.0 KiB
Go
235 lines
8.0 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"image/color"
|
|
"testing"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
"img_infinite_canvas/internal/infrastructure/memory"
|
|
)
|
|
|
|
func TestGenerateMockupModelUsesAnalyzerAndPersistsInlineModelData(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
|
analyzer := &fakeMockupAnalyzer{}
|
|
service.SetMockupModelAnalyzer(analyzer)
|
|
project := design.Project{
|
|
ID: "project-mockup-model",
|
|
Title: "Mockup model",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "target",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Target",
|
|
Width: 640,
|
|
Height: 800,
|
|
Content: testPNGDataURL(color.NRGBA{R: 220, G: 220, B: 210, A: 255}, 6, 8),
|
|
Status: "success",
|
|
},
|
|
},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
first, err := service.GenerateMockupModel(ctx, project.ID, "target")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !hasMockupModel(first.Model) {
|
|
t.Fatalf("expected generated mockup model, got %#v", first.Model)
|
|
}
|
|
if len(first.Model.DepthMap.Data) != first.Model.DepthMap.Width*first.Model.DepthMap.Height {
|
|
t.Fatalf("expected inline depth data, got %#v", first.Model.DepthMap)
|
|
}
|
|
|
|
saved, err := repo.Get(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !hasMockupModel(saved.Nodes[0].MockupModel) {
|
|
t.Fatalf("expected model to be persisted on target node, got %#v", saved.Nodes[0].MockupModel)
|
|
}
|
|
if saved.Nodes[0].MockupSourceContent != project.Nodes[0].Content {
|
|
t.Fatal("expected source content fingerprint to be persisted")
|
|
}
|
|
|
|
second, err := service.GenerateMockupModel(ctx, project.ID, "target")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if analyzer.calls != 1 {
|
|
t.Fatalf("expected cached inline model to avoid a second analyzer call, got %d", analyzer.calls)
|
|
}
|
|
if len(second.Model.DepthMap.Data) != len(first.Model.DepthMap.Data) {
|
|
t.Fatal("expected cached inline model data")
|
|
}
|
|
}
|
|
|
|
func TestGenerateMockupModelAsyncQueuesAndCompletesInlineModel(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
queue := &fakeJobQueue{}
|
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
|
service.SetJobQueue(queue)
|
|
service.SetMockupModelAnalyzer(&fakeMockupAnalyzer{})
|
|
project := design.Project{
|
|
ID: "project-mockup-model-async",
|
|
Title: "Mockup model async",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "target",
|
|
Type: design.NodeTypeImage,
|
|
Title: "Target",
|
|
Width: 640,
|
|
Height: 800,
|
|
Content: testPNGDataURL(color.NRGBA{R: 220, G: 220, B: 210, A: 255}, 6, 8),
|
|
Status: "success",
|
|
},
|
|
},
|
|
}
|
|
if err := repo.Save(ctx, project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
thread, err := service.GenerateMockupModelAsync(ctx, project.ID, "target")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.Status != "running" || thread.ThreadID == "" {
|
|
t.Fatalf("expected running mockup thread, got %#v", thread)
|
|
}
|
|
if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobMockupModelGeneration || queue.jobs[0].ThreadID != thread.ThreadID {
|
|
t.Fatalf("expected mockup model job, got %#v", queue.jobs)
|
|
}
|
|
saved, err := repo.Get(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.Nodes[0].Status != "mockup-modeling" || saved.Status != design.StatusExploring {
|
|
t.Fatalf("expected mockup node to be marked active, got project=%s node=%#v", saved.Status, saved.Nodes[0])
|
|
}
|
|
if len(saved.Messages) != 2 || saved.Messages[0].Name != "mockup_model" || saved.Messages[0].Status != "running" || saved.Messages[1].Name != "generator_task" {
|
|
t.Fatalf("expected running mockup message, got %#v", saved.Messages)
|
|
}
|
|
|
|
if err := service.ProcessJob(ctx, queue.jobs[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, err = repo.Get(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.Nodes[0].Status != "success" || !mockupModelCurrent(saved.Nodes[0]) {
|
|
t.Fatalf("expected completed inline mockup model, got %#v", saved.Nodes[0])
|
|
}
|
|
if len(saved.Messages) != 3 || saved.Messages[2].Name != "mockup_model" || saved.Messages[2].Status != "success" {
|
|
t.Fatalf("expected successful mockup message, got %#v", saved.Messages)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMockupModelMapRepairsNearMissDataLength(t *testing.T) {
|
|
shortData := make([]float64, 254)
|
|
for index := range shortData {
|
|
shortData[index] = 0.42
|
|
}
|
|
normalized, err := normalizeMockupModelMap(design.MockupModelMap{Width: 16, Height: 16, Data: shortData}, "depthMap", 0, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(normalized.Data) != 256 {
|
|
t.Fatalf("expected repaired 16x16 data, got %d", len(normalized.Data))
|
|
}
|
|
if normalized.Data[254] != 0.42 || normalized.Data[255] != 0.42 {
|
|
t.Fatalf("expected missing tail values to be filled from edge value, got tail %#v", normalized.Data[252:])
|
|
}
|
|
|
|
longData := make([]float64, 260)
|
|
for index := range longData {
|
|
longData[index] = float64(index)
|
|
}
|
|
normalized, err = normalizeMockupModelMap(design.MockupModelMap{Width: 16, Height: 16, Data: longData}, "depthMap", 0, 255)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(normalized.Data) != 256 || normalized.Data[255] != 255 {
|
|
t.Fatalf("expected long data to be cropped and clamped, got len=%d last=%v", len(normalized.Data), normalized.Data[255])
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMockupAnalysisRepairsLogoBoundSurfaceAndMismatchedSegmentation(t *testing.T) {
|
|
target := design.Node{ID: "target", Type: design.NodeTypeImage, Width: 800, Height: 800}
|
|
analysis := fakeMockupAnalysis()
|
|
analysis.Placement = design.MockupPlacement{X: 0.32, Y: 0.28, Width: 0.36, Height: 0.16}
|
|
analysis.Surface.Polygon = []design.MockupPoint{{X: 0.32, Y: 0.28}, {X: 0.68, Y: 0.28}, {X: 0.68, Y: 0.44}, {X: 0.32, Y: 0.44}}
|
|
analysis.Model.SegmentationMap = mockupTestMap(32, 32, bottomOnlyMockupMask(32, 32))
|
|
analysis.Model.CameraIntrinsics.Matrix = []float64{1.4, 0, 333.5, 0, 1.4, 333.5, 0, 0, 1}
|
|
|
|
placement, surface, model, err := normalizeMockupAnalysis(target, analysis)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if placement.Height < 0.29 {
|
|
t.Fatalf("expected logo-bound surface to expand to the usable print zone, got %#v", placement)
|
|
}
|
|
if coverage := mockupSegmentationSurfaceCoverage(model.SegmentationMap, surface.Polygon); coverage < 0.7 {
|
|
t.Fatalf("expected segmentation to be repaired against printable surface, got coverage %.2f", coverage)
|
|
}
|
|
if got := model.CameraIntrinsics.Matrix[2]; got < 0 || got > 1 {
|
|
t.Fatalf("expected normalized cx, got %v", got)
|
|
}
|
|
}
|
|
|
|
type fakeMockupAnalyzer struct {
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeMockupAnalyzer) AnalyzeMockupModel(ctx context.Context, req design.MockupModelAnalysisRequest) (design.MockupModelAnalysis, error) {
|
|
f.calls++
|
|
return fakeMockupAnalysis(), nil
|
|
}
|
|
|
|
func fakeMockupAnalysis() design.MockupModelAnalysis {
|
|
return design.MockupModelAnalysis{
|
|
Placement: design.MockupPlacement{X: 0.2, Y: 0.25, Width: 0.4, Height: 0.5},
|
|
Surface: design.MockupSurface{
|
|
Polygon: []design.MockupPoint{{X: 0.2, Y: 0.25}, {X: 0.6, Y: 0.25}, {X: 0.6, Y: 0.75}, {X: 0.2, Y: 0.75}},
|
|
},
|
|
Model: design.MockupModel{
|
|
Version: 2,
|
|
DepthMap: mockupTestMap(2, 2, []float64{0.45, 0.5, 0.52, 0.58}),
|
|
DepthScaleMin: 0,
|
|
DepthScaleMax: 1,
|
|
ShadeMap: mockupTestMap(2, 2, []float64{0.8, 0.82, 0.78, 0.8}),
|
|
SegmentationMap: mockupTestMap(2, 2, []float64{0, 255, 255, 0}),
|
|
XOffsetMap: mockupTestMap(2, 2, []float64{-0.1, 0.1, -0.08, 0.08}),
|
|
YOffsetMap: mockupTestMap(2, 2, []float64{0.02, 0.04, -0.02, -0.04}),
|
|
CameraIntrinsics: design.MockupCameraIntrinsics{
|
|
Matrix: []float64{1.4, 0, 0.5, 0, 1.4, 0.5, 0, 0, 1},
|
|
},
|
|
ColorCorrection: "limited_brightness",
|
|
},
|
|
}
|
|
}
|
|
|
|
func mockupTestMap(width int, height int, data []float64) design.MockupModelMap {
|
|
return design.MockupModelMap{Width: width, Height: height, Data: data}
|
|
}
|
|
|
|
func bottomOnlyMockupMask(width int, height int) []float64 {
|
|
data := make([]float64, width*height)
|
|
for y := height * 3 / 4; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
data[y*width+x] = 255
|
|
}
|
|
}
|
|
return data
|
|
}
|