package usecase import ( "context" "fmt" "testing" "apps/backend/internal/module/scout/domain" "apps/backend/internal/module/scout/repository" ) type stagedPipelineProvider struct { calls []int firstBad int allRelevant bool } type oneTermExpansionProvider struct { calls []string } func (p *oneTermExpansionProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) { term := "" if len(terms) > 0 { term = terms[0] } p.calls = append(p.calls, term) out := make([]ThreadSearchResult, 0, limit) for i := 0; i < limit; i++ { text := "外包合作正在找人" if term != "外包" { text = "想找後端工程師做外包合作,歡迎推薦接案夥伴" } out = append(out, ThreadSearchResult{ URL: fmt.Sprintf("https://www.threads.net/@source/post/%s-%d", term, i), Snippet: text, }) } return out, nil } func (p *stagedPipelineProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) { call := len(p.calls) + 1 p.calls = append(p.calls, limit) term := "市集" if len(terms) > 0 { term = terms[0] } out := make([]ThreadSearchResult, 0, limit) for i := 0; i < limit; i++ { text := term + "討論內容" if !p.allRelevant && call == 1 && i < p.firstBad { text = "完全無關的內容" } out = append(out, ThreadSearchResult{ URL: fmt.Sprintf("https://www.threads.net/@source/post/c%d-%d", call, i), Snippet: text, }) } return out, nil } func newPipelineService(provider ThreadSearchProvider) *Service { svc := New(repository.NewMemory()) svc.Provider = provider return svc } func TestSearchPipelineContinuesWhenRawHitsExceedEligibleHits(t *testing.T) { provider := &stagedPipelineProvider{firstBad: 13} svc := newPipelineService(provider) result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"市集"}, &domain.RunBrief{ Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, }, 10, domain.PathAPI, "", 20) if err != nil { t.Fatal(err) } if len(result.Hits) != 10 || result.Diagnostics.EligibleCount != 10 { t.Fatalf("pipeline result=%+v", result) } if len(provider.calls) != 2 || provider.calls[0] != 20 || provider.calls[1] != 20 { t.Fatalf("calls=%v want initial + same-source boost", provider.calls) } if result.Diagnostics.IrrelevantCount != 13 || result.Diagnostics.RawCount != 40 { t.Fatalf("diagnostics=%+v", result.Diagnostics) } } func TestSearchPipelineStopsAtEligibleTarget(t *testing.T) { provider := &stagedPipelineProvider{allRelevant: true} svc := newPipelineService(provider) result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"市集", "活動"}, &domain.RunBrief{ Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集", "活動"}, }, 3, domain.PathAPI, "", 20) if err != nil { t.Fatal(err) } if len(result.Hits) != 3 || len(provider.calls) != 1 { t.Fatalf("result=%+v calls=%v; target should stop first query", result, provider.calls) } if result.Diagnostics.Stages != 1 { t.Fatalf("stages=%d want 1", result.Diagnostics.Stages) } } func TestSearchPipelineCapsRawCandidatesAt320(t *testing.T) { provider := &stagedPipelineProvider{allRelevant: true} svc := newPipelineService(provider) terms := []string{"一", "二", "三", "四", "五", "六", "七", "八"} result, err := svc.searchEligiblePipeline(context.Background(), 7, terms, &domain.RunBrief{ Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, }, 999, domain.PathAPI, "", 20) if err != nil { t.Fatal(err) } if result.Diagnostics.RawCount != maxPipelineRawCandidates || result.Diagnostics.Stages != 2 { t.Fatalf("diagnostics=%+v", result.Diagnostics) } if len(provider.calls) != 16 { t.Fatalf("calls=%d want 16 (8 terms x 2 stages)", len(provider.calls)) } if len(result.Diagnostics.ShortfallReasons) == 0 { t.Fatal("raw cap shortfall reason missing") } } func TestSearchPipelineExpandsOneApprovedTermWhenProviderRepeatsFirstPage(t *testing.T) { provider := &oneTermExpansionProvider{} svc := newPipelineService(provider) result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"外包"}, &domain.RunBrief{ Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"外包"}, }, 5, domain.PathAPI, "", 20) if err != nil { t.Fatal(err) } if len(result.Hits) != 5 { t.Fatalf("expanded result=%+v, want five eligible hits", result) } if len(provider.calls) < 3 || provider.calls[0] != "外包" || provider.calls[1] != "外包" { t.Fatalf("calls=%v, want repeated first query before expansion", provider.calls) } if result.Diagnostics.Stages != 3 { t.Fatalf("stages=%d, want initial, boost, expansion", result.Diagnostics.Stages) } }