From 8a0b30e936a2da09add4a3178c8882c03a2009b9 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 21 Aug 2026 00:18:10 +0330 Subject: [PATCH] fix(growth): validate reviewed event catalog --- internal/growth/catalog.go | 50 ++++++++++++++++++++++++--- internal/growth/catalog_test.go | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 internal/growth/catalog_test.go diff --git a/internal/growth/catalog.go b/internal/growth/catalog.go index 78f6bd7..a6b8bd6 100644 --- a/internal/growth/catalog.go +++ b/internal/growth/catalog.go @@ -1,10 +1,23 @@ package growth -import "time" +import ( + "fmt" + "slices" + "strings" + "time" +) const officialCalendarSource = "https://calendar.ut.ac.ir/" const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1" +var importedMerchantCategories = []string{ + "ارائه دهنده خدمات اینترنت", + "مراکز آموزشی مجازی", + "کیف و کفش فروشی", + "خدمات شبکه‌های کامپیوتری و اینترنت", + "فروشگاه لوازم آرایشی و بهداشتی", +} + // Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are // Gregorian Tehran-local calendar dates and cover the imported 1405 history plus // the first 90 forecast days after 2026-06-30. @@ -14,10 +27,16 @@ func Catalog1405() []Event { catalogEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", EventOfficial, []string{"تفریح", "خرید"}), catalogEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", EventOfficial, []string{"هدیه", "آموزش", "قدردانی"}), catalogEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "official-holiday", EventOfficial, []string{"تعطیل"}), - catalogEvent("fathers-day-commercial-1405", "روز پدر", "2026-08-25", "2026-08-25", "appreciation", EventCommercial, []string{"هدیه", "خرید", "قدردانی"}), + catalogEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", EventOfficial, []string{"هدیه", "قدردانی", "حرفه‌ای"}), catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []string{"آموزش", "خرید", "مدرسه"}), } - return append([]Event(nil), events...) + result := make([]Event, len(events)) + for index, event := range events { + result[index] = event + result[index].Tags = slices.Clone(event.Tags) + result[index].Categories = slices.Clone(event.Categories) + } + return result } func catalogEvent(id, title, start, end, family string, kind EventKind, tags []string) Event { @@ -25,7 +44,30 @@ func catalogEvent(id, title, start, end, family string, kind EventKind, tags []s if kind == EventCommercial { source = curatedCommercialSource } - return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: append([]string(nil), tags...), Source: source, Kind: kind} + return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: source, Categories: slices.Clone(importedMerchantCategories), Kind: kind} +} + +func ValidateCatalog(events []Event) error { + seen := make(map[string]struct{}, len(events)) + for index, event := range events { + if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" { + return fmt.Errorf("growth catalog event %d is incomplete", index) + } + if _, exists := seen[event.ID]; exists { + return fmt.Errorf("growth catalog contains duplicate id %q", event.ID) + } + seen[event.ID] = struct{}{} + if event.StartDate.IsZero() || event.EndDate.Before(event.StartDate) { + return fmt.Errorf("growth catalog event %q has invalid date range", event.ID) + } + if event.Kind != EventOfficial && event.Kind != EventCommercial { + return fmt.Errorf("growth catalog event %q has invalid kind", event.ID) + } + if len(normalizedSet(event.Tags)) == 0 || len(event.Categories) == 0 { + return fmt.Errorf("growth catalog event %q lacks tags or supported categories", event.ID) + } + } + return nil } func mustCatalogDate(value string) time.Time { diff --git a/internal/growth/catalog_test.go b/internal/growth/catalog_test.go new file mode 100644 index 0000000..3ffafbd --- /dev/null +++ b/internal/growth/catalog_test.go @@ -0,0 +1,60 @@ +package growth + +import "testing" + +func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) { + first := Catalog1405() + if err := ValidateCatalog(first); err != nil { + t.Fatalf("catalog should be valid: %v", err) + } + first[0].Tags[0] = "mutated" + second := Catalog1405() + if second[0].Tags[0] == "mutated" { + t.Fatal("catalog tags escaped through a shared backing array") + } +} + +func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) { + event := catalogEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", EventOfficial, []string{"tag"}) + if err := ValidateCatalog([]Event{event, event}); err == nil { + t.Fatal("expected duplicate id error") + } + event.Source = "" + if err := ValidateCatalog([]Event{event}); err == nil { + t.Fatal("expected missing source error") + } +} + +func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) { + target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")} + excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)} + history := make([]DailyPoint, 0, 9) + for week := 1; week <= 8; week++ { + count := int64(30) + if week == 1 { + count = 1_000 + } + history = append(history, DailyPoint{Date: target.StartDate.AddDate(0, 0, -7*week), SessionCount: count, PaidSessionCount: count, PaidAmountRial: count * 100}) + } + history = append(history, DailyPoint{Date: target.StartDate, SessionCount: 50, PaidSessionCount: 50, PaidAmountRial: 5_000}) + effect := AnalyzeEventEffect(history, target, []Event{excluded}) + if effect.ObservationCount != 7 || effect.BaselineSessionCount != 30 || !effect.MaterialWin { + t.Fatalf("catalog event polluted baseline: %+v", effect) + } +} + +func TestForecastSuppressesPoorBacktest(t *testing.T) { + start := date("2026-07-01") + history := make([]DailyPoint, 0, 90) + for i := 90; i > 0; i-- { + value := int64(100) + if i%2 == 0 { + value = 1_000 + } + history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000}) + } + forecast := BuildForecast(history, nil, start, 90) + if forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 { + t.Fatalf("poor model should be suppressed: %+v", forecast) + } +}