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 TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[DerivedDailySummaryModule](t, output) if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 { t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF) } if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 { t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability) } if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned { t.Fatalf("precip timing = %#v, want most likely hour and thunder", value) } if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." { t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions) } if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) } if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 { t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal daily summary: %v", err) } jsonText := string(data) for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} { if !strings.Contains(jsonText, field) { t.Fatalf("daily json = %s, want field %s", jsonText, field) } } for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} { if strings.Contains(jsonText, removed) { t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed) } } if strings.Contains(jsonText, "qpf") { t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText) } } func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) ctx.Derived.DailySummaries[0].NarrativePeriods = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[DerivedDailySummaryModule](t, output) if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 { t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF) } if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 { t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability) } if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") { t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions) } } func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { t.Fatalf("BuildModule(rainy) error = %v", err) } rainy := moduleValue[PrecipTimingModule](t, output) if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned { t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy) } if len(rainy.PrecipitationWindows) != 2 { t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) } if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) } if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal precip timing: %v", err) } if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) } if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) } ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)}) output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { t.Fatalf("BuildModule(dry) error = %v", err) } dry := moduleValue[PrecipTimingModule](t, output) if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned { t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry) } if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 { t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent) } } func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output) morning, ok := value["morning"] if !ok { t.Fatalf("daypart keys = %#v, want configured morning key", value) } if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { t.Fatalf("morning = %#v, want temp range and precip peak", morning) } afternoon := value["afternoon"] if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) } overnight := value["overnight"] if !overnight.Cold { t.Fatalf("overnight = %#v, want cold hazard", overnight) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal daypart summaries: %v", err) } jsonText := string(data) for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { if !strings.Contains(jsonText, field) { t.Fatalf("daypart json = %s, want field %s", jsonText, field) } } } func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyTomorrow) outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) if err != nil { t.Fatalf("BuildModule(outdoor windows) error = %v", err) } outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput) if outdoor.Best == nil || outdoor.Worst == nil { t.Fatalf("outdoor windows = %#v, want best and worst", outdoor) } if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) } planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) if err != nil { t.Fatalf("BuildModule(tomorrow planning) error = %v", err) } planning := moduleValue[TomorrowPlanningModule](t, planningOutput) if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 { t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning) } data, err := json.Marshal(planningOutput.Value) if err != nil { t.Fatalf("marshal tomorrow planning: %v", err) } if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") { t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data)) } } func TestDerivedModulesHandleMissingData(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) ctx.Derived.DailySummaries = nil ctx.Derived.DaypartSummaries = nil if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil { t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error") } if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil { t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error") } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) if err != nil { t.Fatalf("BuildModule(outdoor windows) error = %v", err) } windows := moduleValue[OutdoorWindowsModule](t, output) if windows.Best != nil || windows.Worst != nil { t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows) } } func derivedModuleContext(id report.ID) ModuleContext { generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") definition := report.DefaultRegistry().MustLookup(id) summary := forecast.DailySummary{ Date: "2026-05-29", Period: timeutil.Period{ Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"), End: mustParseModuleTime("2026-05-30T00:00:00-05:00"), }, Dayparts: []forecast.DaypartSummary{ derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5), derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15), derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42), }, } hours := []weatherdata.ForecastPeriod{ derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5), derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15), derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10), derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42), derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30), derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12), } narrative := []weatherdata.ForecastPeriod{ { Name: "Today", StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), IsDay: boolPtr(true), TextDescription: "Morning storms, then partly sunny.", TemperatureFMax: floatPtr(88), ProbabilityOfPrecipitationPercent: floatPtr(55), }, { Name: "Tonight", StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"), IsDay: boolPtr(false), TextDescription: "Clouds linger tonight.", TemperatureFMin: floatPtr(64), ProbabilityOfPrecipitationPercent: floatPtr(30), }, } summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}} summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...) return ModuleContext{ Resolved: report.Resolved{ Definition: definition, GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: summary.Period, }, Collected: facts.CollectedFacts{ Narrative: &weatherdata.ForecastRun{ IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), Product: "narrative", Periods: append([]weatherdata.ForecastPeriod(nil), narrative...), }, }, Derived: facts.DerivedFacts{ ValidPeriodHourlyPeriods: hours, ValidPeriodNarrativePeriods: narrative, DailySummaries: []forecast.DailySummary{summary}, DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), PrecipTiming: forecast.BuildPrecipTiming(hours), }, Units: "us", Timezone: "America/Chicago", } } func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { hour := derivedHour(start, text, precip, temperature, apparent, gust) return forecast.SummarizeDaypart(name, timeutil.Period{ Start: mustParseModuleTime(start), End: mustParseModuleTime(end), }, []weatherdata.ForecastPeriod{hour}) } func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod { startTime := mustParseModuleTime(start) endTime := startTime.Add(time.Hour) return weatherdata.ForecastPeriod{ StartTime: startTime, EndTime: endTime, TextDescription: text, TemperatureF: floatPtr(temperature), ApparentTemperatureF: apparent, ProbabilityOfPrecipitationPercent: floatPtr(precip), WindGustMph: floatPtr(gust), } } func floatPtr(value float64) *float64 { return &value } func boolPtr(value bool) *bool { return &value } func containsString(values []string, want string) bool { for _, value := range values { if value == want { return true } } return false }