package usecase import ( "context" "strings" "testing" radarDomain "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/scout/domain" ) func TestPrepareActivityBriefUsesGeneratedTerms(t *testing.T) { svc := &Service{} brief, err := svc.PrepareBrief(context.Background(), 1, "市集", "", "", "activity", true) if err != nil { t.Fatalf("PrepareBrief: %v", err) } if brief.Mode != domain.ModeActivity { t.Fatalf("mode=%q want %q", brief.Mode, domain.ModeActivity) } if len(brief.ScanTerms) < 2 { t.Fatalf("ScanTerms=%v want generated variants", brief.ScanTerms) } } func TestPlanActivityTermsGeneratesVariantsNotJustSplit(t *testing.T) { // 單一字主題:應產出「核 + 口語錨點」,不是只有原字 got := planActivityTerms("市集") if len(got) < 3 { t.Fatalf("市集 variants=%v want >=3 generated terms", got) } hasPair := false for _, term := range got { if !radarDomain.IsThreadsSearchable(term) { t.Fatalf("term %q not Threads-searchable", term) } if strings.Contains(term, " ") { hasPair = true } } if !hasPair { t.Fatalf("expected multi-token variants, got %v", got) } // 長句:不應只回整句,要抽出核並產變體 got2 := planActivityTerms("想找台北週末市集可以拍什麼") if len(got2) < 2 { t.Fatalf("long intent variants=%v want >=2", got2) } for _, term := range got2 { if !radarDomain.IsThreadsSearchable(term) { t.Fatalf("long-intent term %q not searchable", term) } // 不應留下整句長字串 if utf8Count(term) > 12 { t.Fatalf("term too long: %q", term) } } // 應含地區或市集相關 joined := strings.Join(got2, "|") if !strings.Contains(joined, "市集") && !strings.Contains(joined, "台北") { t.Fatalf("expected 市集/台北 in variants, got %v", got2) } } func TestPlanActivityTermsNotJustTokenizeIntent(t *testing.T) { // 舊行為:tokenizeIntent("週末市集") → ["週末市集"] 或空拆 // 新行為:至少有錨點變體 got := planActivityTerms("週末市集") if len(got) < 2 { t.Fatalf("got %v, want multiple generated variants", got) } // 若只有把四字當一詞,也要再有「週末 市集」類或「市集 推薦」 onlyExact := len(got) == 1 && got[0] == "週末市集" if onlyExact { t.Fatal("still only returning exact intent — not generating") } } func TestPlanActivityTermsInfersBackendFreelanceIntent(t *testing.T) { got := planActivityTerms("外包 工程師 後端") if len(got) == 0 || got[0] != "後端 外包" { t.Fatalf("terms=%v want 後端 外包 as primary intent query", got) } joined := "|" + strings.Join(got, "|") + "|" for _, want := range []string{"|後端 外包|", "|後端 接案|", "|後端 工程師|"} { if !strings.Contains(joined, want) { t.Fatalf("terms=%v missing inferred query %s", got, want) } } for _, term := range got { if !radarDomain.IsThreadsSearchable(term) { t.Fatalf("term %q not Threads-searchable", term) } } } func TestPlanActivityTermsPreservesExactTitleIntent(t *testing.T) { got := planActivityTerms("鬼滅之刃") if len(got) == 0 || got[0] != "鬼滅之刃" { t.Fatalf("terms=%v want exact title as primary query", got) } joined := "|" + strings.Join(got, "|") + "|" for _, want := range []string{"|鬼滅之刃 最新|", "|鬼滅之刃 熱門|"} { if !strings.Contains(joined, want) { t.Fatalf("terms=%v missing discovery query %s", got, want) } } for _, bad := range []string{"鬼滅之", "滅之刃"} { if strings.Contains(joined, "|"+bad+"|") { t.Fatalf("broken title fragment %q leaked: %v", bad, got) } } } func TestMergeActivityTermsKeepsDeterministicIntentFirst(t *testing.T) { ai := []string{"市集 分享", "文青 市集", "太長了吧這整句不行"} rules := []string{"市集 推薦", "市集"} got := mergeActivityTerms(ai, rules, 6) if len(got) == 0 { t.Fatal("empty merge") } if got[0] != "市集 推薦" { t.Fatalf("deterministic intent term should come first, got %v", got) } // 長詞應被濾掉 for _, t2 := range got { if strings.Contains(t2, "太長") { t.Fatalf("unssearchable term leaked: %v", got) } } } func TestParseActivityAITerms(t *testing.T) { raw := "這裡是開場\n[\"市集 分享\", \"週末 市集\"]\n結尾" got := parseActivityAITerms(raw) if len(got) != 2 { t.Fatalf("parse strings: %v", got) } raw2 := `[{"term":"市集 心得","reason":"x"},{"term":"活動 市集"}]` got2 := parseActivityAITerms(raw2) if len(got2) != 2 { t.Fatalf("parse objects: %v", got2) } } func utf8Count(s string) int { return len([]rune(strings.ReplaceAll(s, " ", ""))) }