package usecase import ( "testing" "time" "apps/backend/internal/module/scout/domain" ) func TestCandidateEvaluatorHasOneDecisionAndCanonicalDuplicate(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"後端 外包"}, }) good := ThreadSearchResult{ URL: "https://threads.net/@builder/post/ABC123?share=1", Snippet: "想找後端工程師做外包合作,有推薦的接案夥伴嗎?", } first := evaluator.EvaluateAt(good, time.Now().UnixNano()) if first.Decision != CandidateEligible || first.Identity != "threads-post:ABC123" { t.Fatalf("first evaluation=%+v", first) } if first.SearchTag == "" || first.Score <= 0 || first.Reason == "" { t.Fatalf("eligible metadata incomplete: %+v", first) } dup := evaluator.EvaluateAt(ThreadSearchResult{ URL: "https://www.threads.com/@renamed/post/ABC123", Snippet: good.Snippet, }, time.Now().UnixNano()) if dup.Decision != CandidateDuplicate || dup.Reason == "" { t.Fatalf("duplicate evaluation=%+v", dup) } stats := evaluator.Stats() if stats.Searched != 2 || stats.Eligible != 1 || stats.Duplicate != 1 || stats.Irrelevant != 0 { t.Fatalf("stats=%+v", stats) } } func TestCandidateEvaluatorSeparatesIrrelevanceReasons(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "鬼滅之刃", ScanTerms: []string{"鬼滅之刃 最新"}, }) for _, tc := range []struct { name string hit ThreadSearchResult want string }{ {name: "missing topic", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/missing", Snippet: "最近想看動畫,有推薦熱門作品嗎"}, want: "missing core"}, {name: "noise", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/noise", Snippet: "鬼滅之刃抽獎,互追拿好禮"}, want: "noise signal"}, {name: "empty", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/empty", Snippet: " "}, want: "missing canonical"}, } { t.Run(tc.name, func(t *testing.T) { got := evaluator.Evaluate(tc.hit) if got.Decision != CandidateIrrelevant || got.Reason == "" || !containsReason(got.Reason, tc.want) { t.Fatalf("evaluation=%+v want reason containing %q", got, tc.want) } }) } stats := evaluator.Stats() if stats.Searched != 3 || stats.Irrelevant != 3 || stats.Eligible != 0 || stats.Duplicate != 0 { t.Fatalf("stats=%+v", stats) } } func TestCandidateEvaluatorAllowsOldPublishedTime(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "鬼滅之刃", ScanTerms: []string{"鬼滅之刃"}, }) got := evaluator.Evaluate(ThreadSearchResult{ URL: "https://threads.net/@a/post/old", Snippet: "鬼滅之刃舊文", PublishedAt: time.Now().Add(-3 * 365 * 24 * time.Hour).UnixNano(), }) if got.Decision != CandidateEligible { t.Fatalf("old relevant candidate=%+v", got) } } func TestCandidateEvaluatorAllowsRicherRetryAfterRejectedIdentity(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"後端 外包"}, }) url := "https://threads.net/@builder/post/RICHER" first := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: "外包合作正在找人"}, time.Now().UnixNano()) if first.Decision != CandidateIrrelevant { t.Fatalf("sparse first hit=%+v, want irrelevant", first) } second := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: "想找後端工程師做外包合作,歡迎推薦接案夥伴"}, time.Now().UnixNano()) if second.Decision != CandidateEligible { t.Fatalf("richer retry=%+v, want eligible", second) } third := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: second.Text}, time.Now().UnixNano()) if third.Decision != CandidateDuplicate { t.Fatalf("eligible identity should be terminal duplicate: %+v", third) } } func TestCandidateEvaluatorRejectsAnchorOnlyActivityBrief(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "推薦 分享", ScanTerms: []string{"推薦"}, }) got := evaluator.Evaluate(ThreadSearchResult{ URL: "https://threads.net/@a/post/ANCHOR", Snippet: "推薦一間好吃的餐廳給大家", }) if got.Decision != CandidateIrrelevant || got.Reason != "no approved topic core matched" { t.Fatalf("anchor-only activity=%+v", got) } } func TestCandidateEvaluatorUsesTitleWhenProviderOmitsSnippet(t *testing.T) { evaluator := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, }) got := evaluator.Evaluate(ThreadSearchResult{ URL: "https://threads.net/@a/post/TITLE_ONLY", Title: "台北市集週末推薦", Snippet: "", }) if got.Decision != CandidateEligible { t.Fatalf("title-only provider hit=%+v", got) } } func TestCandidateEvaluatorAcceptsNaturalActivityIntentFromApprovedVariants(t *testing.T) { brief := &domain.RunBrief{ Mode: domain.ModeActivity, Intent: "找後端工程師接案", ScanTerms: []string{"後端 外包", "後端 接案", "後端 工程師"}, } evaluator := NewCandidateEvaluator(brief) got := evaluator.Evaluate(ThreadSearchResult{ URL: "https://threads.net/@a/post/NATURAL_WORK", Snippet: "正在找後端工程師做外包合作,歡迎推薦接案夥伴", MatchedQuery: "後端 外包", }) if got.Decision != CandidateEligible { t.Fatalf("natural activity candidate=%+v", got) } // Unchecking variants must not discard the explicit role/work concepts // carried by the original sentence. oneTerm := NewCandidateEvaluator(&domain.RunBrief{ Mode: domain.ModeActivity, Intent: brief.Intent, ScanTerms: []string{"後端 外包"}, }) if got := oneTerm.Evaluate(ThreadSearchResult{ URL: "https://threads.net/@a/post/NATURAL_WORK_ONE", Snippet: "正在找後端工程師做外包合作", }); got.Decision != CandidateEligible { t.Fatalf("single approved term lost natural concepts: %+v", got) } } func containsReason(reason, want string) bool { for _, part := range []string{reason} { if len(part) >= len(want) && hasSubstring(part, want) { return true } } return false } func hasSubstring(text, want string) bool { for i := 0; i+len(want) <= len(text); i++ { if text[i:i+len(want)] == want { return true } } return false }