2026-05-29 17:12:20 +00:00
package forecast
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
2026-06-09 20:16:47 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
2026-05-29 17:12:20 +00:00
)
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
bundle := testBundle ( location )
date := time . Date ( 2026 , 5 , 29 , 12 , 0 , 0 , 0 , location )
dayparts := [ ] DaypartDefinition {
{ Name : "overnight" , Start : "00:00" , End : "06:00" } ,
{ Name : "morning" , Start : "06:00" , End : "12:00" } ,
{ Name : "afternoon" , Start : "12:00" , End : "18:00" } ,
{ Name : "evening" , Start : "18:00" , End : "24:00" } ,
}
summary , err := BuildDailySummary ( bundle , date , location , dayparts )
if err != nil {
t . Fatalf ( "BuildDailySummary() error = %v" , err )
}
if len ( summary . Dayparts ) != 4 {
t . Fatalf ( "Dayparts length = %d, want 4" , len ( summary . Dayparts ) )
}
morning := summary . Dayparts [ 1 ]
if len ( morning . HourlyPeriods ) != 2 {
t . Fatalf ( "morning periods = %d, want 2" , len ( morning . HourlyPeriods ) )
}
assertRange ( t , "morning temperature" , morning . Temperature , 58 , 72 )
if morning . MaxPrecipitationProbability == nil || morning . MaxPrecipitationProbability . Value != 70 {
t . Fatalf ( "morning max precip = %#v, want 70" , morning . MaxPrecipitationProbability )
}
if morning . PeakWindGust == nil || morning . PeakWindGust . Value != 40 {
t . Fatalf ( "morning peak gust = %#v, want 40" , morning . PeakWindGust )
}
if morning . DominantCondition != "Thunderstorms and gusty wind" {
2026-05-29 19:15:32 -05:00
t . Fatalf ( "morning dominant = %q, want raw forecast condition" , morning . DominantCondition )
2026-05-29 17:12:20 +00:00
}
2026-05-29 19:15:32 -05:00
if ! morning . Indicators . Wind {
t . Fatalf ( "morning indicators = %#v, want wind" , morning . Indicators )
2026-05-29 17:12:20 +00:00
}
afternoon := summary . Dayparts [ 2 ]
assertRange ( t , "afternoon apparent" , afternoon . ApparentTemperature , 100 , 100 )
if ! afternoon . Indicators . Heat {
t . Fatalf ( "afternoon indicators = %#v, want heat" , afternoon . Indicators )
}
if len ( summary . NarrativePeriods ) != 1 {
t . Fatalf ( "NarrativePeriods length = %d, want 1" , len ( summary . NarrativePeriods ) )
}
if len ( summary . AlertOverlaps ) != 1 {
t . Fatalf ( "AlertOverlaps length = %d, want 1" , len ( summary . AlertOverlaps ) )
}
if len ( morning . AlertOverlaps ) != 1 {
t . Fatalf ( "morning AlertOverlaps length = %d, want 1" , len ( morning . AlertOverlaps ) )
}
if _ , err := json . Marshal ( summary . Dayparts ) ; err != nil {
t . Fatalf ( "daypart summaries are not JSON inspectable: %v" , err )
}
}
func TestBuildDailySummaryFromFixtureBundle ( t * testing . T ) {
data , err := os . ReadFile ( filepath . Join ( "testdata" , "daily_bundle.json" ) )
if err != nil {
t . Fatalf ( "read fixture bundle: %v" , err )
}
2026-06-09 20:16:47 +00:00
var bundle weatherdata . Bundle
2026-05-29 17:12:20 +00:00
if err := json . Unmarshal ( data , & bundle ) ; err != nil {
t . Fatalf ( "decode fixture bundle: %v" , err )
}
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
summary , err := BuildDailySummary ( & bundle , mustParse ( "2026-05-29T12:00:00-05:00" ) , location , [ ] DaypartDefinition {
{ Name : "morning" , Start : "06:00" , End : "12:00" } ,
{ Name : "afternoon" , Start : "12:00" , End : "18:00" } ,
} )
if err != nil {
t . Fatalf ( "BuildDailySummary() error = %v" , err )
}
if len ( summary . Dayparts ) != 2 {
t . Fatalf ( "Dayparts length = %d, want 2" , len ( summary . Dayparts ) )
}
2026-05-29 19:15:32 -05:00
if summary . Dayparts [ 0 ] . DominantCondition != "Showers and thunderstorms" {
t . Fatalf ( "morning dominant = %q, want raw forecast condition" , summary . Dayparts [ 0 ] . DominantCondition )
2026-05-29 17:12:20 +00:00
}
if len ( summary . AlertOverlaps ) != 1 {
t . Fatalf ( "AlertOverlaps length = %d, want 1" , len ( summary . AlertOverlaps ) )
}
}
func TestOvernightGroupingAcrossMidnight ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
date := time . Date ( 2026 , 5 , 29 , 12 , 0 , 0 , 0 , location )
2026-06-09 20:16:47 +00:00
bundle := & weatherdata . Bundle { Hourly : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
hour ( location , "2026-05-29T23:00:00-05:00" , "2026-05-30T00:00:00-05:00" , "Snow" , 31 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-30T05:00:00-05:00" , "2026-05-30T06:00:00-05:00" , "Fog" , 30 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-30T06:00:00-05:00" , "2026-05-30T07:00:00-05:00" , "Clear" , 35 , nil , nil , nil , nil ) ,
} } }
summary , err := BuildDailySummary ( bundle , date , location , [ ] DaypartDefinition {
{ Name : "night" , Start : "22:00" , End : "06:00" } ,
} )
if err != nil {
t . Fatalf ( "BuildDailySummary() error = %v" , err )
}
night := summary . Dayparts [ 0 ]
if len ( night . HourlyPeriods ) != 2 {
t . Fatalf ( "night periods = %d, want 2" , len ( night . HourlyPeriods ) )
}
if ! night . Indicators . Snow || ! night . Indicators . Fog || ! night . Indicators . Cold {
t . Fatalf ( "night indicators = %#v, want snow, fog, and cold" , night . Indicators )
}
}
func TestBoundaryTimestampsAtDaypartEdges ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
date := time . Date ( 2026 , 5 , 29 , 12 , 0 , 0 , 0 , location )
2026-06-09 20:16:47 +00:00
bundle := & weatherdata . Bundle { Hourly : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
hour ( location , "2026-05-29T05:00:00-05:00" , "2026-05-29T06:00:00-05:00" , "Before" , 55 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-29T06:00:00-05:00" , "2026-05-29T07:00:00-05:00" , "Start" , 56 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-29T12:00:00-05:00" , "2026-05-29T13:00:00-05:00" , "After" , 70 , nil , nil , nil , nil ) ,
} } }
summary , err := BuildDailySummary ( bundle , date , location , [ ] DaypartDefinition {
{ Name : "morning" , Start : "06:00" , End : "12:00" } ,
} )
if err != nil {
t . Fatalf ( "BuildDailySummary() error = %v" , err )
}
morning := summary . Dayparts [ 0 ]
if len ( morning . HourlyPeriods ) != 1 {
t . Fatalf ( "morning periods = %d, want only start-boundary period" , len ( morning . HourlyPeriods ) )
}
if morning . HourlyPeriods [ 0 ] . TextDescription != "Start" {
t . Fatalf ( "selected period = %q, want Start" , morning . HourlyPeriods [ 0 ] . TextDescription )
}
}
func TestBuildDailySummaryRequiresHourlyData ( t * testing . T ) {
location := time . UTC
2026-06-09 20:16:47 +00:00
_ , err := BuildDailySummary ( & weatherdata . Bundle { } , time . Now ( ) , location , [ ] DaypartDefinition {
2026-05-29 17:12:20 +00:00
{ Name : "morning" , Start : "06:00" , End : "12:00" } ,
} )
if err == nil {
t . Fatal ( "BuildDailySummary() error = nil, want missing hourly error" )
}
}
2026-05-29 18:08:50 +00:00
func TestBuildPeriodDailySummariesClipsPartialDays ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
2026-06-09 20:16:47 +00:00
bundle := & weatherdata . Bundle { Hourly : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
2026-05-29 18:08:50 +00:00
hour ( location , "2026-05-29T05:00:00-05:00" , "2026-05-29T06:00:00-05:00" , "Before" , 50 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Showers" , 60 , nil , ptr ( 60 ) , nil , nil ) ,
hour ( location , "2026-05-30T14:00:00-05:00" , "2026-05-30T15:00:00-05:00" , "Hot" , 95 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-31T20:00:00-05:00" , "2026-05-31T21:00:00-05:00" , "Wind" , 70 , nil , nil , nil , ptr ( 35 ) ) ,
} } }
period := timeutil . Period {
Start : mustParse ( "2026-05-29T07:00:00-05:00" ) . In ( location ) ,
End : mustParse ( "2026-06-01T00:00:00-05:00" ) . In ( location ) ,
}
summaries , err := BuildPeriodDailySummaries ( bundle , period , location , [ ] DaypartDefinition {
{ Name : "morning" , Start : "06:00" , End : "12:00" } ,
{ Name : "afternoon" , Start : "12:00" , End : "18:00" } ,
{ Name : "evening" , Start : "18:00" , End : "24:00" } ,
} )
if err != nil {
t . Fatalf ( "BuildPeriodDailySummaries() error = %v" , err )
}
if len ( summaries ) != 3 {
t . Fatalf ( "summaries length = %d, want 3" , len ( summaries ) )
}
if summaries [ 0 ] . Period . Start . Format ( time . RFC3339 ) != "2026-05-29T07:00:00-05:00" {
t . Fatalf ( "first period start = %s, want clipped start" , summaries [ 0 ] . Period . Start . Format ( time . RFC3339 ) )
}
if len ( summaries [ 0 ] . Dayparts [ 0 ] . HourlyPeriods ) != 1 || summaries [ 0 ] . Dayparts [ 0 ] . HourlyPeriods [ 0 ] . TextDescription != "Showers" {
t . Fatalf ( "first morning periods = %#v, want only post-start hour" , summaries [ 0 ] . Dayparts [ 0 ] . HourlyPeriods )
}
if summaries [ 2 ] . Dayparts [ 2 ] . PeakWindGust == nil || summaries [ 2 ] . Dayparts [ 2 ] . PeakWindGust . Value != 35 {
t . Fatalf ( "third evening gust = %#v, want 35" , summaries [ 2 ] . Dayparts [ 2 ] . PeakWindGust )
}
}
2026-05-29 17:12:20 +00:00
func TestAlertOverlap ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
raw := json . RawMessage ( ` { "event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"} ` )
2026-06-09 20:16:47 +00:00
alertRun := & weatherdata . AlertRun { Alerts : [ ] json . RawMessage { raw } }
2026-05-29 17:12:20 +00:00
period := timeutil . Period {
Start : mustParse ( "2026-05-29T06:00:00-05:00" ) . In ( location ) ,
End : mustParse ( "2026-05-29T09:00:00-05:00" ) . In ( location ) ,
}
overlaps := AlertOverlaps ( alertRun , period )
if len ( overlaps ) != 1 {
t . Fatalf ( "overlaps length = %d, want 1" , len ( overlaps ) )
}
if overlaps [ 0 ] . Overlap . Start . Format ( time . RFC3339 ) != "2026-05-29T07:00:00-05:00" {
t . Fatalf ( "overlap start = %s, want alert start" , overlaps [ 0 ] . Overlap . Start . Format ( time . RFC3339 ) )
}
if overlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) != "2026-05-29T09:00:00-05:00" {
t . Fatalf ( "overlap end = %s, want period end" , overlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) )
}
}
2026-06-16 20:34:01 -05:00
func TestAlertOverlapUsesOnsetAndEnds ( t * testing . T ) {
2026-06-16 21:19:03 -05:00
raw := json . RawMessage ( ` { "event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.\n\nUse extra\tcaution.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"} ` )
2026-06-16 20:34:01 -05:00
alertRun := & weatherdata . AlertRun { Alerts : [ ] json . RawMessage { raw } }
period := timeutil . Period {
Start : mustParse ( "2026-06-17T12:00:00-05:00" ) ,
End : mustParse ( "2026-06-17T21:00:00-05:00" ) ,
}
overlaps := AlertOverlaps ( alertRun , period )
if len ( overlaps ) != 1 {
t . Fatalf ( "overlaps length = %d, want 1" , len ( overlaps ) )
}
if overlaps [ 0 ] . Period . Start . Format ( time . RFC3339 ) != "2026-06-17T18:00:00Z" {
t . Fatalf ( "alert period start = %s, want onset" , overlaps [ 0 ] . Period . Start . Format ( time . RFC3339 ) )
}
if overlaps [ 0 ] . Period . End . Format ( time . RFC3339 ) != "2026-06-18T01:00:00Z" {
t . Fatalf ( "alert period end = %s, want ends" , overlaps [ 0 ] . Period . End . Format ( time . RFC3339 ) )
}
if overlaps [ 0 ] . Overlap . Start . Format ( time . RFC3339 ) != "2026-06-17T18:00:00Z" {
t . Fatalf ( "overlap start = %s, want onset" , overlaps [ 0 ] . Overlap . Start . Format ( time . RFC3339 ) )
}
if overlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) != "2026-06-18T01:00:00Z" {
t . Fatalf ( "overlap end = %s, want alert ends" , overlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) )
}
2026-06-16 21:09:17 -05:00
if overlaps [ 0 ] . Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." {
t . Fatalf ( "Description = %q, want preserved description" , overlaps [ 0 ] . Description )
}
2026-06-16 21:19:03 -05:00
if overlaps [ 0 ] . Instruction != "Secure outdoor objects. Use extra caution." {
2026-06-16 21:09:17 -05:00
t . Fatalf ( "Instruction = %q, want preserved instruction" , overlaps [ 0 ] . Instruction )
}
2026-06-16 20:34:01 -05:00
}
2026-06-16 21:19:03 -05:00
func TestCollapseWhitespace ( t * testing . T ) {
tests := [ ] struct {
name string
value string
want string
} {
{ name : "newlines" , value : "Secure outdoor objects.\nUse extra caution." , want : "Secure outdoor objects. Use extra caution." } ,
{ name : "crlf" , value : "Secure outdoor objects.\r\nUse extra caution." , want : "Secure outdoor objects. Use extra caution." } ,
{ name : "tabs and repeated spaces" , value : "Secure\toutdoor objects." , want : "Secure outdoor objects." } ,
{ name : "leading trailing" , value : " Secure outdoor objects. " , want : "Secure outdoor objects." } ,
{ name : "empty" , value : "" , want : "" } ,
{ name : "all whitespace" , value : " \n\t\r\n " , want : "" } ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
if got := collapseWhitespace ( tt . value ) ; got != tt . want {
t . Fatalf ( "collapseWhitespace(%q) = %q, want %q" , tt . value , got , tt . want )
}
} )
}
}
2026-06-16 20:34:01 -05:00
func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance ( t * testing . T ) {
location , err := time . LoadLocation ( "America/Chicago" )
if err != nil {
t . Fatalf ( "load location: %v" , err )
}
raw := json . RawMessage ( ` { "event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"} ` )
alertRun := & weatherdata . AlertRun { Alerts : [ ] json . RawMessage { raw } }
june16 := timeutil . CivilDay ( time . Date ( 2026 , 6 , 16 , 12 , 0 , 0 , 0 , location ) , location )
june17Afternoon := timeutil . Period {
Start : time . Date ( 2026 , 6 , 17 , 12 , 0 , 0 , 0 , location ) ,
End : time . Date ( 2026 , 6 , 17 , 21 , 0 , 0 , 0 , location ) ,
}
if overlaps := AlertOverlaps ( alertRun , june16 ) ; len ( overlaps ) != 0 {
t . Fatalf ( "June 16 overlaps length = %d, want none: %#v" , len ( overlaps ) , overlaps )
}
if overlaps := AlertOverlaps ( alertRun , june17Afternoon ) ; len ( overlaps ) != 1 {
t . Fatalf ( "June 17 afternoon overlaps length = %d, want 1: %#v" , len ( overlaps ) , overlaps )
}
}
func TestAlertOverlapSupportsOlderEffectiveExpiresPayload ( t * testing . T ) {
raw := json . RawMessage ( ` { "event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"} ` )
alertRun := & weatherdata . AlertRun { Alerts : [ ] json . RawMessage { raw } }
period := timeutil . Period {
Start : mustParse ( "2026-05-29T06:00:00-05:00" ) ,
End : mustParse ( "2026-05-29T11:00:00-05:00" ) ,
}
overlaps := AlertOverlaps ( alertRun , period )
if len ( overlaps ) != 1 {
t . Fatalf ( "overlaps length = %d, want 1" , len ( overlaps ) )
}
if overlaps [ 0 ] . Period . Start . Format ( time . RFC3339 ) != "2026-05-29T07:00:00-05:00" {
t . Fatalf ( "alert period start = %s, want effective fallback" , overlaps [ 0 ] . Period . Start . Format ( time . RFC3339 ) )
}
if overlaps [ 0 ] . Period . End . Format ( time . RFC3339 ) != "2026-05-29T10:00:00-05:00" {
t . Fatalf ( "alert period end = %s, want expires fallback" , overlaps [ 0 ] . Period . End . Format ( time . RFC3339 ) )
}
}
func TestAlertOverlapSkipsInvalidSelectedPeriod ( t * testing . T ) {
raw := json . RawMessage ( ` { "event":"Wind Advisory","headline":"Invalid event period","severity":"Moderate","onset":"2026-06-17T18:00:00Z","ends":"2026-06-17T08:45:00Z"} ` )
alertRun := & weatherdata . AlertRun { Alerts : [ ] json . RawMessage { raw } }
period := timeutil . Period {
Start : mustParse ( "2026-06-17T00:00:00Z" ) ,
End : mustParse ( "2026-06-18T00:00:00Z" ) ,
}
if overlaps := AlertOverlaps ( alertRun , period ) ; len ( overlaps ) != 0 {
t . Fatalf ( "overlaps length = %d, want invalid alert skipped: %#v" , len ( overlaps ) , overlaps )
}
}
2026-06-10 09:24:06 -05:00
func TestBuildPrecipTimingBuildsThresholdWindows ( t * testing . T ) {
2026-06-09 20:43:53 +00:00
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
periods := [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Cloudy" , 70 , nil , ptr ( 0 ) , nil , nil ) ,
hour ( location , "2026-05-29T12:00:00-05:00" , "2026-05-29T13:00:00-05:00" , "Thunderstorms" , 70 , nil , ptr ( 80 ) , nil , nil ) ,
2026-06-10 09:24:06 -05:00
hour ( location , "2026-05-29T11:00:00-05:00" , "2026-05-29T12:00:00-05:00" , "Brief lull" , 70 , nil , ptr ( 39.999 ) , nil , nil ) ,
hour ( location , "2026-05-29T13:00:00-05:00" , "2026-05-29T14:00:00-05:00" , "Unknown rain chance" , 70 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-29T10:00:00-05:00" , "2026-05-29T11:00:00-05:00" , "Rain likely" , 70 , nil , ptr ( 60 ) , nil , nil ) ,
hour ( location , "2026-05-29T09:00:00-05:00" , "2026-05-29T10:00:00-05:00" , "Showers" , 70 , nil , ptr ( 40 ) , nil , nil ) ,
2026-06-09 20:43:53 +00:00
}
timing := BuildPrecipTiming ( periods )
if timing . FirstPrecipitation == nil || timing . FirstPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T09:00:00-05:00" {
2026-06-10 09:24:06 -05:00
t . Fatalf ( "FirstPrecipitation = %#v, want first threshold window start" , timing . FirstPrecipitation )
2026-06-09 20:43:53 +00:00
}
2026-06-10 09:24:06 -05:00
if timing . LastPrecipitation == nil || timing . LastPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T13:00:00-05:00" {
t . Fatalf ( "LastPrecipitation = %#v, want final closed threshold window end" , timing . LastPrecipitation )
2026-06-09 20:43:53 +00:00
}
if timing . MaxPrecipitationProbability == nil || timing . MaxPrecipitationProbability . Value != 80 {
t . Fatalf ( "MaxPrecipitationProbability = %#v, want 80" , timing . MaxPrecipitationProbability )
}
2026-06-10 09:24:06 -05:00
if timing . ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
t . Fatalf ( "ProbabilityThreshold = %v, want default threshold" , timing . ProbabilityThreshold )
}
if len ( timing . PrecipitationWindows ) != 2 {
t . Fatalf ( "PrecipitationWindows length = %d, want 2: %#v" , len ( timing . PrecipitationWindows ) , timing . PrecipitationWindows )
}
first := timing . PrecipitationWindows [ 0 ]
if first . Start . Format ( time . RFC3339 ) != "2026-05-29T09:00:00-05:00" || first . End == nil || first . End . Format ( time . RFC3339 ) != "2026-05-29T11:00:00-05:00" {
t . Fatalf ( "first window = %#v, want 9-11 AM" , first )
}
if first . MaxPrecipitationProbability . Value != 60 || first . MaxPrecipitationProbability . Time . Format ( time . RFC3339 ) != "2026-05-29T10:00:00-05:00" {
t . Fatalf ( "first window max = %#v, want 60 at 10 AM" , first . MaxPrecipitationProbability )
}
2026-06-16 19:10:11 -05:00
if len ( first . TextDescriptions ) != 2 || first . TextDescriptions [ 0 ] != "Showers" || first . TextDescriptions [ 1 ] != "Rain likely" {
t . Fatalf ( "first window text descriptions = %#v, want contributing hourly descriptions" , first . TextDescriptions )
}
2026-06-10 09:24:06 -05:00
second := timing . PrecipitationWindows [ 1 ]
if second . Start . Format ( time . RFC3339 ) != "2026-05-29T12:00:00-05:00" || second . End == nil || second . End . Format ( time . RFC3339 ) != "2026-05-29T13:00:00-05:00" {
t . Fatalf ( "second window = %#v, want noon-1 PM" , second )
}
2026-06-16 19:10:11 -05:00
if len ( second . TextDescriptions ) != 1 || second . TextDescriptions [ 0 ] != "Thunderstorms" {
t . Fatalf ( "second window text descriptions = %#v, want thunderstorm description" , second . TextDescriptions )
}
2026-06-09 20:43:53 +00:00
if ! timing . ThunderMentioned {
t . Fatal ( "ThunderMentioned = false, want true" )
}
}
2026-06-10 09:24:06 -05:00
func TestBuildPrecipTimingLeavesFinalWindowOpen ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
periods := [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Cloudy" , 70 , nil , ptr ( 0 ) , nil , nil ) ,
hour ( location , "2026-05-29T09:00:00-05:00" , "2026-05-29T10:00:00-05:00" , "Showers" , 70 , nil , ptr ( 45 ) , nil , nil ) ,
hour ( location , "2026-05-29T10:00:00-05:00" , "2026-05-29T11:00:00-05:00" , "Rain likely" , 70 , nil , ptr ( 60 ) , nil , nil ) ,
}
timing := BuildPrecipTiming ( periods )
if len ( timing . PrecipitationWindows ) != 1 {
t . Fatalf ( "PrecipitationWindows length = %d, want 1" , len ( timing . PrecipitationWindows ) )
}
if timing . PrecipitationWindows [ 0 ] . End != nil {
t . Fatalf ( "open window End = %v, want nil" , timing . PrecipitationWindows [ 0 ] . End )
}
if timing . LastPrecipitation != nil {
t . Fatalf ( "LastPrecipitation = %#v, want nil for open final window" , timing . LastPrecipitation )
}
}
func TestBuildPrecipTimingSupportsNonDefaultThreshold ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
periods := [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Showers" , 70 , nil , ptr ( 50 ) , nil , nil ) ,
hour ( location , "2026-05-29T09:00:00-05:00" , "2026-05-29T10:00:00-05:00" , "Rain likely" , 70 , nil , ptr ( 60 ) , nil , nil ) ,
hour ( location , "2026-05-29T10:00:00-05:00" , "2026-05-29T11:00:00-05:00" , "Showers" , 70 , nil , ptr ( 55 ) , nil , nil ) ,
hour ( location , "2026-05-29T11:00:00-05:00" , "2026-05-29T12:00:00-05:00" , "Drying out" , 70 , nil , ptr ( 20 ) , nil , nil ) ,
}
timing := buildPrecipTimingWithThreshold ( periods , 55 )
if timing . ProbabilityThreshold != 55 {
t . Fatalf ( "ProbabilityThreshold = %v, want 55" , timing . ProbabilityThreshold )
}
if len ( timing . PrecipitationWindows ) != 1 {
t . Fatalf ( "PrecipitationWindows length = %d, want 1" , len ( timing . PrecipitationWindows ) )
}
window := timing . PrecipitationWindows [ 0 ]
if window . Start . Format ( time . RFC3339 ) != "2026-05-29T09:00:00-05:00" || window . End == nil || window . End . Format ( time . RFC3339 ) != "2026-05-29T11:00:00-05:00" {
t . Fatalf ( "window = %#v, want 9-11 AM" , window )
}
}
2026-06-09 20:43:53 +00:00
func TestBuildPrecipTimingHandlesDryForecast ( t * testing . T ) {
location := time . FixedZone ( "Test" , - 5 * 60 * 60 )
periods := [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Sunny" , 70 , nil , ptr ( 0 ) , nil , nil ) ,
}
timing := BuildPrecipTiming ( periods )
2026-06-10 09:24:06 -05:00
if timing . FirstPrecipitation != nil || timing . LastPrecipitation != nil || len ( timing . PrecipitationWindows ) != 0 || timing . ThunderMentioned {
2026-06-09 20:43:53 +00:00
t . Fatalf ( "dry timing = %#v, want no precip timing and no thunder" , timing )
}
2026-06-10 09:24:06 -05:00
if timing . ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
t . Fatalf ( "ProbabilityThreshold = %v, want default threshold" , timing . ProbabilityThreshold )
}
2026-06-09 20:43:53 +00:00
if timing . MaxPrecipitationProbability == nil || timing . MaxPrecipitationProbability . Value != 0 {
t . Fatalf ( "dry max precip = %#v, want checked zero chance" , timing . MaxPrecipitationProbability )
}
}
2026-05-29 17:12:20 +00:00
func TestThresholdHelpers ( t * testing . T ) {
if ! DifferenceAtLeast ( 50 , 56 , 5 ) {
t . Fatal ( "DifferenceAtLeast = false, want true" )
}
if ! CrossesAtOrAbove ( 29 , 32 , 32 ) {
t . Fatal ( "CrossesAtOrAbove = false, want true" )
}
if ! CrossesBelow ( 35 , 31 , 32 ) {
t . Fatal ( "CrossesBelow = false, want true" )
}
}
2026-06-09 20:16:47 +00:00
func testBundle ( location * time . Location ) * weatherdata . Bundle {
return & weatherdata . Bundle {
Hourly : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
hour ( location , "2026-05-29T05:00:00-05:00" , "2026-05-29T06:00:00-05:00" , "Cloudy" , 55 , nil , nil , nil , nil ) ,
hour ( location , "2026-05-29T06:00:00-05:00" , "2026-05-29T07:00:00-05:00" , "Thunderstorms and gusty wind" , 58 , ptr ( 57 ) , ptr ( 70 ) , ptr ( 22 ) , ptr ( 40 ) ) ,
hour ( location , "2026-05-29T11:00:00-05:00" , "2026-05-29T12:00:00-05:00" , "Thunderstorms and gusty wind" , 72 , ptr ( 74 ) , ptr ( 60 ) , ptr ( 18 ) , ptr ( 35 ) ) ,
hour ( location , "2026-05-29T14:00:00-05:00" , "2026-05-29T15:00:00-05:00" , "Hot and sunny" , 96 , ptr ( 100 ) , ptr ( 5 ) , ptr ( 10 ) , ptr ( 12 ) ) ,
} } ,
2026-06-09 20:16:47 +00:00
Narrative : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
hour ( location , "2026-05-29T06:00:00-05:00" , "2026-05-29T18:00:00-05:00" , "Storms early, hot later." , 96 , nil , nil , nil , nil ) ,
} } ,
2026-06-09 20:16:47 +00:00
Alerts : & weatherdata . AlertRun { Alerts : [ ] json . RawMessage {
2026-05-29 17:12:20 +00:00
json . RawMessage ( ` { "event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"} ` ) ,
} } ,
2026-06-09 20:16:47 +00:00
Discussion : & weatherdata . Discussion { Product : "discussion" , KeyMessages : [ ] string { "Storms possible." } } ,
Sources : [ ] weatherdata . Source { { Name : "hourly" } } ,
Warnings : [ ] weatherdata . SourceWarning { { Source : "daily" , Code : "missing_source" } } ,
2026-05-29 17:12:20 +00:00
}
}
2026-06-09 20:16:47 +00:00
func hour ( location * time . Location , start string , end string , text string , temperature float64 , apparent * float64 , precip * float64 , wind * float64 , gust * float64 ) weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
startTime := mustParse ( start ) . In ( location )
endTime := mustParse ( end ) . In ( location )
temp := temperature
2026-06-09 20:16:47 +00:00
return weatherdata . ForecastPeriod {
2026-05-29 17:12:20 +00:00
StartTime : startTime ,
EndTime : endTime ,
TextDescription : text ,
TemperatureF : & temp ,
ApparentTemperatureF : apparent ,
ProbabilityOfPrecipitationPercent : precip ,
WindSpeedMph : wind ,
WindGustMph : gust ,
}
}
func mustParse ( value string ) time . Time {
parsed , err := time . Parse ( time . RFC3339 , value )
if err != nil {
panic ( err )
}
return parsed
}
func ptr ( value float64 ) * float64 {
return & value
}
func assertRange ( t * testing . T , name string , got Range , wantMin float64 , wantMax float64 ) {
t . Helper ( )
if got . Min == nil || got . Max == nil {
t . Fatalf ( "%s = %#v, want min and max" , name , got )
}
if * got . Min != wantMin || * got . Max != wantMax {
t . Fatalf ( "%s = [%v,%v], want [%v,%v]" , name , * got . Min , * got . Max , wantMin , wantMax )
}
}