241 lines
10 KiB
Go
241 lines
10 KiB
Go
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 != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
|
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
|
|
}
|
|
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
|
|
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
|
}
|
|
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
|
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
|
}
|
|
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", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} {
|
|
if !strings.Contains(jsonText, field) {
|
|
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
|
}
|
|
}
|
|
if strings.Contains(jsonText, "qpf") {
|
|
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
|
}
|
|
}
|
|
|
|
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.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
|
|
t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy)
|
|
}
|
|
|
|
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 dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
|
|
t.Fatalf("dry precip timing = %#v, want no precip hours 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-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),
|
|
}
|
|
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
|
return ModuleContext{
|
|
Resolved: report.Resolved{
|
|
Definition: definition,
|
|
GeneratedAt: generatedAt,
|
|
Timezone: "America/Chicago",
|
|
ValidPeriod: summary.Period,
|
|
},
|
|
Derived: facts.DerivedFacts{
|
|
ValidPeriodHourlyPeriods: hours,
|
|
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
|
|
}
|