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

121 lines
5.5 KiB
Go
Raw Permalink Normal View History

2026-07-13 08:59:13 +00:00
package usecase_test
import (
"context"
"testing"
"apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
"apps/backend/internal/module/scout/usecase"
"github.com/stretchr/testify/require"
)
type capturedSearchProvider struct {
terms []string
hits []usecase.ThreadSearchResult
}
func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
svc := usecase.New(repository.NewMemory())
brand, err := svc.CreateBrand(context.Background(), 1, "B", "")
require.NoError(t, err)
product, err := svc.SaveProduct(context.Background(), 1, &domain.Product{
BrandID: brand.ID, Label: "P", PainPoints: []string{"敏感肌", "敏感肌"}, MatchTags: []string{"保養", "敏感肌"},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false)
require.NoError(t, err)
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "替代方案", "成分/規格", "敏感肌保養"}, brief.ScanTerms)
}
func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) {
p.terms = append([]string(nil), terms...)
return p.hits, nil
}
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?"},
{URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?"},
{URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠"},
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得"},
{URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮"},
}}
store := repository.NewMemory()
svc := usecase.New(store)
svc.Provider = provider
brief := &domain.RunBrief{
Mode: domain.ModeProduct, Intent: "敏感肌保養", Pains: []string{"敏感肌", "敏感肌"},
Tags: []string{"保養", "敏感肌"}, Periphery: []string{"使用情境", "保養"},
}
brief.ScanTerms = []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}
posts, err := svc.RunScanFromBrief(context.Background(), 42, brief)
require.NoError(t, err)
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.terms)
require.Len(t, posts, 4)
byClass := map[string]*domain.Post{}
for _, post := range posts {
byClass[post.Classification] = post
require.NotEmpty(t, post.Permalink)
require.Equal(t, post.Permalink, post.ExternalID)
require.InDelta(t, 0, post.Score, 100)
require.NotEmpty(t, post.MatchReason)
}
require.Contains(t, byClass, domain.ClassificationSeekingHelp)
require.Contains(t, byClass, domain.ClassificationSeekingRecommendation)
require.Contains(t, byClass, domain.ClassificationProviderOffer)
require.Contains(t, byClass, domain.ClassificationDiscussion)
require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌")
require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink)
again, err := svc.RunScanFromBrief(context.Background(), 42, brief)
require.NoError(t, err)
require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID)
persisted, err := svc.ListPosts(context.Background(), 42, "")
require.NoError(t, err)
require.Len(t, persisted, 4)
}
func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@a/post/1", Snippet: "請問這週市集怎麼報名?"},
{URL: "https://www.threads.net/@b/post/2", Snippet: "市集活動公告,週六開幕"},
{URL: "https://www.threads.net/@c/post/3", Snippet: "市集攤位服務洽詢"},
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享市集逛街心得"},
{URL: "https://www.threads.net/@e/post/5", Snippet: "市集 crypto 賺錢群組"},
}}
svc := usecase.New(repository.NewMemory())
svc.Provider = provider
brief := &domain.RunBrief{Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}}
posts, err := svc.RunScanFromBrief(context.Background(), 7, brief)
require.NoError(t, err)
require.Len(t, posts, 4)
classes := map[string]bool{}
for _, post := range posts {
classes[post.Classification] = true
require.Contains(t, post.MatchReason, "recency unavailable (neutral)")
require.Greater(t, post.Score, 0)
}
require.True(t, classes[domain.ClassificationAsking])
require.True(t, classes[domain.ClassificationAnnouncement])
require.True(t, classes[domain.ClassificationProviderOffer])
require.True(t, classes[domain.ClassificationDiscussion])
}
func TestDraftOutreachIsDeterministicAndDoesNotNeedAI(t *testing.T) {
svc := usecase.New(repository.NewMemory())
svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{
URL: "https://www.threads.net/@author/post/1", Snippet: "請問怎麼處理頭皮困擾?",
}}}
posts, err := svc.RunScanFromBrief(context.Background(), 9, &domain.RunBrief{
Mode: domain.ModeTheme, Intent: "頭皮", ScanTerms: []string{"頭皮"},
})
require.NoError(t, err)
draft, err := svc.DraftOutreach(context.Background(), 9, posts[0].ID, "")
require.NoError(t, err)
require.Equal(t, "嗨 @author看到你提到「頭皮」我也遇過類似情況。若你願意想聽聽你後來怎麼處理。", draft.DraftText)
}