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.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 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) } 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"} { if !strings.Contains(jsonText, field) { t.Fatalf("current json = %s, want field %s", jsonText, field) } } } 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.Discussion = nil ctx.Collected.WeatherStory = nil for _, id := range []module.ID{module.CurrentConditions, 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) } data, err := json.Marshal(storyOutput.Value) if err != nil { t.Fatalf("Marshal weather story: %v", err) } if !strings.Contains(string(data), "download_url") { t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) } } 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 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, }, 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{ 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 }