774 lines
32 KiB
Go
774 lines
32 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.Daily)
|
|
|
|
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.Date != "Friday, May 29, 2026" {
|
|
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
|
}
|
|
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 !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
|
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than 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.Daily)
|
|
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.Daily)
|
|
|
|
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].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
|
|
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
|
}
|
|
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
|
|
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") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
|
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
|
}
|
|
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", 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 TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := derivedModuleContext(report.Hourly)
|
|
ctx.Derived.DailySummaries = nil
|
|
ctx.Derived.DaypartSummaries = nil
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule() error = %v", err)
|
|
}
|
|
value := moduleValue[PrecipTimingModule](t, output)
|
|
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
|
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
|
}
|
|
}
|
|
|
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := derivedModuleContext(report.Daily)
|
|
|
|
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)
|
|
}
|
|
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
|
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
|
}
|
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
|
}
|
|
overnight := value["overnight"]
|
|
if overnight.MentionPrecipitation {
|
|
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
|
}
|
|
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)
|
|
}
|
|
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{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
|
|
if !strings.Contains(jsonText, field) {
|
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
|
}
|
|
}
|
|
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
|
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
|
}
|
|
}
|
|
|
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := derivedModuleContext(report.Daily)
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule() error = %v", err)
|
|
}
|
|
richText := mustMarshalModuleJSON(t, output.Value)
|
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
|
if !strings.Contains(richText, field) {
|
|
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
|
}
|
|
}
|
|
|
|
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
|
morning, ok := prompt["morning"]
|
|
if !ok {
|
|
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
|
}
|
|
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
|
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
|
}
|
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
|
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
|
}
|
|
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
|
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
|
}
|
|
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
|
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
|
}
|
|
afternoon := prompt["afternoon"]
|
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
|
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
|
}
|
|
|
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
|
if !strings.Contains(promptText, field) {
|
|
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
|
}
|
|
}
|
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
|
if strings.Contains(promptText, field) {
|
|
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
temps []float64
|
|
wantTrend string
|
|
wantStart string
|
|
wantEnd string
|
|
wantPeak string
|
|
wantSteady string
|
|
}{
|
|
{
|
|
name: "rising",
|
|
temps: []float64{58, 68},
|
|
wantTrend: "rising",
|
|
wantStart: "upper 50s",
|
|
wantEnd: "upper 60s",
|
|
},
|
|
{
|
|
name: "falling",
|
|
temps: []float64{65, 58},
|
|
wantTrend: "falling",
|
|
wantStart: "mid 60s",
|
|
wantEnd: "upper 50s",
|
|
},
|
|
{
|
|
name: "peaking",
|
|
temps: []float64{62, 78, 65},
|
|
wantTrend: "peaking",
|
|
wantPeak: "upper 70s",
|
|
},
|
|
{
|
|
name: "steady",
|
|
temps: []float64{77, 78},
|
|
wantTrend: "steady",
|
|
wantSteady: "upper 70s",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
|
prompt := derivedDaypartSummaryPromptValue(rich)
|
|
if prompt.TemperatureTrend != test.wantTrend ||
|
|
prompt.TemperatureStartPhraseF != test.wantStart ||
|
|
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
|
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
|
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
|
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
|
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
|
MaxPopTime: "6 AM",
|
|
MaxPopTimeLabel: "6:00 AM",
|
|
})
|
|
if withLabel.MaxPopTime != "6:00 AM" {
|
|
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
|
}
|
|
|
|
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
|
MaxPopTime: "6 AM",
|
|
})
|
|
if withoutLabel.MaxPopTime != "6 AM" {
|
|
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
|
}
|
|
}
|
|
|
|
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
temps []float64
|
|
wantTrend string
|
|
wantStart string
|
|
wantEnd string
|
|
wantPeak string
|
|
wantSteady string
|
|
}{
|
|
{
|
|
name: "rising",
|
|
temps: []float64{58, 68},
|
|
wantTrend: "rising",
|
|
wantStart: "upper 50s",
|
|
wantEnd: "upper 60s",
|
|
},
|
|
{
|
|
name: "falling",
|
|
temps: []float64{65, 58},
|
|
wantTrend: "falling",
|
|
wantStart: "mid 60s",
|
|
wantEnd: "upper 50s",
|
|
},
|
|
{
|
|
name: "peaking",
|
|
temps: []float64{62, 78, 65},
|
|
wantTrend: "peaking",
|
|
wantPeak: "upper 70s",
|
|
},
|
|
{
|
|
name: "steady same band",
|
|
temps: []float64{77, 78},
|
|
wantTrend: "steady",
|
|
wantSteady: "upper 70s",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
|
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
|
if value.DominantConditionDisplay != "Sunny" {
|
|
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
|
}
|
|
if value.TemperatureTrend != test.wantTrend ||
|
|
value.TemperatureStartPhraseF != test.wantStart ||
|
|
value.TemperatureEndPhraseF != test.wantEnd ||
|
|
value.TemperaturePeakPhraseF != test.wantPeak ||
|
|
value.TemperatureSteadyPhraseF != test.wantSteady {
|
|
t.Fatalf("temperature presentation = %#v", value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTemperaturePhraseF(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value forecast.Range
|
|
want string
|
|
}{
|
|
{
|
|
name: "single low band",
|
|
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
|
want: "low 70s",
|
|
},
|
|
{
|
|
name: "single upper value",
|
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
|
want: "upper 60s",
|
|
},
|
|
{
|
|
name: "range across bands",
|
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
|
want: "upper 60s to mid 70s",
|
|
},
|
|
{
|
|
name: "max only",
|
|
value: forecast.Range{Max: floatPtr(84)},
|
|
want: "mid 80s",
|
|
},
|
|
{
|
|
name: "empty",
|
|
value: forecast.Range{},
|
|
want: "",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := temperaturePhraseF(test.value); got != test.want {
|
|
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := derivedModuleContext(report.Tomorrow)
|
|
|
|
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)
|
|
}
|
|
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
|
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
|
}
|
|
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
|
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
|
}
|
|
|
|
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 TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := dailyModuleContext()
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
|
}
|
|
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
|
t.Fatalf("output = %#v, want daily planning stanza", output)
|
|
}
|
|
planning := moduleValue[DailyPlanningModule](t, output)
|
|
if len(planning.MorningReadiness) == 0 ||
|
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
|
len(planning.OvernightChangeWatch) == 0 {
|
|
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
|
}
|
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
|
}
|
|
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
|
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
|
}
|
|
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
|
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
|
}
|
|
data, err := json.Marshal(output.Value)
|
|
if err != nil {
|
|
t.Fatalf("marshal daily planning: %v", err)
|
|
}
|
|
jsonText := string(data)
|
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
|
if !strings.Contains(jsonText, field) {
|
|
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
|
}
|
|
}
|
|
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
|
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
|
}
|
|
}
|
|
|
|
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
ctx := derivedModuleContext(id)
|
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := dailyModuleContext()
|
|
ctx.Derived.DailySummaries = nil
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
|
}
|
|
planning := moduleValue[DailyPlanningModule](t, output)
|
|
if len(planning.MorningReadiness) != 0 ||
|
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
|
len(planning.OvernightChangeWatch) != 0 {
|
|
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
|
}
|
|
}
|
|
|
|
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := todayModuleContext()
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
|
}
|
|
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
|
t.Fatalf("output = %#v, want today planning stanza", output)
|
|
}
|
|
planning := moduleValue[TodayPlanningModule](t, output)
|
|
if len(planning.MorningReadiness) == 0 ||
|
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
|
len(planning.OutdoorPlanning) == 0 ||
|
|
len(planning.LateDayChangeWatch) == 0 {
|
|
t.Fatalf("today planning = %#v, want populated planning fields", planning)
|
|
}
|
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
|
}
|
|
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
|
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
|
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
|
}
|
|
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
|
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
|
}
|
|
data, err := json.Marshal(output.Value)
|
|
if err != nil {
|
|
t.Fatalf("marshal today planning: %v", err)
|
|
}
|
|
jsonText := string(data)
|
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
|
if !strings.Contains(jsonText, field) {
|
|
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
|
}
|
|
}
|
|
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
|
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
|
}
|
|
}
|
|
|
|
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
ctx := derivedModuleContext(id)
|
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := todayModuleContext()
|
|
ctx.Derived.DailySummaries = nil
|
|
|
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
|
}
|
|
planning := moduleValue[TodayPlanningModule](t, output)
|
|
if len(planning.MorningReadiness) != 0 ||
|
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
|
len(planning.OutdoorPlanning) != 0 ||
|
|
len(planning.LateDayChangeWatch) != 0 {
|
|
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
|
}
|
|
}
|
|
|
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
ctx := derivedModuleContext(report.Daily)
|
|
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 todayModuleContext() ModuleContext {
|
|
ctx := derivedModuleContext(report.Daily)
|
|
ctx.Resolved.Definition = report.Definition{
|
|
ID: report.Today,
|
|
Name: "Today Report",
|
|
PromptID: "weather.today_generated_text",
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func dailyModuleContext() ModuleContext {
|
|
ctx := derivedModuleContext(report.Tomorrow)
|
|
ctx.Resolved.Definition = report.Definition{
|
|
ID: report.Daily,
|
|
Name: "Daily Report",
|
|
PromptID: "weather.daily_generated_text",
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
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 derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
|
startTime := mustParseModuleTime(start)
|
|
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
|
for index, temperature := range temperatures {
|
|
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
|
periods = append(periods, weatherdata.ForecastPeriod{
|
|
StartTime: periodStart,
|
|
EndTime: periodStart.Add(time.Hour),
|
|
TextDescription: text,
|
|
TemperatureF: floatPtr(temperature),
|
|
})
|
|
}
|
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
|
Start: startTime,
|
|
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
|
}, periods)
|
|
}
|
|
|
|
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
|
|
}
|