package report import ( "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestDailyValidPeriod(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T17:45:00-05:00") resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location}) if err == nil { t.Fatal("Resolve() error = nil, want explicit date requirement") } if !strings.Contains(err.Error(), "requires an explicit date") { t.Fatalf("Resolve() error = %v, want explicit date requirement", err) } _ = resolved } func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T17:45:00-05:00") date := mustParse("2026-05-31T12:00:00-05:00") resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") if resolved.Definition.PromptID != "weather.daily_generated_text" { t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID) } if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) } if resolved.Definition.TemplateID != "daily" { t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID) } if resolved.Definition.GeneratedTextSchemaID != "daily" { t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID) } } func TestTodayValidPeriod(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T17:45:00-05:00") resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00") if resolved.Definition.PromptID != "weather.today_generated_text" { t.Fatalf("PromptID = %q, want weather.today_generated_text", resolved.Definition.PromptID) } if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) } if resolved.Definition.TemplateID != "today" { t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID) } if resolved.Definition.GeneratedTextSchemaID != "today" { t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID) } } func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T17:45:00-05:00") date := mustParse("2026-05-31T12:00:00-05:00") resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location, Date: date}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") } func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T20:00:00-05:00") resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00") if resolved.Definition.PromptID != "weather.tomorrow_generated_text" { t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID) } if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) } if resolved.Definition.TemplateID != "tomorrow" { t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID) } if resolved.Definition.GeneratedTextSchemaID != "tomorrow" { t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID) } } func TestThreeDayPeriodCalculation(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T05:00:00-05:00") resolved, err := Resolve(ThreeDay, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00") } func TestHourlyLookupAndPeriodCalculation(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T05:15:00-05:00") resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } if resolved.Definition.ID != Hourly { t.Fatalf("ID = %q, want hourly", resolved.Definition.ID) } if resolved.Definition.PromptID != "weather.hourly_generated_text" { t.Fatalf("PromptID = %q, want weather.hourly_generated_text", resolved.Definition.PromptID) } if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) } if resolved.Definition.TemplateID != "hourly" { t.Fatalf("TemplateID = %q, want hourly", resolved.Definition.TemplateID) } if resolved.Definition.GeneratedTextSchemaID != "hourly" { t.Fatalf("GeneratedTextSchemaID = %q, want hourly", resolved.Definition.GeneratedTextSchemaID) } if resolved.Definition.ComparisonStrategy != CompareRollingWindow { t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00") } func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) { location, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("load location: %v", err) } now := mustParse("2026-05-29T10:15:00Z") resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00") } func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T22:30:00-05:00") resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00") } func TestWeekendPeriodCalculation(t *testing.T) { location := mustLoadLocation(t) tests := []struct { name string now string start string end string }{ {name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "tuesday", now: "2026-05-26T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "wednesday", now: "2026-05-27T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "thursday", now: "2026-05-28T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"}, {name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resolved, err := Resolve(Weekend, ResolveRequest{Now: mustParse(tt.now), Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, tt.start, tt.end) }) } } func TestWeekendSundayErrors(t *testing.T) { location := mustLoadLocation(t) _, err := Resolve(Weekend, ResolveRequest{Now: mustParse("2026-05-31T08:00:00-05:00"), Location: location}) if err == nil { t.Fatal("Resolve() error = nil, want Sunday weekend error") } if !strings.Contains(err.Error(), "Sunday") { t.Fatalf("error = %q, want Sunday context", err.Error()) } } func TestStormManualPeriodParsingAndValidation(t *testing.T) { location := mustLoadLocation(t) period, err := ParseStormPeriod("2026-05-29T18:00", "2026-05-30T06:00:00-05:00", location) if err != nil { t.Fatalf("ParseStormPeriod() error = %v", err) } assertPeriod(t, period, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00") _, err = ParseStormPeriod("2026-05-30T06:00", "2026-05-29T18:00", location) if err == nil { t.Fatal("ParseStormPeriod() error = nil, want invalid period error") } } func TestStormResolve(t *testing.T) { location := mustLoadLocation(t) resolved, err := Resolve(Storm, ResolveRequest{ Now: mustParse("2026-05-29T12:00:00-05:00"), Location: location, StormStart: mustParse("2026-05-29T18:00:00-05:00"), StormEnd: mustParse("2026-05-30T06:00:00-05:00"), }) if err != nil { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00") if resolved.Definition.ComparisonStrategy != CompareExplicitWindow { t.Fatalf("ComparisonStrategy = %q, want explicit event window", resolved.Definition.ComparisonStrategy) } } func TestIDForCommandName(t *testing.T) { tests := []struct { name string want ID }{ {name: "daily", want: Daily}, {name: "today", want: Today}, {name: "tomorrow", want: Tomorrow}, {name: "hourly", want: Hourly}, {name: "three-day", want: ThreeDay}, {name: "weekend", want: Weekend}, {name: "storm", want: Storm}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := IDForCommandName(tt.name) if err != nil { t.Fatalf("IDForCommandName() error = %v", err) } if got != tt.want { t.Fatalf("IDForCommandName() = %q, want %q", got, tt.want) } }) } if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" { t.Fatalf("CommandNames() = %s, want stable command names", names) } dailyID, err := IDForCommandName("daily") if err != nil { t.Fatalf("IDForCommandName(daily) error = %v", err) } todayID, err := IDForCommandName("today") if err != nil { t.Fatalf("IDForCommandName(today) error = %v", err) } if dailyID == todayID { t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID) } if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) { t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err) } } func TestIDForConfigKey(t *testing.T) { tests := []struct { key string want ID }{ {key: "daily", want: Daily}, {key: "today", want: Today}, {key: "tomorrow", want: Tomorrow}, {key: "hourly", want: Hourly}, {key: "three_day", want: ThreeDay}, {key: "three-day", want: ThreeDay}, {key: "three_day_outlook", want: ThreeDay}, {key: "three-day-outlook", want: ThreeDay}, {key: "weekend", want: Weekend}, {key: "weekend_outlook", want: Weekend}, {key: "storm", want: Storm}, {key: "storm_report", want: Storm}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { got, err := IDForConfigKey(tt.key) if err != nil { t.Fatalf("IDForConfigKey() error = %v", err) } if got != tt.want { t.Fatalf("IDForConfigKey() = %q, want %q", got, tt.want) } }) } if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) { t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err) } retiredDailyKey := strings.Join([]string{"daily", "today"}, "_") if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) { t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err) } dailyID, err := IDForConfigKey("daily") if err != nil { t.Fatalf("IDForConfigKey(daily) error = %v", err) } todayID, err := IDForConfigKey("today") if err != nil { t.Fatalf("IDForConfigKey(today) error = %v", err) } if dailyID == todayID { t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID) } } func TestBatchForCommandName(t *testing.T) { tests := []struct { name string want Batch }{ {name: "morning", want: Morning}, {name: "evening", want: Evening}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := BatchForCommandName(tt.name) if err != nil { t.Fatalf("BatchForCommandName() error = %v", err) } if got != tt.want { t.Fatalf("BatchForCommandName() = %q, want %q", got, tt.want) } }) } if names := strings.Join(BatchCommandNames(), ","); names != "morning,evening" { t.Fatalf("BatchCommandNames() = %s, want stable batch command names", names) } if _, err := BatchForCommandName("hourly"); err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) { t.Fatalf("BatchForCommandName(hourly) error = %v, want unknown batch", err) } } func TestRegistryLookupErrorIsActionable(t *testing.T) { _, err := DefaultRegistry().Lookup(ID("unknown")) if err == nil { t.Fatal("Lookup() error = nil, want unknown report error") } if !strings.Contains(err.Error(), `unknown report "unknown"`) { t.Fatalf("error = %q, want unknown report context", err.Error()) } } func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) { ids := resolvedDefinitionIDs(DefaultRegistry().All()) want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"} if strings.Join(ids, ",") != strings.Join(want, ",") { t.Fatalf("All() ids = %#v, want %#v", ids, want) } } func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) { for _, definition := range DefaultRegistry().All() { if definition.PromptID == "" { t.Fatalf("%s PromptID is empty", definition.ID) } if definition.Generated && definition.GenerationMode == "" { t.Fatalf("%s GenerationMode is empty", definition.ID) } if definition.ComparisonStrategy == "" { t.Fatalf("%s ComparisonStrategy is empty", definition.ID) } } } func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) { for _, definition := range DefaultRegistry().All() { if !definition.Generated { continue } if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow { wantTemplate := string(definition.ID) if definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate) } if definition.TemplateID != wantTemplate { t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate) } if definition.GeneratedTextSchemaID != wantTemplate { t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate) } continue } if definition.GenerationMode != GenerationModeScriptoriumMarkdown { t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeScriptoriumMarkdown) } if definition.TemplateID != "" { t.Fatalf("%s TemplateID = %q, want empty for Markdown report", definition.ID, definition.TemplateID) } if definition.GeneratedTextSchemaID != "" { t.Fatalf("%s GeneratedTextSchemaID = %q, want empty for Markdown report", definition.ID, definition.GeneratedTextSchemaID) } } } func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { tests := []struct { id ID artifactGroup string batchOutputName string generated bool compatiblePriorIDs []ID comparisonStrategy ComparisonStrategy }{ { id: Daily, artifactGroup: "daily", batchOutputName: "daily.md", generated: true, compatiblePriorIDs: []ID{Daily}, comparisonStrategy: CompareSameValidDate, }, { id: Today, artifactGroup: "today", batchOutputName: "today.md", generated: true, compatiblePriorIDs: []ID{Today}, comparisonStrategy: CompareSameValidDate, }, { id: Tomorrow, artifactGroup: "tomorrow", batchOutputName: "tomorrow.md", generated: true, compatiblePriorIDs: []ID{Tomorrow}, comparisonStrategy: CompareSameValidDate, }, { id: Hourly, artifactGroup: "hourly", batchOutputName: "hourly.md", generated: true, compatiblePriorIDs: []ID{Hourly}, comparisonStrategy: CompareRollingWindow, }, { id: ThreeDay, artifactGroup: "three-day", batchOutputName: "three-day.md", generated: true, compatiblePriorIDs: []ID{ThreeDay}, comparisonStrategy: CompareSameValidDate, }, { id: Weekend, artifactGroup: "weekend", batchOutputName: "weekend.md", generated: true, compatiblePriorIDs: []ID{Weekend}, comparisonStrategy: CompareWeekendWindow, }, { id: Storm, artifactGroup: "storm", batchOutputName: "storm.md", generated: true, compatiblePriorIDs: []ID{Storm}, comparisonStrategy: CompareExplicitWindow, }, } registry := DefaultRegistry() for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { definition, err := registry.Lookup(tt.id) if err != nil { t.Fatalf("Lookup() error = %v", err) } if definition.ArtifactGroup != tt.artifactGroup { t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup) } if definition.BatchOutputName != tt.batchOutputName { t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName) } if definition.Generated != tt.generated { t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated) } if definition.ComparisonStrategy != tt.comparisonStrategy { t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy) } if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) { t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs) } for _, id := range tt.compatiblePriorIDs { if !definition.CompatibleWithPrior(id) { t.Fatalf("CompatibleWithPrior(%q) = false, want true", id) } } }) } } func TestGeneratedRegistryDefinitionsDeclareDistributorPathDefaults(t *testing.T) { for _, definition := range DefaultRegistry().All() { if !definition.Generated { continue } if len(definition.DistributorPathTemplates) == 0 { t.Fatalf("%s DistributorPathTemplates is empty", definition.ID) } } } func TestRegistryDefinitionsDeclareDefaultDistributorPathTemplates(t *testing.T) { tests := []struct { id ID want []string }{ { id: Hourly, want: []string{ "hourly/index.md", }, }, { id: Daily, want: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", }, }, { id: Today, want: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "today/index.md", }, }, { id: Tomorrow, want: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "tomorrow/index.md", }, }, { id: ThreeDay, want: []string{ "three-day/{valid_start_date}/{run_id}.md", "three-day/{valid_start_date}/index.md", }, }, { id: Weekend, want: []string{ "weekend/{valid_start_date}/{run_id}.md", "weekend/{valid_start_date}/index.md", }, }, { id: Storm, want: []string{ "storm/{storm_id}/{run_id}.md", "storm/{storm_id}/index.md", }, }, } registry := DefaultRegistry() for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { definition, err := registry.Lookup(tt.id) if err != nil { t.Fatalf("Lookup() error = %v", err) } if !reflect.DeepEqual(definition.DistributorPathTemplates, tt.want) { t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.want) } }) } } func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { tests := []struct { id ID want []module.ID }{ { id: Daily, want: []module.ID{ module.Metadata, module.CurrentConditions, module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, module.DailyPlanning, module.HourlyForecast, }, }, { id: Today, want: []module.ID{ module.Metadata, module.CurrentConditions, module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, module.HourlyForecast, module.TodayPlanning, }, }, { id: Tomorrow, want: []module.ID{ module.Metadata, module.CurrentConditions, module.NarrativeForecast, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, module.TomorrowPlanning, module.HourlyForecast, }, }, { id: Hourly, want: []module.ID{ module.Metadata, module.CurrentConditions, module.HourlyForecast, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, }, }, { id: ThreeDay, want: []module.ID{ module.Metadata, module.CurrentConditions, module.DerivedDaypartSummaries, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, }, }, { id: Weekend, want: []module.ID{ module.Metadata, module.CurrentConditions, module.DerivedDaypartSummaries, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, module.OutdoorWindows, }, }, { id: Storm, want: []module.ID{ module.Metadata, module.CurrentConditions, module.PrecipTiming, module.AlertDigest, module.SPCConvectiveOutlooks, module.AreaForecastDiscussion, module.SPCConvectiveDiscussion, module.WeatherStory, }, }, } registry := DefaultRegistry() for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { definition, err := registry.Lookup(tt.id) if err != nil { t.Fatalf("Lookup() error = %v", err) } if !reflect.DeepEqual(definition.ModuleIDs(), tt.want) { t.Fatalf("ModuleIDs() = %#v, want %#v", definition.ModuleIDs(), tt.want) } }) } } func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { base := DefaultRegistry() overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{ Daily: { {ID: module.Metadata}, {ID: module.AlertDigest}, }, }) if err != nil { t.Fatalf("WithModuleOverrides() error = %v", err) } definition, err := overridden.Lookup(Daily) if err != nil { t.Fatalf("Lookup(overridden) error = %v", err) } if !reflect.DeepEqual(definition.ModuleIDs(), []module.ID{module.Metadata, module.AlertDigest}) { t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs()) } defaultDefinition, err := base.Lookup(Daily) if err != nil { t.Fatalf("Lookup(default) error = %v", err) } if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) { t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs()) } if !reflect.DeepEqual(definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates) { t.Fatalf("overridden DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates) } } func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) { _, err := DefaultRegistry().WithModuleOverrides(map[ID][]module.ConfigItem{ ID("unknown"): {{ID: module.Metadata}}, }) if err == nil { t.Fatal("WithModuleOverrides() error = nil, want unknown report") } if !strings.Contains(err.Error(), `unknown report "unknown"`) { t.Fatalf("error = %q, want unknown report context", err.Error()) } } func TestResolvedMetadata(t *testing.T) { location := mustLoadLocation(t) resolved, err := Resolve(Daily, ResolveRequest{ Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location, Date: mustParse("2026-05-29T12:00:00-05:00"), }) if err != nil { t.Fatalf("Resolve() error = %v", err) } metadata := resolved.Metadata() if metadata.ReportID != Daily { t.Fatalf("ReportID = %q, want daily", metadata.ReportID) } if metadata.PromptID != "weather.daily_generated_text" { t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID) } if metadata.RunID != "20260529T100000.000000000Z_daily_2026-05-29" { t.Fatalf("RunID = %q, want dated Daily report id", metadata.RunID) } } func TestDailyRunIDIncludesValidDateDisambiguator(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T05:00:00-05:00") first, err := Resolve(Daily, ResolveRequest{ Now: now, Location: location, Date: mustParse("2026-05-31T12:00:00-05:00"), }) if err != nil { t.Fatalf("Resolve(first) error = %v", err) } second, err := Resolve(Daily, ResolveRequest{ Now: now, Location: location, Date: mustParse("2026-06-01T12:00:00-05:00"), }) if err != nil { t.Fatalf("Resolve(second) error = %v", err) } firstRunID := first.Metadata().RunID secondRunID := second.Metadata().RunID if firstRunID == secondRunID { t.Fatalf("Daily RunIDs both = %q, want distinct valid-date suffixes", firstRunID) } if firstRunID != "20260529T100000.000000000Z_daily_2026-05-31" { t.Fatalf("first RunID = %q, want valid-date suffix", firstRunID) } if secondRunID != "20260529T100000.000000000Z_daily_2026-06-01" { t.Fatalf("second RunID = %q, want valid-date suffix", secondRunID) } } func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) { location := mustLoadLocation(t) resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location}) if err != nil { t.Fatalf("Resolve() error = %v", err) } metadata := resolved.Metadata() if metadata.ReportID != Hourly { t.Fatalf("ReportID = %q, want hourly", metadata.ReportID) } if metadata.PromptID != "weather.hourly_generated_text" { t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID) } if metadata.RunID != "20260529T101500.000000000Z_hourly" { t.Fatalf("RunID = %q, want unchanged report id shape", metadata.RunID) } } func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) { t.Helper() if !period.IsValid() { t.Fatalf("period = %#v, want valid", period) } if period.Start.Format(time.RFC3339) != wantStart { t.Fatalf("Start = %s, want %s", period.Start.Format(time.RFC3339), wantStart) } if period.End.Format(time.RFC3339) != wantEnd { t.Fatalf("End = %s, want %s", period.End.Format(time.RFC3339), wantEnd) } } func resolvedDefinitionIDs(definitions []Definition) []string { ids := make([]string, 0, len(definitions)) for _, definition := range definitions { ids = append(ids, string(definition.ID)) } return ids } func mustLoadLocation(t *testing.T) *time.Location { t.Helper() location, err := time.LoadLocation("America/Chicago") if err != nil { t.Fatalf("load location: %v", err) } return location } func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed }