feat: rank growth events with grounded evidence
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -36,14 +38,152 @@ func TestUpcomingEventsKeepsFiveChronologicalEligibleCandidates(t *testing.T) {
|
||||
|
||||
func TestFallbackEventRecommendationsUseTheFirstThreeCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
events := []UpcomingEvent{{EvidenceID: "event:first"}, {EvidenceID: "event:second"}, {EvidenceID: "event:third"}, {EvidenceID: "event:fourth"}}
|
||||
events := []UpcomingEvent{
|
||||
{EvidenceID: "event:first", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisHistorical, HistoricalEvidence: &HistoricalEvidence{MaterialDecline: true}, RankingScore: 90},
|
||||
{EvidenceID: "event:second", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}, RankingScore: 60},
|
||||
{EvidenceID: "event:third", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{}, RankingScore: 30},
|
||||
{EvidenceID: "event:fourth", Date: time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC), RankingScore: 1},
|
||||
}
|
||||
got := fallbackEventRecommendations(events)
|
||||
if len(got) != 3 || got[0].EvidenceID != "event:first" || got[2].EvidenceID != "event:third" {
|
||||
t.Fatalf("fallbackEventRecommendations() = %+v", got)
|
||||
}
|
||||
if got[0].Stance != StanceCaution || got[0].InsightBasis != InsightBasisHistorical {
|
||||
t.Fatalf("historical decline fallback = %+v, want historical caution", got[0])
|
||||
}
|
||||
total := 0
|
||||
for index, item := range got {
|
||||
total += item.PriorityPercent
|
||||
if item.PriorityPercent <= 0 || index > 0 && got[index-1].PriorityPercent < item.PriorityPercent {
|
||||
t.Fatalf("fallback percentages are not positive descending: %+v", got)
|
||||
}
|
||||
}
|
||||
if total != 100 {
|
||||
t.Fatalf("fallback percentage sum = %d, want 100", total)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.RelevanceReason == "" {
|
||||
t.Fatal("fallback recommendation lacks a relevance reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackEventRecommendationsUsesStableEqualShares(t *testing.T) {
|
||||
t.Parallel()
|
||||
events := []UpcomingEvent{
|
||||
{EvidenceID: "event:a", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
|
||||
{EvidenceID: "event:b", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
|
||||
{EvidenceID: "event:c", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
|
||||
}
|
||||
|
||||
got := fallbackEventRecommendations(events)
|
||||
if got[0].PriorityPercent != 34 || got[1].PriorityPercent != 33 || got[2].PriorityPercent != 33 {
|
||||
t.Fatalf("equal fallback shares = %+v, want 34/33/33", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackEventRecommendationsNeverPromotesNonPromotableTone(t *testing.T) {
|
||||
t.Parallel()
|
||||
events := []UpcomingEvent{
|
||||
{EvidenceID: "event:a", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", Tone: "MOURNING", RankingScore: 30},
|
||||
{EvidenceID: "event:b", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 20},
|
||||
{EvidenceID: "event:c", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 10},
|
||||
}
|
||||
|
||||
got := fallbackEventRecommendations(events)
|
||||
if got[0].Stance == StancePromote {
|
||||
t.Fatalf("non-promotable tone received PROMOTE fallback: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEventEvidencePrefersEligibleExactHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
prior := growth.Event{ID: "prior", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 29, 0, 0, 0, 0, time.UTC)}
|
||||
effect := growth.EventEffect{Eligible: true, ObservationCount: 6, PaidSessionUpliftBPS: -2300, AmountUpliftBPS: -1200, MaterialDecline: true}
|
||||
event := UpcomingEvent{EvidenceID: "event:black-friday", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"وبسایت"}}, RankingScore: 100}
|
||||
|
||||
got := applyEventEvidence(event, &prior, effect, growth.SuppressionInsufficientHistory)
|
||||
if got.InsightBasis != InsightBasisHistorical || got.HistoricalEvidence == nil || got.ProfileEvidence != nil {
|
||||
t.Fatalf("applyEventEvidence() = %+v, want historical-only evidence", got)
|
||||
}
|
||||
if got.HistoricalEvidence.PriorStartDate != prior.StartDate || got.HistoricalEvidence.PaidSessionUpliftBPS != -2300 || !got.HistoricalEvidence.MaterialDecline {
|
||||
t.Fatalf("historical evidence = %+v", got.HistoricalEvidence)
|
||||
}
|
||||
if got.RankingScore <= event.RankingScore {
|
||||
t.Fatalf("historical evidence did not increase ranking score: got %d, base %d", got.RankingScore, event.RankingScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEventEvidenceFallsBackWithSuppressionReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := UpcomingEvent{EvidenceID: "event:yalda", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}}
|
||||
|
||||
got := applyEventEvidence(event, nil, growth.EventEffect{}, growth.SuppressionInsufficientHistory)
|
||||
if got.InsightBasis != InsightBasisBusinessProfile || got.HistoricalEvidence != nil || got.ProfileEvidence == nil {
|
||||
t.Fatalf("applyEventEvidence() = %+v, want profile-only evidence", got)
|
||||
}
|
||||
if got.ProfileEvidence.HistoryUnavailableReason != growth.SuppressionInsufficientHistory {
|
||||
t.Fatalf("history unavailable reason = %q", got.ProfileEvidence.HistoryUnavailableReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedProfileSignalsDoesNotFabricateCategoryMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := growth.Event{Categories: []string{"رستوران"}, Tags: []string{"حضوری"}}
|
||||
context := MerchantContext{Category: "فروشگاه پوشاک", Niches: []string{"پوشاک کودک"}, SalesChannels: []string{"وبسایت"}}
|
||||
|
||||
got := matchedProfileSignals(event, context)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("matchedProfileSignals() = %+v, want no fabricated match", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationFallbackRequiresAtLeastThreeCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, count := range []int{0, 1, 2} {
|
||||
events := make([]UpcomingEvent, count)
|
||||
fallback, shouldCallAI := recommendationFallback(events)
|
||||
if fallback != nil || shouldCallAI {
|
||||
t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want nil,false", count, fallback, shouldCallAI)
|
||||
}
|
||||
}
|
||||
for _, count := range []int{3, 4, 5} {
|
||||
events := make([]UpcomingEvent, count)
|
||||
for index := range events {
|
||||
events[index] = UpcomingEvent{EvidenceID: fmt.Sprintf("event:%d", index), InsightBasis: InsightBasisBusinessProfile, RankingScore: int64(count - index)}
|
||||
}
|
||||
fallback, shouldCallAI := recommendationFallback(events)
|
||||
if fallback == nil || len(fallback.EventRecommendations) != 3 || !shouldCallAI {
|
||||
t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want three recommendations,true", count, fallback, shouldCallAI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMerchantContextsDeduplicatesCurrentProfilesDeterministically(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeMerchantContexts("فروشگاه", []MerchantContext{
|
||||
{BusinessDescriptions: []string{"فروش پوشاک"}, Niches: []string{"پوشاک", "هدیه"}, SalesChannels: []string{"وبسایت"}},
|
||||
{BusinessDescriptions: []string{"فروش هدیه", "فروش پوشاک"}, Niches: []string{"هدیه"}, SalesChannels: []string{"اینستاگرام", "وبسایت"}},
|
||||
})
|
||||
if strings.Join(got.BusinessDescriptions, ",") != "فروش پوشاک,فروش هدیه" || strings.Join(got.Niches, ",") != "پوشاک,هدیه" || strings.Join(got.SalesChannels, ",") != "وبسایت,اینستاگرام" {
|
||||
t.Fatalf("mergeMerchantContexts() = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMerchantContextsBoundsModelProfilePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
profiles := make([]MerchantContext, 0, 7)
|
||||
for index := range 7 {
|
||||
profiles = append(profiles, MerchantContext{BusinessDescriptions: []string{strings.Repeat(string(rune('آ'+index)), 400)}, Niches: []string{fmt.Sprintf("حوزه-%d", index)}, SalesChannels: []string{fmt.Sprintf("کانال-%d", index)}})
|
||||
}
|
||||
|
||||
got := mergeMerchantContexts("فروشگاه", profiles)
|
||||
if len(got.BusinessDescriptions) != 5 {
|
||||
t.Fatalf("business descriptions = %d, want bounded to 5", len(got.BusinessDescriptions))
|
||||
}
|
||||
for _, description := range got.BusinessDescriptions {
|
||||
if len([]rune(description)) > 320 {
|
||||
t.Fatalf("description contains %d runes, want at most 320", len([]rune(description)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user