220 lines
10 KiB
Go
220 lines
10 KiB
Go
package growthreport
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
|
|
)
|
|
|
|
func TestEvidenceToolsReturnsUnavailableForKnownEventWithoutPlaybook(t *testing.T) {
|
|
t.Parallel()
|
|
tools := NewEvidenceTools(Analysis{
|
|
Evidence: Evidence{Events: []UpcomingEvent{{EvidenceID: "event:known"}}},
|
|
Fallback: &AICopy{},
|
|
})
|
|
|
|
got, err := tools.Execute(context.Background(), "get_campaign_playbook", json.RawMessage(`{"evidenceId":"event:known"}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute() error = %v, want an explicit unavailable result", err)
|
|
}
|
|
if string(got) != `{"state":"UNAVAILABLE","reason":"NO_PLAYBOOK"}` {
|
|
t.Fatalf("Execute() = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestEvidenceToolsRejectsUnknownPlaybookEvidence(t *testing.T) {
|
|
t.Parallel()
|
|
tools := NewEvidenceTools(Analysis{
|
|
Evidence: Evidence{Events: []UpcomingEvent{{EvidenceID: "event:known"}}},
|
|
Fallback: &AICopy{},
|
|
})
|
|
|
|
if _, err := tools.Execute(context.Background(), "get_campaign_playbook", json.RawMessage(`{"evidenceId":"event:unknown"}`)); err == nil {
|
|
t.Fatal("Execute() error = nil, want unknown evidence error")
|
|
}
|
|
}
|
|
|
|
func TestUpcomingEventsKeepsFiveChronologicalEligibleCandidates(t *testing.T) {
|
|
t.Parallel()
|
|
start := time.Date(2026, time.August, 22, 0, 0, 0, 0, time.UTC)
|
|
catalog := []growth.Event{
|
|
{ID: "past", TitleFA: "رویداد گذشته", StartDate: start.AddDate(0, 0, -1)},
|
|
{ID: "one", TitleFA: "رویداد اول", StartDate: start},
|
|
{ID: "two", TitleFA: "رویداد دوم", StartDate: start.AddDate(0, 0, 40)},
|
|
{ID: "three", TitleFA: "رویداد سوم", StartDate: start.AddDate(0, 0, 100)},
|
|
{ID: "four", TitleFA: "رویداد چهارم", StartDate: start.AddDate(0, 0, 190)},
|
|
{ID: "five", TitleFA: "رویداد پنجم", StartDate: start.AddDate(0, 0, 280), OfficialHoliday: true},
|
|
{ID: "six", TitleFA: "رویداد ششم", StartDate: start.AddDate(0, 0, 320)},
|
|
}
|
|
|
|
got := upcomingEvents(catalog, start, 5)
|
|
if len(got) != 5 {
|
|
t.Fatalf("upcomingEvents() returned %d events, want 5", len(got))
|
|
}
|
|
for index, want := range []string{"event:one", "event:two", "event:three", "event:four", "event:five"} {
|
|
if got[index].EvidenceID != want {
|
|
t.Fatalf("upcomingEvents()[%d] = %q, want %q", index, got[index].EvidenceID, want)
|
|
}
|
|
}
|
|
if !got[4].OfficialHoliday {
|
|
t.Fatal("eligible official holiday was removed from the candidate set")
|
|
}
|
|
}
|
|
|
|
func TestFallbackEventRecommendationsUseTheFirstThreeCandidates(t *testing.T) {
|
|
t.Parallel()
|
|
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)))
|
|
}
|
|
}
|
|
}
|