272 lines
13 KiB
Go
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
|
|
}
|