package briefing import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() tests := []struct { id module.ID stanza string }{ {id: module.Metadata, stanza: "metadata"}, {id: module.CurrentConditions, stanza: "current_conditions"}, {id: module.NarrativeForecast, stanza: "narrative_forecast"}, {id: module.HourlyForecast, stanza: "hourly_forecast"}, {id: module.AlertDigest, stanza: "alert_digest"}, {id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"}, {id: module.WeatherStory, stanza: "weather_story"}, } for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output == nil { t.Fatal("BuildModule() output = nil, want stanza") } if output.ID != tt.id || output.StanzaName != tt.stanza { t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza) } }) } } func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[HourlyForecastModule](t, output) if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 { t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value) } period := value.Periods[0] if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) } if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" { t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds) } if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal hourly forecast: %v", err) } jsonText := string(data) for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("hourly json = %s, want field %s", jsonText, field) } } if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) } if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) { t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText) } } func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) { t.Fatalf("BuildModule() error = %v, want incompatible report", err) } } func TestHourlyForecastModuleBuildsForHourly(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[HourlyForecastModule](t, output) if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." { t.Fatalf("HourlyForecast = %#v, want hourly report period", value) } } func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[NarrativeForecastModule](t, output) if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 { t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value) } period := value.Periods[0] if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) } if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" { t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds) } if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) } if period.WindDirection != "NE" { t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal narrative forecast: %v", err) } jsonText := string(data) for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("narrative json = %s, want field %s", jsonText, field) } } if strings.Contains(jsonText, "wind_direction_degrees") { t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) } if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) { t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText) } if strings.Contains(jsonText, "Tomorrow night") { t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) } } func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast}) if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) { t.Fatalf("BuildModule() error = %v, want incompatible report", err) } } func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[MetadataModule](t, output) if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { t.Fatalf("metadata = %#v, want report identity", value) } if value.Location == nil || value.Location.Name != "Brentwood" { t.Fatalf("Location = %#v, want configured location", value.Location) } if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" { t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings) } if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 { t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal metadata: %v", err) } jsonText := string(data) if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") { t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText) } } func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[CurrentConditionsModule](t, output) if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { t.Fatalf("CurrentConditions = %#v, want current condition facts", value) } if value.WindDirection != "S" { t.Fatalf("WindDirection = %q, want S", value.WindDirection) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal current conditions: %v", err) } jsonText := string(data) for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { if !strings.Contains(jsonText, field) { t.Fatalf("current json = %s, want field %s", jsonText, field) } } if strings.Contains(jsonText, "wind_direction_degrees") { t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText) } } func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.Alerts = &weatherdata.AlertRun{} ctx.Derived.AlertOverlaps = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest}) if err != nil { t.Fatalf("BuildModule(checked empty) error = %v", err) } checked := moduleValue[AlertDigestModule](t, output) if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing { t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked) } ctx.Collected.Alerts = nil ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}} output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest}) if err != nil { t.Fatalf("BuildModule(missing) error = %v", err) } missing := moduleValue[AlertDigestModule](t, output) if missing.Checked || !missing.Missing { t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing) } } func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.Current = nil ctx.Collected.Narrative = nil ctx.Collected.Hourly = nil ctx.Derived.ValidPeriodNarrativePeriods = nil ctx.Derived.ValidPeriodHourlyPeriods = nil ctx.Collected.Discussion = nil ctx.Collected.WeatherStory = nil for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} { output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id}) if err != nil { t.Fatalf("BuildModule(%s) error = %v", id, err) } if output != nil { t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output) } } } func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion}) if err != nil { t.Fatalf("BuildModule(afd) error = %v", err) } afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput) if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." { t.Fatalf("AFD = %#v, want discussion sections", afd) } storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory}) if err != nil { t.Fatalf("BuildModule(weather story) error = %v", err) } story := moduleValue[WeatherStoryModule](t, storyOutput) if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { t.Fatalf("WeatherStory = %#v, want structured story fields", story) } if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" { t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds) } data, err := json.Marshal(storyOutput.Value) if err != nil { t.Fatalf("Marshal weather story: %v", err) } if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") { t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) } if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") { t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data)) } } func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ ID: module.AreaForecastDiscussion, Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}, }) if err != nil { t.Fatalf("BuildModule() error = %v", err) } afd := moduleValue[AreaForecastDiscussionModule](t, output) if afd.ShortTerm != "Showers increase this afternoon." { t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm) } if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" { t.Fatalf("AFD = %#v, want only short_term section", afd) } } func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly) var item module.ConfigItem for _, candidate := range ctx.Resolved.Definition.Modules { if candidate.ID == module.AreaForecastDiscussion { item = candidate break } } if item.ID == "" { t.Fatal("hourly default modules missing area_forecast_discussion") } output, err := registry.BuildModule(ctx, item) if err != nil { t.Fatalf("BuildModule() error = %v", err) } afd := moduleValue[AreaForecastDiscussionModule](t, output) if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." { t.Fatalf("AFD = %#v, want key messages and short term", afd) } if afd.Product != "" || afd.LongTerm != "" { t.Fatalf("AFD = %#v, want product and long term omitted", afd) } } func testModuleContext() ModuleContext { generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") definition := report.DefaultRegistry().MustLookup(report.DailyToday) resolved := report.Resolved{ Definition: definition, GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{ Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"), End: mustParseModuleTime("2026-05-30T00:00:00-05:00"), }, } isDay := true tempF := 74.0 apparentF := 76.0 humidity := 71.0 windMph := 8.0 windDirection := 190.0 narrativeTempF := 81.0 narrativePop := 60.0 narrativeWind := 12.0 narrativeWindDirection := 45.0 hourlyTempF := 76.0 hourlyPop := 70.0 hourlyHumidity := 66.0 hourlyWindMph := 14.0 updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") return ModuleContext{ Resolved: resolved, Collected: facts.CollectedFacts{ Current: &weatherdata.Current{ ConditionText: "Partly cloudy", IsDay: &isDay, TemperatureF: &tempF, ApparentTemperatureF: &apparentF, RelativeHumidityPercent: &humidity, WindSpeedMph: &windMph, WindDirectionDegrees: &windDirection, }, Narrative: &weatherdata.ForecastRun{ LocationID: "test-grid", LocationName: "Testville", IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), UpdatedAt: &updatedAt, Product: "narrative", Periods: []weatherdata.ForecastPeriod{ { Name: "Today", StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), IsDay: &isDay, TextDescription: "Morning storms, then partly sunny.", TemperatureF: floatPtr(narrativeTempF), WindSpeedMph: &narrativeWind, WindDirectionDegrees: &narrativeWindDirection, ProbabilityOfPrecipitationPercent: &narrativePop, }, }, }, Hourly: &weatherdata.ForecastRun{ LocationID: "test-grid", LocationName: "Testville", IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), UpdatedAt: &updatedAt, Product: "hourly", Periods: []weatherdata.ForecastPeriod{ { StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), TextDescription: "Showers likely.", TemperatureF: &hourlyTempF, WindSpeedMph: &hourlyWindMph, WindDirectionDegrees: &windDirection, ProbabilityOfPrecipitationPercent: &hourlyPop, RelativeHumidityPercent: &hourlyHumidity, }, { StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"), TextDescription: "Tomorrow showers.", }, }, }, Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`), }}, Discussion: &weatherdata.Discussion{ Product: "discussion", KeyMessages: []string{"Scattered showers are possible."}, ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."}, LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."}, }, WeatherStory: &weatherdata.WeatherStory{ OfficeID: "LSX", StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), UpdatedAt: &updatedAt, Title: "Rain Chances", Description: "Scattered showers are possible.", AltText: "Weather story graphic with rain chances.", Priority: true, Order: 1, DownloadURL: "https://example.invalid/story.png", }, SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}}, SourceWarnings: []weatherdata.SourceWarning{{ Source: "daily", Code: "missing_source", Severity: "warning", Message: "daily source is missing", Endpoint: "/forecast/daily", CompletenessImpact: "source omitted", }}, }, Derived: facts.DerivedFacts{ ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{ { StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), TextDescription: "Showers likely.", TemperatureF: &hourlyTempF, WindSpeedMph: &hourlyWindMph, WindDirectionDegrees: &windDirection, ProbabilityOfPrecipitationPercent: &hourlyPop, RelativeHumidityPercent: &hourlyHumidity, }, }, ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{ { Name: "Today", StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), IsDay: &isDay, TextDescription: "Morning storms, then partly sunny.", TemperatureF: floatPtr(narrativeTempF), WindSpeedMph: &narrativeWind, WindDirectionDegrees: &narrativeWindDirection, ProbabilityOfPrecipitationPercent: &narrativePop, }, }, AlertOverlaps: []forecast.AlertOverlap{{ Event: "Flood Watch", Headline: "Flooding possible", Severity: "Moderate", }}, }, Units: "us", Timezone: "America/Chicago", Location: &LocationContext{ ID: "home", Name: "Brentwood", Region: "St. Louis Metro", Timezone: "America/Chicago", }, } } func moduleValue[T any](t *testing.T, output *module.Output) T { t.Helper() var value T data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal module value: %v", err) } if err := json.Unmarshal(data, &value); err != nil { t.Fatalf("decode module value: %v", err) } return value } func mustParseModuleTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed }