fix(growth): validate reviewed event catalog
This commit is contained in:
@@ -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 {
|
||||
|
||||
60
internal/growth/catalog_test.go
Normal file
60
internal/growth/catalog_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user