thread-master/apps/backend/internal/module/scout/usecase/candidate_test.go

162 lines
6.3 KiB
Go

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
}