2026-06-09 20:43:53 +00:00
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 ( )
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-09 20:43:53 +00:00
output , err := registry . BuildModule ( ctx , module . ConfigItem { ID : module . DerivedDailySummary } )
if err != nil {
t . Fatalf ( "BuildModule() error = %v" , err )
}
value := moduleValue [ DerivedDailySummaryModule ] ( t , output )
2026-06-10 10:36:52 -05:00
if value . Date != "Friday, May 29, 2026" {
t . Fatalf ( "Date = %q, want friendly local date" , value . Date )
}
2026-06-10 09:35:39 -05:00
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 )
2026-06-09 20:43:53 +00:00
}
2026-06-10 09:35:39 -05:00
if value . MostLikelyPrecipitationHour != "80% at 12 PM" || ! value . ThunderMentioned {
t . Fatalf ( "precip timing = %#v, want most likely hour and thunder" , value )
2026-06-09 20:43:53 +00:00
}
2026-06-10 10:00:36 -05:00
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 )
2026-06-09 20:43:53 +00:00
}
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 )
2026-06-10 09:35:39 -05:00
for _ , field := range [ ] string { "high_temp_f" , "low_temp_f" , "daily_precipitation_probability" , "most_likely_precipitation_hour" , "heat_index_max_f" } {
2026-06-09 20:43:53 +00:00
if ! strings . Contains ( jsonText , field ) {
t . Fatalf ( "daily json = %s, want field %s" , jsonText , field )
}
}
2026-06-10 09:35:39 -05:00
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 )
}
}
2026-06-09 20:43:53 +00:00
if strings . Contains ( jsonText , "qpf" ) {
t . Fatalf ( "daily json = %s, want no QPF fields without upstream QPF facts" , jsonText )
}
}
2026-06-10 09:35:39 -05:00
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-10 09:35:39 -05:00
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 )
}
}
2026-06-09 20:43:53 +00:00
func TestPrecipTimingModuleHandlesRainyAndDryForecasts ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-09 20:43:53 +00:00
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 )
2026-06-10 09:24:06 -05:00
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 )
}
2026-06-14 12:54:32 -05:00
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" {
2026-06-10 09:24:06 -05:00
t . Fatalf ( "first precipitation window = %#v, want 8-9 AM at 60%%" , rainy . PrecipitationWindows [ 0 ] )
}
2026-06-16 19:10:11 -05:00
if rainy . PrecipitationWindows [ 0 ] . PrecipitationType != "showers" || rainy . PrecipitationWindows [ 0 ] . ExpectationPhrase != "Showers likely." {
t . Fatalf ( "first precipitation window phrase = %#v, want showers likely" , rainy . PrecipitationWindows [ 0 ] )
}
2026-06-14 12:54:32 -05:00
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" {
2026-06-10 09:24:06 -05:00
t . Fatalf ( "second precipitation window = %#v, want noon-2 PM at 80%%" , rainy . PrecipitationWindows [ 1 ] )
}
2026-06-16 19:10:11 -05:00
if rainy . PrecipitationWindows [ 1 ] . PrecipitationType != "showers and thunderstorms" || rainy . PrecipitationWindows [ 1 ] . ExpectationPhrase != "Expect showers and thunderstorms." {
t . Fatalf ( "second precipitation window phrase = %#v, want expect showers and thunderstorms" , rainy . PrecipitationWindows [ 1 ] )
}
2026-06-10 09:24:06 -05:00
data , err := json . Marshal ( output . Value )
if err != nil {
t . Fatalf ( "marshal precip timing: %v" , err )
}
2026-06-14 12:54:32 -05:00
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" ) {
2026-06-10 09:24:06 -05:00
t . Fatalf ( "precip timing json = %s, want threshold and windows" , string ( data ) )
}
2026-06-16 19:10:11 -05:00
if ! strings . Contains ( string ( data ) , "precipitation_type" ) || ! strings . Contains ( string ( data ) , "expectation_phrase" ) {
t . Fatalf ( "precip timing json = %s, want precipitation type and expectation phrase" , string ( data ) )
}
2026-06-12 12:00:40 -05:00
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 ) )
}
2026-06-10 09:24:06 -05:00
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 ) )
2026-06-09 20:43:53 +00:00
}
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 )
2026-06-10 09:24:06 -05:00
if len ( dry . PrecipitationWindows ) != 0 || dry . ThunderMentioned {
t . Fatalf ( "dry precip timing = %#v, want no precip windows and no thunder" , dry )
2026-06-09 20:43:53 +00:00
}
if dry . MaxPopPercent == nil || * dry . MaxPopPercent != 0 {
t . Fatalf ( "dry MaxPopPercent = %#v, want checked zero" , dry . MaxPopPercent )
}
}
2026-06-16 19:10:11 -05:00
func TestPrecipTimingModuleBuildsExpectationPhrases ( t * testing . T ) {
now := mustParseModuleTime ( "2026-05-29T08:00:00-05:00" )
tests := [ ] struct {
name string
maxPop float64
descriptions [ ] string
wantType string
wantPhrase string
} {
{
name : "chance lower bound" ,
maxPop : 40 ,
descriptions : [ ] string { "Scattered showers" } ,
wantType : "showers" ,
wantPhrase : "Chance of showers." ,
} ,
{
name : "chance upper bound" ,
maxPop : 49 ,
descriptions : [ ] string { "Rain possible" } ,
wantType : "rain" ,
wantPhrase : "Chance of rain." ,
} ,
{
name : "likely lower bound" ,
maxPop : 50 ,
descriptions : [ ] string { "Drizzle" } ,
wantType : "drizzle" ,
wantPhrase : "Drizzle likely." ,
} ,
{
name : "likely upper bound" ,
maxPop : 69 ,
descriptions : [ ] string { "Freezing rain" } ,
wantType : "freezing rain" ,
wantPhrase : "Freezing rain likely." ,
} ,
{
name : "expect lower bound" ,
maxPop : 70 ,
descriptions : [ ] string { "Snow" } ,
wantType : "snow" ,
wantPhrase : "Expect snow." ,
} ,
{
name : "showers and thunderstorms preferred" ,
maxPop : 100 ,
descriptions : [ ] string { "Showers likely" , "Thunderstorms possible" } ,
wantType : "showers and thunderstorms" ,
wantPhrase : "Expect showers and thunderstorms." ,
} ,
{
name : "thunderstorms only" ,
maxPop : 80 ,
descriptions : [ ] string { "Thunderstorms" } ,
wantType : "thunderstorms" ,
wantPhrase : "Expect thunderstorms." ,
} ,
{
name : "unknown fallback" ,
maxPop : 95 ,
descriptions : [ ] string { "Unsettled conditions" } ,
wantType : "precipitation" ,
wantPhrase : "Expect precipitation." ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
value := precipTimingValue ( forecast . PrecipTiming {
ProbabilityThreshold : forecast . DefaultPrecipWindowProbabilityThreshold ,
PrecipitationWindows : [ ] forecast . PrecipitationWindow {
{
Start : now ,
MaxPrecipitationProbability : forecast . TimedValue {
Value : tt . maxPop ,
Time : now ,
} ,
ProbabilityThreshold : forecast . DefaultPrecipWindowProbabilityThreshold ,
TextDescriptions : tt . descriptions ,
} ,
} ,
} , "America/Chicago" )
if len ( value . PrecipitationWindows ) != 1 {
t . Fatalf ( "PrecipitationWindows = %#v, want one window" , value . PrecipitationWindows )
}
window := value . PrecipitationWindows [ 0 ]
if window . PrecipitationType != tt . wantType || window . ExpectationPhrase != tt . wantPhrase {
t . Fatalf ( "window = %#v, want type %q and phrase %q" , window , tt . wantType , tt . wantPhrase )
}
} )
}
}
2026-06-12 17:34:40 +00:00
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-14 04:26:05 +00:00
ctx := derivedModuleContext ( report . Hourly )
2026-06-12 17:34:40 +00:00
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 )
}
}
2026-06-09 20:43:53 +00:00
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-09 20:43:53 +00:00
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 )
}
2026-06-14 23:26:45 -05:00
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" {
2026-06-14 23:30:57 +00:00
t . Fatalf ( "morning presentation fields = %#v, want display facts for template composition" , morning )
}
2026-06-12 12:00:40 -05:00
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 )
2026-06-10 10:36:52 -05:00
}
2026-06-14 23:30:57 +00:00
overnight := value [ "overnight" ]
if overnight . MentionPrecipitation {
t . Fatalf ( "overnight MentionPrecipitation = true, want false below threshold" )
}
2026-06-09 20:43:53 +00:00
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 )
2026-06-14 23:26:45 -05:00
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" } {
2026-06-09 20:43:53 +00:00
if ! strings . Contains ( jsonText , field ) {
t . Fatalf ( "daypart json = %s, want field %s" , jsonText , field )
}
}
2026-06-12 12:00:40 -05:00
if strings . Contains ( jsonText , ` "period": ` ) || strings . Contains ( jsonText , ` T06:00:00 ` ) {
2026-06-10 10:36:52 -05:00
t . Fatalf ( "daypart json = %s, want friendly period label instead of raw timestamps" , jsonText )
}
2026-06-09 20:43:53 +00:00
}
2026-06-15 20:38:56 +00:00
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 )
}
}
2026-06-14 23:26:45 -05:00
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 )
}
} )
}
}
2026-06-14 23:30:57 +00:00
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 )
}
} )
}
}
2026-06-09 20:43:53 +00:00
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-14 23:22:31 +00:00
ctx := derivedModuleContext ( report . Tomorrow )
2026-06-09 20:43:53 +00:00
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 )
}
2026-06-12 12:00:40 -05:00
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 )
}
2026-06-09 20:43:53 +00:00
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 ) )
}
}
2026-06-15 16:17:22 +00:00
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 ( )
2026-06-15 16:43:06 +00:00
for _ , id := range [ ] report . ID { report . Today , report . Tomorrow , report . Hourly , report . ThreeDay , report . Weekend , report . Storm } {
2026-06-15 16:17:22 +00:00
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 )
}
}
2026-06-15 14:37:08 +00:00
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 ( )
2026-06-15 16:43:06 +00:00
for _ , id := range [ ] report . ID { report . Tomorrow , report . Daily } {
2026-06-15 14:37:08 +00:00
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 )
}
}
2026-06-09 20:43:53 +00:00
func TestDerivedModulesHandleMissingData ( t * testing . T ) {
registry := MustDefaultModuleRegistry ( )
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-09 20:43:53 +00:00
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 ) ,
2026-06-10 09:24:06 -05:00
derivedHour ( "2026-05-29T09:00:00-05:00" , "Dry break" , 20 , 62 , nil , 10 ) ,
2026-06-09 20:43:53 +00:00
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 ) ,
2026-06-10 09:24:06 -05:00
derivedHour ( "2026-05-29T14:00:00-05:00" , "Drying out" , 20 , 78 , nil , 12 ) ,
2026-06-09 20:43:53 +00:00
}
2026-06-10 08:24:50 -05:00
narrative := [ ] weatherdata . ForecastPeriod {
{
2026-06-10 09:35:39 -05:00
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 ) ,
2026-06-10 08:24:50 -05:00
} ,
}
2026-06-09 20:43:53 +00:00
summary . Dayparts [ 2 ] . AlertOverlaps = [ ] forecast . AlertOverlap { { Event : "Severe Thunderstorm Watch" } }
2026-06-10 09:35:39 -05:00
summary . NarrativePeriods = append ( [ ] weatherdata . ForecastPeriod ( nil ) , narrative ... )
2026-06-09 20:43:53 +00:00
return ModuleContext {
Resolved : report . Resolved {
Definition : definition ,
GeneratedAt : generatedAt ,
Timezone : "America/Chicago" ,
ValidPeriod : summary . Period ,
} ,
2026-06-10 08:24:50 -05:00
Collected : facts . CollectedFacts {
Narrative : & weatherdata . ForecastRun {
IssuedAt : mustParseModuleTime ( "2026-05-29T10:30:00-05:00" ) ,
Product : "narrative" ,
Periods : append ( [ ] weatherdata . ForecastPeriod ( nil ) , narrative ... ) ,
} ,
} ,
2026-06-09 20:43:53 +00:00
Derived : facts . DerivedFacts {
2026-06-10 08:24:50 -05:00
ValidPeriodHourlyPeriods : hours ,
ValidPeriodNarrativePeriods : narrative ,
DailySummaries : [ ] forecast . DailySummary { summary } ,
DaypartSummaries : append ( [ ] forecast . DaypartSummary ( nil ) , summary . Dayparts ... ) ,
PrecipTiming : forecast . BuildPrecipTiming ( hours ) ,
2026-06-09 20:43:53 +00:00
} ,
Units : "us" ,
Timezone : "America/Chicago" ,
}
}
2026-06-15 14:37:08 +00:00
func todayModuleContext ( ) ModuleContext {
2026-06-15 16:43:06 +00:00
ctx := derivedModuleContext ( report . Daily )
2026-06-15 14:37:08 +00:00
ctx . Resolved . Definition = report . Definition {
ID : report . Today ,
Name : "Today Report" ,
PromptID : "weather.today_generated_text" ,
}
return ctx
}
2026-06-15 16:17:22 +00:00
func dailyModuleContext ( ) ModuleContext {
ctx := derivedModuleContext ( report . Tomorrow )
ctx . Resolved . Definition = report . Definition {
2026-06-15 16:43:06 +00:00
ID : report . Daily ,
2026-06-15 16:17:22 +00:00
Name : "Daily Report" ,
PromptID : "weather.daily_generated_text" ,
}
return ctx
}
2026-06-09 20:43:53 +00:00
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 } )
}
2026-06-14 23:26:45 -05:00
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 )
}
2026-06-09 20:43:53 +00:00
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
}
2026-06-10 09:35:39 -05:00
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
}