package logic import ( "context" "testing" "time" "img_infinite_canvas/internal/application" "img_infinite_canvas/internal/domain/design" "img_infinite_canvas/internal/infrastructure/memory" "img_infinite_canvas/internal/svc" "img_infinite_canvas/internal/types" ) func TestProjectEventsFiltersMessagesByThreadID(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() project := design.Project{ ID: "project-events", Title: "Events", Status: design.StatusReady, UpdatedAt: time.Now(), Messages: []design.Message{ {ID: "a", Role: "tool", Type: "tool", Title: "A", Content: "thread-a", ThreadID: "thread-a", CreatedAt: time.Now()}, {ID: "b", Role: "tool", Type: "tool", Title: "B", Content: "thread-b", ThreadID: "thread-b", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } logic := NewProjectEventsLogic(ctx, &svc.ServiceContext{ DesignService: application.NewDesignService(repo, nil, nil, nil, nil), }) var thinking []types.AgentMessage err := logic.Stream(&types.ProjectIdRequest{Id: project.ID}, "thread-b", func(event string, payload any) error { if event == "thinking" { thinking = append(thinking, payload.(types.AgentMessage)) } return nil }) if err != nil { t.Fatal(err) } if len(thinking) != 1 || thinking[0].Id != "b" { t.Fatalf("expected only thread-b message, got %#v", thinking) } } func TestMessageEventStateThrottlesStreamingMessages(t *testing.T) { now := time.Now() state := newMessageEventState() message := design.Message{ID: "streaming", Role: "assistant", Type: "assistant", Content: "a", Status: "running", CreatedAt: now} if !state.shouldEmit(message, now) { t.Fatal("expected first streaming message to emit") } message.Content = "ab" if state.shouldEmit(message, now.Add(100*time.Millisecond)) { t.Fatal("expected rapid streaming update to be throttled") } message.Content = "abc" if !state.shouldEmit(message, now.Add(streamingThinkingThrottle+time.Millisecond)) { t.Fatal("expected streaming update after throttle window to emit") } message.Content = "abcd" message.Status = "success" if !state.shouldEmit(message, now.Add(streamingThinkingThrottle+2*time.Millisecond)) { t.Fatal("expected final message status to emit immediately") } } func TestProjectThreadStateFinishesMockupToolThread(t *testing.T) { started, finished, failed := projectThreadState([]design.Message{ {ID: "m1", Role: "tool", Type: "tool", Name: "mockup_model", ToolHint: "mockup_model", ThreadID: "thread-mockup", Status: "running"}, {ID: "m2", Role: "tool", Type: "tool", Name: "mockup_model", ToolHint: "mockup_model", ThreadID: "thread-mockup", Status: "success"}, }, "thread-mockup") if !started || !finished || failed { t.Fatalf("expected completed mockup tool thread, got started=%v finished=%v failed=%v", started, finished, failed) } } func TestProjectThreadStateWaitsForRunningGenerationTool(t *testing.T) { started, finished, failed := projectThreadState([]design.Message{ {ID: "tool", Role: "tool", Type: "tool", Title: "GPT Image 2", ThreadID: "thread-image", Status: "running"}, {ID: "assistant", Role: "assistant", Type: "assistant", Title: "猫主题视觉图", Content: "已生成。", ThreadID: "thread-image", Status: "success"}, }, "thread-image") if !started || finished || failed { t.Fatalf("expected running generation tool to keep thread open, got started=%v finished=%v failed=%v", started, finished, failed) } } func TestProjectThreadStateFinishesAfterGenerationToolSucceeds(t *testing.T) { started, finished, failed := projectThreadState([]design.Message{ {ID: "tool-running", Role: "tool", Type: "tool", Title: "GPT Image 2", ThreadID: "thread-image", Status: "running"}, {ID: "tool-success", Role: "tool", Type: "tool", Title: "GPT Image 2", ThreadID: "thread-image", Status: "success"}, {ID: "assistant", Role: "assistant", Type: "assistant", Title: "猫主题视觉图", Content: "已生成。", ThreadID: "thread-image", Status: "success"}, }, "thread-image") if !started || !finished || failed { t.Fatalf("expected completed generation thread, got started=%v finished=%v failed=%v", started, finished, failed) } } func TestProjectEventFingerprintIgnoresMessageOnlyChanges(t *testing.T) { now := time.Now() project := design.Project{ ID: "project-events-fingerprint", Title: "Events", Status: design.StatusExploring, UpdatedAt: now, Messages: []design.Message{ {ID: "m", Role: "assistant", Type: "assistant", Content: "a", Status: "running", CreatedAt: now}, }, } before := projectEventFingerprint(project) project.UpdatedAt = now.Add(time.Second) project.Version = "next" project.SnapshotID = "snapshot" project.Messages[0].Content = "streamed content" after := projectEventFingerprint(project) if before != after { t.Fatal("expected message-only save to keep project event fingerprint stable") } project.Nodes = append(project.Nodes, design.Node{ID: "n", Type: design.NodeTypeImage, Status: "generating"}) if before == projectEventFingerprint(project) { t.Fatal("expected canvas node changes to update project event fingerprint") } }