package growth import ( "bytes" "encoding/json" "testing" "time" ) func TestAnalyzeEventEffectUsesMatchingWeekdaysAndThresholds(t *testing.T) { event := Event{ID: "teachers-day", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")} history := make([]DailyPoint, 0, 9) for weeks := 8; weeks >= 1; weeks-- { history = append(history, DailyPoint{Date: event.StartDate.AddDate(0, 0, -7*weeks), SessionCount: 25, PaidSessionCount: 20, PaidAmountRial: 2_000_000}) } history = append(history, DailyPoint{Date: event.StartDate, SessionCount: 40, PaidSessionCount: 30, PaidAmountRial: 3_000_000}) effect := AnalyzeEventEffect(history, event, nil) if !effect.Eligible || !effect.MaterialWin { t.Fatalf("expected eligible material win, got %+v", effect) } if effect.BaselineSessionCount != 25 || effect.SessionUpliftBPS != 6_000 || effect.AmountUpliftBPS != 5_000 { t.Fatalf("unexpected effect: %+v", effect) } } func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) { event := Event{ID: "small", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")} history := []DailyPoint{{Date: event.StartDate.AddDate(0, 0, -7), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -14), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -21), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -28), SessionCount: 20}, {Date: event.StartDate, SessionCount: 50}} effect := AnalyzeEventEffect(history, event, nil) if effect.Eligible || effect.SuppressionReason != SuppressionInsufficientBaselineSessions { t.Fatalf("expected baseline suppression, got %+v", effect) } } func TestMostRecentExactOccurrenceRequiresOccasionAndVariant(t *testing.T) { t.Parallel() upcoming := Event{ID: "black-friday-2026", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2026, 11, 27, 0, 0, 0, 0, time.UTC)} catalog := []Event{ {ID: "wrong-variant", OccasionKey: "black-friday", EventVariant: "PRIMARY", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)}, {ID: "old", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC)}, {ID: "recent", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)}, {ID: "wrong-key", OccasionKey: "cyber-monday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)}, } got, ok := MostRecentExactOccurrence(upcoming, catalog) if !ok || got.ID != "recent" { t.Fatalf("MostRecentExactOccurrence() = (%+v, %v), want recent exact occurrence", got, ok) } } func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) { historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}} upcoming := []Event{ {ID: "exact", Family: "appreciation", Tags: []string{"unrelated"}}, {ID: "similar", Family: "other", Tags: []string{"gift", "education", "campaign"}}, {ID: "weak", Family: "other", Tags: []string{"internet"}}, } matches := MatchUpcomingEvents(historical, upcoming) if len(matches) != 2 || matches[0].Event.ID != "exact" || matches[0].ScoreBPS != 10_000 || matches[1].Event.ID != "similar" { t.Fatalf("unexpected matches: %+v", matches) } } func TestBuildOpportunitiesReturnsThreeNearestCommercialEventsWithoutHistoricalWins(t *testing.T) { start := date("2026-08-21") forecast := Forecast{Confidence: ConfidenceMedium} for day := start; day.Before(start.AddDate(0, 0, 120)); day = day.AddDate(0, 0, 1) { forecast.Points = append(forecast.Points, ForecastPoint{ Date: day, PaidSessions: Range{P10: 80, P50: 100, P90: 120}, PaidAmountRial: Range{P10: 800, P50: 1000, P90: 1200}, }) } catalog := filterCatalog(testCatalog(), "مراکز آموزشی مجازی") got := buildOpportunities(nil, catalog, forecast, start, 3) if len(got) != 3 { t.Fatalf("got %d opportunities, want 3: %+v", len(got), got) } want := []string{"back-to-school-1405", "autumn-opening-1405", "halloween-2026"} for index, id := range want { if got[index].EventID != id { t.Fatalf("opportunity %d = %q, want %q", index, got[index].EventID, id) } } } func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) { start := date("2026-07-01") history := make([]DailyPoint, 0, 112) for i := 112; i > 0; i-- { day := start.AddDate(0, 0, -i) base := int64(100 + int(day.Weekday())*5) history = append(history, DailyPoint{Date: day, SessionCount: base + 20, PaidSessionCount: base, PaidAmountRial: base * 100_000}) } forecast := BuildForecast(history, nil, start, 90) if len(forecast.Points) != 90 || forecast.Confidence == ConfidenceInsufficientData { t.Fatalf("unexpected forecast: confidence=%s points=%d", forecast.Confidence, len(forecast.Points)) } for _, point := range forecast.Points { if point.PaidSessions.P10 > point.PaidSessions.P50 || point.PaidSessions.P50 > point.PaidSessions.P90 || point.PaidAmountRial.P10 > point.PaidAmountRial.P50 || point.PaidAmountRial.P50 > point.PaidAmountRial.P90 { t.Fatalf("unordered range: %+v", point) } } } func TestBuildForecastUsesLimitedHistoryWithLowConfidence(t *testing.T) { start := date("2026-07-01") history := make([]DailyPoint, 0, 14) for daysAgo := 14; daysAgo > 0; daysAgo-- { day := start.AddDate(0, 0, -daysAgo) value := int64(80 + int(day.Weekday())*5) history = append(history, DailyPoint{Date: day, SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 100_000}) } forecast := BuildForecast(history, nil, start, 90) if forecast.Confidence != ConfidenceLow || len(forecast.Points) != 90 { t.Fatalf("limited history should produce a low-confidence forecast: confidence=%s points=%d", forecast.Confidence, len(forecast.Points)) } for _, point := range forecast.Points { if point.PaidSessions.P10 > point.PaidSessions.P50 || point.PaidSessions.P50 > point.PaidSessions.P90 { t.Fatalf("limited-history range is unordered: %+v", point.PaidSessions) } } } func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) { forecast := Forecast{Confidence: ConfidenceMedium, Points: []ForecastPoint{ {Date: date("2026-07-01"), BaselineSessions: 200, PaidSessions: Range{P50: 150}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 7_000_000}}, {Date: date("2026-07-02"), BaselineSessions: 99, PaidSessions: Range{P50: 10}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 1}}, }} risks := DetectRisks(forecast, 3) if len(risks) != 1 || risks[0].Date != date("2026-07-01") { t.Fatalf("unexpected risks: %+v", risks) } } func TestDetectRisksCoalescesAdjacentDaysAndRanksDeepestFirst(t *testing.T) { forecast := Forecast{Confidence: ConfidenceHigh, Points: []ForecastPoint{ {Date: date("2026-07-01"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 150}, PaidAmountRial: Range{P50: 750}}, {Date: date("2026-07-02"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 100}, PaidAmountRial: Range{P50: 500}}, {Date: date("2026-07-04"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 120}, PaidAmountRial: Range{P50: 600}}, }} risks := DetectRisks(forecast, 3) if len(risks) != 2 || risks[0].Date != date("2026-07-01") || risks[0].EndDate != date("2026-07-02") || risks[0].SessionDeclineBPS != 5_000 { t.Fatalf("unexpected ranked/coalesced risks: %+v", risks) } } func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) { peers := make([]PeerMerchantValue, 10) for i := range peers { peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100} } result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers}) if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 7_500 { t.Fatalf("unexpected peer result: %+v", result) } suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers}) if suppressed.State != PeerSuppressed || suppressed.Reason != SuppressionTerminalScope { t.Fatalf("unexpected terminal result: %+v", suppressed) } } func TestFingerprintIsStableAndVersionSensitive(t *testing.T) { input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini", ModelConfigVersion: "cfg-1"} a := Fingerprint(input) b := Fingerprint(input) input.Model = "other" if a == "" || a != b || a == Fingerprint(input) { t.Fatalf("fingerprint stability/versioning failed") } } func TestPeerPercentileUsesMidRankForTies(t *testing.T) { peers := make([]PeerMerchantValue, 10) for index := range peers { peers[index] = PeerMerchantValue{PaidSessions: 50, SessionCount: 100} } result := EvaluatePeer(PeerInput{MerchantPaidSessions: 50, Peers: peers}) if result.PercentileBPS != 5_000 { t.Fatalf("all-tie percentile = %d, want 5000", result.PercentileBPS) } } func TestFingerprintIncludesModelConfigurationPolicy(t *testing.T) { input := FingerprintInput{MerchantID: 7, ForecastStart: date("2026-07-01"), HorizonDays: 90, Model: "mini", ModelConfigVersion: "cfg-1"} first := Fingerprint(input) input.ModelConfigVersion = "cfg-2" if first == Fingerprint(input) { t.Fatal("model configuration policy must invalidate fingerprint") } } func TestTehranRefreshBoundary(t *testing.T) { last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21. next := NextRefreshAt(last) want := time.Date(2026, 8, 21, 20, 30, 0, 0, time.UTC) if !next.Equal(want) || CanManualRefresh(last, want.Add(-time.Second)) || !CanManualRefresh(last, want) { t.Fatalf("unexpected boundary next=%s", next) } } func TestBuildFallbackUsesOnlyEvidenceAndPersianCopy(t *testing.T) { bundle := EvidenceBundle{Opportunities: []Opportunity{{EvidenceID: "opp-1", EventTitleFA: "روز معلم"}}, Risks: []Risk{{EvidenceID: "risk-1"}}} briefs := BuildFallback(bundle) if len(briefs) != 2 || briefs[0].EvidenceIDs[0] != "opp-1" || briefs[0].HeadlineFA == "" { t.Fatalf("unexpected briefs: %+v", briefs) } } func TestBuildDeterministicReportLimitsAndOrdersOpportunities(t *testing.T) { start := date("2026-07-01") history := make([]DailyPoint, 0, 120) for i := 120; i > 0; i-- { day := start.AddDate(0, 0, -i) sessions := int64(120) amount := int64(12_000_000) if day.Equal(date("2026-05-02")) { sessions = 180 amount = 18_000_000 } history = append(history, DailyPoint{Date: day, SessionCount: sessions, PaidSessionCount: sessions, PaidAmountRial: amount}) } catalog := []Event{ {ID: "past", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02"), Family: "appreciation", Tags: []string{"gift"}}, {ID: "future", TitleFA: "روز پدر", StartDate: date("2026-07-10"), EndDate: date("2026-07-10"), Family: "appreciation", Tags: []string{"gift"}}, } report := BuildDeterministicReport(ReportInput{History: history, Catalog: catalog, ForecastStart: start, HorizonDays: 90, Peer: &PeerInput{TerminalScoped: true}}) if len(report.HistoricalWins) != 1 || len(report.Opportunities) != 1 || report.Opportunities[0].EventID != "future" { t.Fatalf("unexpected report evidence: %+v", report) } if report.Peer == nil || report.Peer.Reason != SuppressionTerminalScope { t.Fatalf("expected terminal peer suppression: %+v", report.Peer) } } func TestBuildDeterministicReportSerializesEmptyCollectionsAsArrays(t *testing.T) { report := BuildDeterministicReport(ReportInput{ForecastStart: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), HorizonDays: 90}) encoded, err := json.Marshal(report) if err != nil { t.Fatal(err) } for _, field := range []string{`"historicalWins":[]`, `"opportunities":[]`, `"risks":[]`, `"fallbackBriefs":[]`} { if !bytes.Contains(encoded, []byte(field)) { t.Fatalf("report must encode %s as an array: %s", field, encoded) } } } func TestFingerprintTreatsEquivalentForecastDatesEqually(t *testing.T) { tehran, err := time.LoadLocation("Asia/Tehran") if err != nil { t.Fatal(err) } local := time.Date(2026, 7, 1, 0, 0, 0, 0, tehran) utcInstant := local.UTC() base := FingerprintInput{MerchantID: 1, ForecastStart: local, HorizonDays: 90} a := Fingerprint(base) base.ForecastStart = utcInstant if a != Fingerprint(base) { t.Fatal("equivalent forecast instants must fingerprint equally") } } func date(value string) time.Time { parsed, err := time.Parse(time.DateOnly, value) if err != nil { panic(err) } return parsed }