Files

272 lines
13 KiB
Go

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
}