3eae2beb12
When an image-generator target requests multiple images, duplicate the target placeholder into N generating clones and lay them out beside the target. Incremental results after the first are appended as siblings rather than merged into the target, and placeholder cleanup now clears the target's generating clones on failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
396 lines
12 KiB
Go
396 lines
12 KiB
Go
package application
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
)
|
|
|
|
func TestMergeGeneratedNodesIntoImageGeneratorTargetUsesGeneratedSizeAndKeepsCenter(t *testing.T) {
|
|
nodes := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "图像生成器",
|
|
X: 100,
|
|
Y: 80,
|
|
Width: 360,
|
|
Height: 240,
|
|
Status: "generating",
|
|
LayerRole: "image-generator",
|
|
},
|
|
}
|
|
generated := []design.Node{
|
|
{
|
|
ID: "generated-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "商品主图",
|
|
Content: "https://example.com/generated.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
Width: 1536,
|
|
Height: 1024,
|
|
},
|
|
}
|
|
|
|
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
|
|
if len(merged) != 1 {
|
|
t.Fatalf("expected one merged node, got %d", len(merged))
|
|
}
|
|
node := merged[0]
|
|
if node.LayerRole != "" || node.Status != "success" {
|
|
t.Fatalf("expected completed image node, got role=%q status=%q", node.LayerRole, node.Status)
|
|
}
|
|
if node.Width != 1536 || node.Height != 1024 {
|
|
t.Fatalf("expected generated size, got %.0fx%.0f", node.Width, node.Height)
|
|
}
|
|
if node.X+node.Width/2 != 280 || node.Y+node.Height/2 != 200 {
|
|
t.Fatalf("expected replacement to keep placeholder center, got center %.0f,%.0f", node.X+node.Width/2, node.Y+node.Height/2)
|
|
}
|
|
}
|
|
|
|
func TestMergeGeneratedNodesIntoCompletedImageGeneratorTargetDoesNotReappend(t *testing.T) {
|
|
nodes := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "旧图",
|
|
Content: "https://example.com/old.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
LayerRole: "",
|
|
Width: 360,
|
|
Height: 240,
|
|
},
|
|
}
|
|
generated := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "新图",
|
|
Content: "https://example.com/new.png",
|
|
Tone: "image-generator",
|
|
Status: "success",
|
|
LayerRole: "image-generator",
|
|
Width: 1024,
|
|
Height: 1024,
|
|
},
|
|
}
|
|
|
|
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
|
|
if len(merged) != 1 {
|
|
t.Fatalf("expected existing target to be updated in place, got %#v", merged)
|
|
}
|
|
node := merged[0]
|
|
if node.Content != "https://example.com/new.png" || node.LayerRole != "" || node.Tone != "visual" {
|
|
t.Fatalf("expected completed image generator to become regular image, got %#v", node)
|
|
}
|
|
}
|
|
|
|
func TestMergeGeneratedNodesIntoImageGeneratorTargetPlacesExtraImagesBesideTarget(t *testing.T) {
|
|
nodes := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "图像生成器",
|
|
X: 100,
|
|
Y: 80,
|
|
Width: 360,
|
|
Height: 240,
|
|
Status: "generating",
|
|
LayerRole: "image-generator",
|
|
},
|
|
}
|
|
generated := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "商品主图 1",
|
|
Content: "https://example.com/generated-1.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
Width: 360,
|
|
Height: 240,
|
|
},
|
|
{
|
|
ID: "generated-2",
|
|
Type: design.NodeTypeImage,
|
|
Title: "商品主图 2",
|
|
Content: "https://example.com/generated-2.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
Width: 360,
|
|
Height: 240,
|
|
},
|
|
}
|
|
|
|
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
|
|
if len(merged) != 2 {
|
|
t.Fatalf("expected target plus sibling image, got %#v", merged)
|
|
}
|
|
if merged[0].Content != "https://example.com/generated-1.png" || merged[0].LayerRole != "" {
|
|
t.Fatalf("expected first image to replace target, got %#v", merged[0])
|
|
}
|
|
if merged[1].Content != "https://example.com/generated-2.png" || merged[1].X <= merged[0].X+merged[0].Width || merged[1].Y != merged[0].Y {
|
|
t.Fatalf("expected second image beside target, got %#v after %#v", merged[1], merged[0])
|
|
}
|
|
|
|
mergedAgain := mergeGeneratedNodesIntoTarget(merged, generated, "generator-1")
|
|
if len(mergedAgain) != 2 {
|
|
t.Fatalf("expected repeated merge to update sibling without duplicating, got %#v", mergedAgain)
|
|
}
|
|
}
|
|
|
|
func TestMergeGeneratedNodeIntoCompletedImageGeneratorTargetAppendsSibling(t *testing.T) {
|
|
nodes := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "商品主图 1",
|
|
Content: "https://example.com/generated-1.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
X: 100,
|
|
Y: 80,
|
|
Width: 360,
|
|
Height: 240,
|
|
},
|
|
}
|
|
generated := []design.Node{
|
|
{
|
|
ID: "generated-2",
|
|
Type: design.NodeTypeImage,
|
|
Title: "商品主图 2",
|
|
Content: "https://example.com/generated-2.png",
|
|
Tone: "visual",
|
|
Status: "success",
|
|
Width: 360,
|
|
Height: 240,
|
|
},
|
|
}
|
|
|
|
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
|
|
if len(merged) != 2 {
|
|
t.Fatalf("expected completed target plus sibling image, got %#v", merged)
|
|
}
|
|
if merged[0].Content != "https://example.com/generated-1.png" {
|
|
t.Fatalf("expected completed target to stay intact, got %#v", merged[0])
|
|
}
|
|
if merged[1].Content != "https://example.com/generated-2.png" || merged[1].X <= merged[0].X+merged[0].Width {
|
|
t.Fatalf("expected sibling appended beside completed target, got %#v", merged[1])
|
|
}
|
|
}
|
|
|
|
func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T) {
|
|
targetID := imageGeneratorTargetNodeID([]design.AgentChatMessage{
|
|
{
|
|
Role: "user",
|
|
Contents: []design.AgentContent{
|
|
{Type: "text", Text: "cat\nimage generator target node: generator-1"},
|
|
},
|
|
},
|
|
})
|
|
if targetID != "generator-1" {
|
|
t.Fatalf("expected target id, got %q", targetID)
|
|
}
|
|
}
|
|
|
|
func TestMarkImageGeneratorTargetGeneratingCopiesPlaceholdersForRequestedCount(t *testing.T) {
|
|
nodes := []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "图像生成器",
|
|
X: 100,
|
|
Y: 80,
|
|
Width: 360,
|
|
Height: 240,
|
|
Status: "draft",
|
|
LayerRole: "image-generator",
|
|
},
|
|
{
|
|
ID: "regular-image",
|
|
Type: design.NodeTypeImage,
|
|
Content: "https://example.com/keep.png",
|
|
Status: "success",
|
|
},
|
|
}
|
|
|
|
marked := markImageGeneratorTargetGenerating(nodes, "generator-1", "生成五张商品主图", "1024x1024", 5)
|
|
if len(marked) != 6 {
|
|
t.Fatalf("expected target, four copied placeholders, and existing image, got %#v", marked)
|
|
}
|
|
if marked[0].ID != "generator-1" || marked[0].Status != "generating" || marked[0].Content != "生成五张商品主图" {
|
|
t.Fatalf("expected original generator to become first placeholder, got %#v", marked[0])
|
|
}
|
|
for index := 1; index <= 4; index++ {
|
|
node := marked[index]
|
|
if node.ID == "" || node.ID == "generator-1" || node.ParentID != "generator-1" || node.LayerRole != "image-generator" || node.Status != "generating" {
|
|
t.Fatalf("expected copied image generator placeholder at index %d, got %#v", index, node)
|
|
}
|
|
if node.X <= marked[index-1].X+marked[index-1].Width || node.Y != marked[0].Y {
|
|
t.Fatalf("expected copied placeholder %d beside previous placeholder, got %#v", index, node)
|
|
}
|
|
}
|
|
if marked[5].ID != "regular-image" {
|
|
t.Fatalf("expected unrelated image to stay after placeholders, got %#v", marked[5])
|
|
}
|
|
|
|
remarked := markImageGeneratorTargetGenerating(marked, "generator-1", "生成两张商品主图", "1024x1024", 2)
|
|
if len(remarked) != 3 {
|
|
t.Fatalf("expected stale copied placeholders to be replaced for new count, got %#v", remarked)
|
|
}
|
|
if remarked[1].ParentID != "generator-1" || remarked[1].Status != "generating" {
|
|
t.Fatalf("expected one fresh copied placeholder, got %#v", remarked[1])
|
|
}
|
|
if remarked[2].ID != "regular-image" {
|
|
t.Fatalf("expected unrelated image to remain, got %#v", remarked[2])
|
|
}
|
|
}
|
|
|
|
func TestRestoreFinishedGeneratedImageNodesConvertsCompletedImageGeneratorNode(t *testing.T) {
|
|
now := time.Now()
|
|
project := design.Project{
|
|
ID: "project-completed-generator",
|
|
Status: design.StatusReady,
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "generator-1",
|
|
Type: design.NodeTypeImage,
|
|
Title: "生成图片",
|
|
Content: "http://localhost:19000/canvas-assets/cat.png",
|
|
Tone: "image-generator",
|
|
Status: "success",
|
|
LayerRole: "image-generator",
|
|
Width: 1024,
|
|
Height: 1024,
|
|
},
|
|
},
|
|
}
|
|
|
|
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
|
|
if !changed {
|
|
t.Fatal("expected completed image generator node to be normalized")
|
|
}
|
|
node := restored.Nodes[0]
|
|
if node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" || node.Content != "http://localhost:19000/canvas-assets/cat.png" {
|
|
t.Fatalf("expected regular completed image node, got %#v", node)
|
|
}
|
|
}
|
|
|
|
func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing.T) {
|
|
now := time.Now()
|
|
project := design.Project{
|
|
ID: "project-stale-snapshot",
|
|
Status: design.StatusReady,
|
|
UpdatedAt: now,
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "artifact-node",
|
|
Type: design.NodeTypeImage,
|
|
Title: "去背景中",
|
|
Content: "http://localhost:19000/canvas-assets/old.webp",
|
|
Status: "generating",
|
|
LayerRole: "background-removed",
|
|
Width: 512,
|
|
Height: 512,
|
|
},
|
|
{
|
|
ID: "stale-agent-placeholder",
|
|
Type: design.NodeTypeImage,
|
|
Title: "猫主题视觉图",
|
|
Content: "cat",
|
|
Status: "generating",
|
|
Width: 1024,
|
|
Height: 1024,
|
|
},
|
|
},
|
|
Messages: []design.Message{
|
|
toolArtifactsMessage("thread-image", "cat", []design.Node{
|
|
{
|
|
ID: "artifact-node",
|
|
Type: design.NodeTypeImage,
|
|
Title: "猫主题视觉图",
|
|
Content: "http://localhost:19000/canvas-assets/cat.png",
|
|
Status: "success",
|
|
Width: 1024,
|
|
Height: 1024,
|
|
},
|
|
}, now),
|
|
},
|
|
}
|
|
|
|
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
|
|
if !changed {
|
|
t.Fatal("expected stale snapshot to be repaired")
|
|
}
|
|
if len(restored.Nodes) != 1 {
|
|
t.Fatalf("expected stale prompt placeholder removed, got %#v", restored.Nodes)
|
|
}
|
|
node := restored.Nodes[0]
|
|
if node.ID != "artifact-node" || node.Status != "success" || node.LayerRole != "" || node.Content != "http://localhost:19000/canvas-assets/cat.png" {
|
|
t.Fatalf("expected artifact node restored, got %#v", node)
|
|
}
|
|
}
|
|
|
|
func TestRestoreFinishedGeneratedImageNodesRepairsBlankSuccessfulImageNode(t *testing.T) {
|
|
now := time.Now()
|
|
project := design.Project{
|
|
ID: "project-blank-generated-node",
|
|
Status: design.StatusReady,
|
|
Nodes: []design.Node{
|
|
{
|
|
ID: "blank-node",
|
|
Type: design.NodeTypeImage,
|
|
Status: "success",
|
|
Width: 320,
|
|
Height: 240,
|
|
},
|
|
},
|
|
Messages: []design.Message{
|
|
toolArtifactsMessage("thread-image", "pants", []design.Node{
|
|
{
|
|
ID: "artifact-node",
|
|
Type: design.NodeTypeImage,
|
|
Title: "浅麻灰长裤抠图",
|
|
Content: "http://localhost:19000/canvas-assets/pants.png",
|
|
Status: "success",
|
|
Width: 1024,
|
|
Height: 1024,
|
|
},
|
|
}, now),
|
|
},
|
|
}
|
|
|
|
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
|
|
if !changed {
|
|
t.Fatal("expected blank generated node to be repaired")
|
|
}
|
|
if len(restored.Nodes) != 1 {
|
|
t.Fatalf("expected one repaired node, got %#v", restored.Nodes)
|
|
}
|
|
node := restored.Nodes[0]
|
|
if node.ID != "blank-node" || node.Content != "http://localhost:19000/canvas-assets/pants.png" || node.Title != "浅麻灰长裤抠图" || node.Width != 1024 || node.Height != 1024 {
|
|
t.Fatalf("expected blank node repaired from artifact, got %#v", node)
|
|
}
|
|
}
|
|
|
|
func TestMergeSavedCanvasWorkStateDoesNotReapplyStaleGeneratingNodes(t *testing.T) {
|
|
existing := []design.Node{
|
|
{ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "http://localhost:19000/canvas-assets/generated.png", Status: "success"},
|
|
}
|
|
incoming := []design.Node{
|
|
{ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "cat", Status: "generating"},
|
|
{ID: "stale-placeholder", Type: design.NodeTypeImage, Title: "猫主题视觉图", Content: "cat", Status: "generating"},
|
|
}
|
|
|
|
merged := mergeSavedCanvasWorkState(existing, incoming)
|
|
if len(merged) != 1 {
|
|
t.Fatalf("expected stale placeholder dropped, got %#v", merged)
|
|
}
|
|
if merged[0].Status != "success" || merged[0].Content != existing[0].Content {
|
|
t.Fatalf("expected server-completed node preserved, got %#v", merged[0])
|
|
}
|
|
}
|