162 lines
6.3 KiB
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
|
|
}
|