2026-06-09 20:23:53 +00:00
package facts
import (
"encoding/json"
2026-06-12 14:56:17 +00:00
"strings"
2026-06-09 20:23:53 +00:00
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate ( t * testing . T ) {
fetchedAt := mustParse ( "2026-05-29T10:00:00Z" )
bundle := & weatherdata . Bundle {
FetchedAt : fetchedAt ,
Current : & weatherdata . Current { ConditionText : "Clear" } ,
Hourly : & weatherdata . ForecastRun { Product : "hourly" } ,
2026-06-12 14:47:42 +00:00
SPCConvectiveOutlooks : & weatherdata . ConvectiveOutlookRun {
Product : "convective_outlook" ,
Outlooks : [ ] weatherdata . ConvectiveOutlook { {
ID : "day1-categorical-slight" ,
Label : "SLGT" ,
} } ,
} ,
Sources : [ ] weatherdata . Source { { Name : "hourly" } } ,
Warnings : [ ] weatherdata . SourceWarning { { Source : "discussion" , Code : "missing_source" } } ,
2026-06-09 20:23:53 +00:00
}
collected := BuildCollected ( bundle )
if collected . FetchedAt != fetchedAt || collected . Current . ConditionText != "Clear" || collected . Hourly . Product != "hourly" {
t . Fatalf ( "CollectedFacts = %#v, want source facts copied from bundle" , collected )
}
2026-06-12 14:47:42 +00:00
if collected . SPCConvectiveOutlooks == nil || collected . SPCConvectiveOutlooks . Outlooks [ 0 ] . Label != "SLGT" {
t . Fatalf ( "SPCConvectiveOutlooks = %#v, want source copied from bundle" , collected . SPCConvectiveOutlooks )
}
2026-06-09 20:23:53 +00:00
if len ( collected . SourceProvenance ) != 1 || collected . SourceProvenance [ 0 ] . Name != "hourly" {
t . Fatalf ( "SourceProvenance = %#v, want hourly source" , collected . SourceProvenance )
}
2026-06-10 08:24:50 -05:00
if len ( collected . SourceWarnings ) != 1 || collected . SourceWarnings [ 0 ] . Source != "discussion" {
t . Fatalf ( "SourceWarnings = %#v, want discussion warning" , collected . SourceWarnings )
2026-06-09 20:23:53 +00:00
}
bundle . Sources [ 0 ] . Name = "changed"
bundle . Warnings [ 0 ] . Source = "changed"
2026-06-10 08:24:50 -05:00
if collected . SourceProvenance [ 0 ] . Name != "hourly" || collected . SourceWarnings [ 0 ] . Source != "discussion" {
2026-06-09 20:23:53 +00:00
t . Fatalf ( "collected source slices changed after bundle mutation: %#v %#v" , collected . SourceProvenance , collected . SourceWarnings )
}
2026-06-12 14:47:42 +00:00
roundTrip := collected . Bundle ( )
if roundTrip . SPCConvectiveOutlooks == nil || roundTrip . SPCConvectiveOutlooks . Outlooks [ 0 ] . ID != "day1-categorical-slight" {
t . Fatalf ( "Bundle().SPCConvectiveOutlooks = %#v, want collected source restored" , roundTrip . SPCConvectiveOutlooks )
}
2026-06-09 20:23:53 +00:00
}
func TestBuildDerivedDailySlicesDaypartsAndAlerts ( t * testing . T ) {
location := testLocation ( )
2026-06-15 16:43:06 +00:00
resolved := resolveForTest ( t , report . Daily , mustParse ( "2026-05-29T08:00:00-05:00" ) , location )
2026-06-09 20:23:53 +00:00
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( testBundle ( location ) ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived() error = %v" , err )
}
if len ( derived . ValidPeriodHourlyPeriods ) != 4 {
t . Fatalf ( "ValidPeriodHourlyPeriods length = %d, want 4" , len ( derived . ValidPeriodHourlyPeriods ) )
}
if len ( derived . ValidPeriodNarrativePeriods ) != 1 {
t . Fatalf ( "ValidPeriodNarrativePeriods length = %d, want 1" , len ( derived . ValidPeriodNarrativePeriods ) )
}
if len ( derived . AlertOverlaps ) != 1 || derived . AlertOverlaps [ 0 ] . Event != "Flood Watch" {
t . Fatalf ( "AlertOverlaps = %#v, want Flood Watch overlap" , derived . AlertOverlaps )
}
if len ( derived . DailySummaries ) != 1 || len ( derived . DailySummaries [ 0 ] . Dayparts ) != 3 {
t . Fatalf ( "DailySummaries = %#v, want one summary with dayparts" , derived . DailySummaries )
}
if len ( derived . DaypartSummaries ) != 3 {
t . Fatalf ( "DaypartSummaries length = %d, want 3" , len ( derived . DaypartSummaries ) )
}
2026-06-09 20:43:53 +00:00
if derived . PrecipTiming . FirstPrecipitation == nil || derived . PrecipTiming . FirstPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T08:00:00-05:00" {
t . Fatalf ( "PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start" , derived . PrecipTiming . FirstPrecipitation )
}
2026-06-10 09:24:06 -05:00
if derived . PrecipTiming . LastPrecipitation == nil || derived . PrecipTiming . LastPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T14:00:00-05:00" {
t . Fatalf ( "PrecipTiming.LastPrecipitation = %#v, want final closed window end" , derived . PrecipTiming . LastPrecipitation )
}
if len ( derived . PrecipTiming . PrecipitationWindows ) != 2 {
t . Fatalf ( "PrecipitationWindows = %#v, want two threshold windows" , derived . PrecipTiming . PrecipitationWindows )
}
2026-06-09 20:43:53 +00:00
if ! derived . PrecipTiming . ThunderMentioned {
t . Fatal ( "PrecipTiming.ThunderMentioned = false, want true" )
}
2026-06-09 20:23:53 +00:00
morning := derived . DailySummaries [ 0 ] . Dayparts [ 0 ]
if len ( morning . HourlyPeriods ) != 1 || morning . MaxPrecipitationProbability == nil || morning . MaxPrecipitationProbability . Value != 60 {
t . Fatalf ( "morning summary = %#v, want sliced hour with precip max" , morning )
}
}
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources ( t * testing . T ) {
location := testLocation ( )
resolved := resolveForTest ( t , report . ThreeDay , mustParse ( "2026-05-29T08:00:00-05:00" ) , location )
bundle := testBundle ( location )
bundle . Narrative = nil
bundle . Alerts = nil
bundle . Discussion = nil
bundle . WeatherStory = nil
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( bundle ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived() error = %v" , err )
}
if len ( derived . DailySummaries ) != 3 {
t . Fatalf ( "DailySummaries length = %d, want 3 partial-day summaries" , len ( derived . DailySummaries ) )
}
if len ( derived . ValidPeriodNarrativePeriods ) != 0 {
t . Fatalf ( "ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source" , len ( derived . ValidPeriodNarrativePeriods ) )
}
if len ( derived . AlertOverlaps ) != 0 {
t . Fatalf ( "AlertOverlaps = %#v, want none for missing optional alerts" , derived . AlertOverlaps )
}
if derived . DailySummaries [ 0 ] . Period . Start . Format ( time . RFC3339 ) != "2026-05-29T08:00:00-05:00" {
t . Fatalf ( "first summary start = %s, want valid-period start" , derived . DailySummaries [ 0 ] . Period . Start . Format ( time . RFC3339 ) )
}
}
func TestBuildDerivedWeekendAndTomorrow ( t * testing . T ) {
location := testLocation ( )
2026-06-14 23:22:31 +00:00
for _ , id := range [ ] report . ID { report . Tomorrow , report . Weekend } {
2026-06-09 20:23:53 +00:00
resolved := resolveForTest ( t , id , mustParse ( "2026-05-29T08:00:00-05:00" ) , location )
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( testBundle ( location ) ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived(%s) error = %v" , id , err )
}
if len ( derived . DailySummaries ) == 0 {
t . Fatalf ( "BuildDerived(%s) DailySummaries length = 0, want summaries" , id )
}
}
}
2026-06-14 04:26:05 +00:00
func TestBuildDerivedHourlyUsesRollingWindowFacts ( t * testing . T ) {
2026-06-12 17:34:40 +00:00
location := testLocation ( )
2026-06-14 04:26:05 +00:00
resolved := resolveForTest ( t , report . Hourly , mustParse ( "2026-05-29T08:30:00-05:00" ) , location )
2026-06-12 17:34:40 +00:00
bundle := testBundle ( location )
bundle . Alerts = & weatherdata . AlertRun { Alerts : [ ] json . RawMessage {
json . RawMessage ( ` { "event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"} ` ) ,
json . RawMessage ( ` { "event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"} ` ) ,
json . RawMessage ( ` { "event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"} ` ) ,
} }
bundle . SPCConvectiveOutlooks = testConvectiveOutlookRun ( location )
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( bundle ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived() error = %v" , err )
}
if len ( derived . ValidPeriodHourlyPeriods ) != 4 {
t . Fatalf ( "ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours" , len ( derived . ValidPeriodHourlyPeriods ) )
}
if derived . ValidPeriodHourlyPeriods [ 0 ] . StartTime . Format ( time . RFC3339 ) != "2026-05-29T08:00:00-05:00" ||
derived . ValidPeriodHourlyPeriods [ 3 ] . StartTime . Format ( time . RFC3339 ) != "2026-05-29T14:00:00-05:00" {
t . Fatalf ( "ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM" , derived . ValidPeriodHourlyPeriods )
}
if len ( derived . ValidPeriodNarrativePeriods ) != 1 {
t . Fatalf ( "ValidPeriodNarrativePeriods length = %d, want overlapping narrative period" , len ( derived . ValidPeriodNarrativePeriods ) )
}
if len ( derived . DailySummaries ) != 0 || len ( derived . DaypartSummaries ) != 0 || derived . StormWindowSummary != nil {
2026-06-14 04:26:05 +00:00
t . Fatalf ( "hourly summaries daily=%#v daypart=%#v storm=%#v, want none" , derived . DailySummaries , derived . DaypartSummaries , derived . StormWindowSummary )
2026-06-12 17:34:40 +00:00
}
if derived . PrecipTiming . FirstPrecipitation == nil || derived . PrecipTiming . FirstPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T08:00:00-05:00" {
t . Fatalf ( "PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour" , derived . PrecipTiming . FirstPrecipitation )
}
if derived . PrecipTiming . LastPrecipitation == nil || derived . PrecipTiming . LastPrecipitation . Time . Format ( time . RFC3339 ) != "2026-05-29T14:00:00-05:00" {
t . Fatalf ( "PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end" , derived . PrecipTiming . LastPrecipitation )
}
if len ( derived . PrecipTiming . PrecipitationWindows ) != 2 {
2026-06-14 04:26:05 +00:00
t . Fatalf ( "PrecipTiming.PrecipitationWindows = %#v, want two hourly windows" , derived . PrecipTiming . PrecipitationWindows )
2026-06-12 17:34:40 +00:00
}
if len ( derived . AlertOverlaps ) != 1 || derived . AlertOverlaps [ 0 ] . Event != "Flood Watch" {
2026-06-14 04:26:05 +00:00
t . Fatalf ( "AlertOverlaps = %#v, want only alert overlapping hourly period" , derived . AlertOverlaps )
2026-06-12 17:34:40 +00:00
}
if derived . AlertOverlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) != "2026-05-29T14:30:00-05:00" {
2026-06-14 04:26:05 +00:00
t . Fatalf ( "Alert overlap end = %s, want clipped to hourly end" , derived . AlertOverlaps [ 0 ] . Overlap . End . Format ( time . RFC3339 ) )
2026-06-12 17:34:40 +00:00
}
if got , want := outlookIDs ( derived . SPCConvectiveOutlooks ) , [ ] string { "fri-high" , "fri-storm" , "fri-low" , "fri-missing-rank" , "fri-probabilistic" } ; strings . Join ( got , "," ) != strings . Join ( want , "," ) {
t . Fatalf ( "SPCConvectiveOutlooks IDs = %#v, want %#v" , got , want )
}
if got , want := discussionHeadlines ( derived . SPCConvectiveDiscussions ) , [ ] string { "day1 early" , "day1 late" } ; strings . Join ( got , "," ) != strings . Join ( want , "," ) {
t . Fatalf ( "SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v" , got , want )
}
}
2026-06-09 20:23:53 +00:00
func TestBuildDerivedStormBuildsWindowSummary ( t * testing . T ) {
location := testLocation ( )
resolved := resolveStormForTest ( t , location )
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( testBundle ( location ) ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived() error = %v" , err )
}
if len ( derived . ValidPeriodHourlyPeriods ) != 2 {
t . Fatalf ( "ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours" , len ( derived . ValidPeriodHourlyPeriods ) )
}
if len ( derived . ValidPeriodDailyPeriods ) != 1 {
t . Fatalf ( "ValidPeriodDailyPeriods length = %d, want 1 daily period" , len ( derived . ValidPeriodDailyPeriods ) )
}
if derived . StormWindowSummary == nil {
t . Fatal ( "StormWindowSummary = nil, want summary" )
}
if derived . StormWindowSummary . MaxPrecipitationProbability == nil || derived . StormWindowSummary . MaxPrecipitationProbability . Value != 80 {
t . Fatalf ( "StormWindowSummary = %#v, want peak precipitation" , derived . StormWindowSummary )
}
if len ( derived . StormWindowSummary . AlertOverlaps ) != 1 {
t . Fatalf ( "StormWindowSummary.AlertOverlaps length = %d, want 1" , len ( derived . StormWindowSummary . AlertOverlaps ) )
}
}
2026-06-12 14:56:17 +00:00
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod ( t * testing . T ) {
location := testLocation ( )
now := mustParse ( "2026-05-29T08:00:00-05:00" )
bundle := testBundle ( location )
bundle . SPCConvectiveOutlooks = testConvectiveOutlookRun ( location )
tests := [ ] struct {
name string
resolved report . Resolved
wantOutlookIDs [ ] string
wantDiscussion [ ] string
} {
{
2026-06-15 16:49:37 +00:00
name : "daily" ,
2026-06-15 16:43:06 +00:00
resolved : resolveForTest ( t , report . Daily , now , location ) ,
2026-06-12 14:56:17 +00:00
wantOutlookIDs : [ ] string { "fri-high" , "fri-storm" , "fri-low" , "fri-missing-rank" , "fri-probabilistic" } ,
wantDiscussion : [ ] string { "day1 early" , "day1 late" } ,
} ,
{
name : "daily tomorrow" ,
2026-06-14 23:22:31 +00:00
resolved : resolveForTest ( t , report . Tomorrow , now , location ) ,
2026-06-12 14:56:17 +00:00
wantOutlookIDs : [ ] string { "sat-enhanced" } ,
wantDiscussion : [ ] string { "day2" } ,
} ,
{
name : "three day" ,
resolved : resolveForTest ( t , report . ThreeDay , now , location ) ,
wantOutlookIDs : [ ] string { "fri-high" , "fri-storm" , "fri-low" , "fri-missing-rank" , "fri-probabilistic" , "sat-enhanced" , "sun-slight" } ,
wantDiscussion : [ ] string { "day1 early" , "day1 late" , "day2" , "day3" } ,
} ,
{
name : "weekend" ,
resolved : resolveForTest ( t , report . Weekend , now , location ) ,
wantOutlookIDs : [ ] string { "sat-enhanced" , "sun-slight" } ,
wantDiscussion : [ ] string { "day2" , "day3" } ,
} ,
{
name : "storm" ,
resolved : resolveStormForTest ( t , location ) ,
wantOutlookIDs : [ ] string { "fri-storm" , "fri-low" } ,
wantDiscussion : [ ] string { "day1 early" , "day1 late" } ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : tt . resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( bundle ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived() error = %v" , err )
}
if got := outlookIDs ( derived . SPCConvectiveOutlooks ) ; strings . Join ( got , "," ) != strings . Join ( tt . wantOutlookIDs , "," ) {
t . Fatalf ( "SPCConvectiveOutlooks IDs = %#v, want %#v" , got , tt . wantOutlookIDs )
}
if got := discussionHeadlines ( derived . SPCConvectiveDiscussions ) ; strings . Join ( got , "," ) != strings . Join ( tt . wantDiscussion , "," ) {
t . Fatalf ( "SPCConvectiveDiscussions = %#v, want %#v" , got , tt . wantDiscussion )
}
} )
}
}
2026-06-12 17:34:40 +00:00
func TestBuildDerivedUnsupportedReportReturnsActionableError ( t * testing . T ) {
location := testLocation ( )
2026-06-14 04:26:05 +00:00
resolved := resolveForTest ( t , report . Hourly , mustParse ( "2026-05-29T08:00:00-05:00" ) , location )
2026-06-12 17:34:40 +00:00
resolved . Definition . ID = report . ID ( "future_report" )
_ , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( testBundle ( location ) ) ,
} )
if err == nil || ! strings . Contains ( err . Error ( ) , ` derived facts are not implemented for report "future_report" ` ) {
t . Fatalf ( "BuildDerived() error = %v, want actionable unsupported report error" , err )
}
}
2026-06-12 14:56:17 +00:00
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty ( t * testing . T ) {
location := testLocation ( )
2026-06-15 16:43:06 +00:00
resolved := resolveForTest ( t , report . Daily , mustParse ( "2026-05-29T08:00:00-05:00" ) , location )
2026-06-12 14:56:17 +00:00
bundle := testBundle ( location )
bundle . SPCConvectiveOutlooks = nil
derived , err := BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( bundle ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived(missing source) error = %v" , err )
}
if derived . SPCConvectiveOutlooks != nil || derived . SPCConvectiveDiscussions != nil {
t . Fatalf ( "missing source derived outlooks=%#v discussions=%#v, want nil slices" , derived . SPCConvectiveOutlooks , derived . SPCConvectiveDiscussions )
}
bundle . SPCConvectiveOutlooks = & weatherdata . ConvectiveOutlookRun { Outlooks : [ ] weatherdata . ConvectiveOutlook { } , Discussions : [ ] weatherdata . ConvectiveOutlookDiscussion { } }
derived , err = BuildDerived ( BuildDerivedRequest {
Resolved : resolved ,
Timezone : location . String ( ) ,
Dayparts : testDayparts ( ) ,
Collected : BuildCollected ( bundle ) ,
} )
if err != nil {
t . Fatalf ( "BuildDerived(checked empty source) error = %v" , err )
}
if derived . SPCConvectiveOutlooks == nil || len ( derived . SPCConvectiveOutlooks ) != 0 {
t . Fatalf ( "checked empty outlooks = %#v, want non-nil empty slice" , derived . SPCConvectiveOutlooks )
}
if derived . SPCConvectiveDiscussions == nil || len ( derived . SPCConvectiveDiscussions ) != 0 {
t . Fatalf ( "checked empty discussions = %#v, want non-nil empty slice" , derived . SPCConvectiveDiscussions )
}
}
2026-06-09 20:23:53 +00:00
func testBundle ( location * time . Location ) * weatherdata . Bundle {
return & weatherdata . Bundle {
FetchedAt : mustParse ( "2026-05-29T10:00:00Z" ) ,
Current : & weatherdata . Current { ConditionText : "Cloudy" } ,
Hourly : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T08:00:00-05:00" , "2026-05-29T09:00:00-05:00" , "Showers" , 60 , 10 ) ,
hour ( location , "2026-05-29T12:00:00-05:00" , "2026-05-29T13:00:00-05:00" , "Thunderstorms" , 80 , 35 ) ,
hour ( location , "2026-05-29T13:00:00-05:00" , "2026-05-29T14:00:00-05:00" , "Heavy rain" , 70 , 25 ) ,
hour ( location , "2026-05-29T14:00:00-05:00" , "2026-05-29T15:00:00-05:00" , "Hot" , 10 , 15 ) ,
hour ( location , "2026-05-30T09:00:00-05:00" , "2026-05-30T10:00:00-05:00" , "Clear" , 0 , 5 ) ,
} } ,
Narrative : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T06:00:00-05:00" , "2026-05-29T18:00:00-05:00" , "Storm chances peak midday." , 70 , 20 ) ,
} } ,
Daily : & weatherdata . ForecastRun { Periods : [ ] weatherdata . ForecastPeriod {
hour ( location , "2026-05-29T00:00:00-05:00" , "2026-05-30T00:00:00-05:00" , "Storms" , 70 , 20 ) ,
} } ,
Alerts : & weatherdata . AlertRun { Alerts : [ ] json . RawMessage {
json . RawMessage ( ` { "event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"} ` ) ,
} } ,
Discussion : & weatherdata . Discussion { Product : "discussion" , KeyMessages : [ ] string { "Storm confidence is moderate." } } ,
WeatherStory : & weatherdata . WeatherStory { Title : "Storm Risk" } ,
Sources : [ ] weatherdata . Source { { Name : "hourly" } } ,
Warnings : [ ] weatherdata . SourceWarning { { Source : "daily" , Code : "missing_source" } } ,
}
}
2026-06-12 14:56:17 +00:00
func testConvectiveOutlookRun ( location * time . Location ) * weatherdata . ConvectiveOutlookRun {
rank1 := 1
rank2 := 2
rank3 := 3
rank4 := 4
rank5 := 5
return & weatherdata . ConvectiveOutlookRun {
Outlooks : [ ] weatherdata . ConvectiveOutlook {
convectiveOutlook ( location , "mon-outside" , 4 , "categorical" , "MDT" , & rank5 , "2026-06-01T00:00:00-05:00" , "2026-06-02T00:00:00-05:00" ) ,
convectiveOutlook ( location , "sun-slight" , 3 , "categorical" , "SLGT" , & rank3 , "2026-05-31T06:00:00-05:00" , "2026-06-01T00:00:00-05:00" ) ,
convectiveOutlook ( location , "fri-low" , 1 , "categorical" , "MRGL" , & rank1 , "2026-05-29T06:00:00-05:00" , "2026-05-29T12:00:00-05:00" ) ,
convectiveOutlook ( location , "fri-missing-rank" , 1 , "categorical" , "GEN" , nil , "2026-05-29T08:00:00-05:00" , "2026-05-29T10:00:00-05:00" ) ,
convectiveOutlook ( location , "sat-enhanced" , 2 , "categorical" , "ENH" , & rank4 , "2026-05-30T01:00:00-05:00" , "2026-05-30T12:00:00-05:00" ) ,
convectiveOutlook ( location , "fri-high" , 1 , "categorical" , "SLGT" , & rank3 , "2026-05-29T07:00:00-05:00" , "2026-05-29T11:00:00-05:00" ) ,
convectiveOutlook ( location , "fri-probabilistic" , 1 , "probabilistic" , "5%" , & rank2 , "2026-05-29T05:00:00-05:00" , "2026-05-29T10:00:00-05:00" ) ,
convectiveOutlook ( location , "fri-storm" , 1 , "categorical" , "SLGT" , & rank2 , "2026-05-29T12:00:00-05:00" , "2026-05-29T13:00:00-05:00" ) ,
} ,
Discussions : [ ] weatherdata . ConvectiveOutlookDiscussion {
{ Day : 4 , Headline : "day4" , UpdatedAt : ptrTime ( mustParse ( "2026-05-31T10:00:00-05:00" ) ) } ,
{ Day : 1 , Headline : "day1 late" , UpdatedAt : ptrTime ( mustParse ( "2026-05-29T09:00:00-05:00" ) ) } ,
{ Day : 3 , Headline : "day3" , UpdatedAt : ptrTime ( mustParse ( "2026-05-31T08:00:00-05:00" ) ) } ,
{ Day : 1 , Headline : "day1 early" , UpdatedAt : ptrTime ( mustParse ( "2026-05-29T08:00:00-05:00" ) ) } ,
{ Day : 2 , Headline : "day2" , UpdatedAt : ptrTime ( mustParse ( "2026-05-30T08:00:00-05:00" ) ) } ,
} ,
}
}
func convectiveOutlook ( location * time . Location , id string , day int , outlookType string , label string , rank * int , validFrom string , validTo string ) weatherdata . ConvectiveOutlook {
return weatherdata . ConvectiveOutlook {
ID : id ,
Day : day ,
OutlookType : outlookType ,
Label : label ,
SeverityRank : rank ,
ValidFrom : mustParse ( validFrom ) . In ( location ) ,
ValidTo : mustParse ( validTo ) . In ( location ) ,
}
}
func outlookIDs ( outlooks [ ] weatherdata . ConvectiveOutlook ) [ ] string {
out := make ( [ ] string , 0 , len ( outlooks ) )
for _ , outlook := range outlooks {
out = append ( out , outlook . ID )
}
return out
}
func discussionHeadlines ( discussions [ ] weatherdata . ConvectiveOutlookDiscussion ) [ ] string {
out := make ( [ ] string , 0 , len ( discussions ) )
for _ , discussion := range discussions {
out = append ( out , discussion . Headline )
}
return out
}
func ptrTime ( value time . Time ) * time . Time {
return & value
}
2026-06-09 20:23:53 +00:00
func hour ( location * time . Location , start string , end string , text string , precip float64 , gust float64 ) weatherdata . ForecastPeriod {
temperature := 70.0
return weatherdata . ForecastPeriod {
StartTime : mustParse ( start ) . In ( location ) ,
EndTime : mustParse ( end ) . In ( location ) ,
TextDescription : text ,
TemperatureF : & temperature ,
ProbabilityOfPrecipitationPercent : & precip ,
WindGustMph : & gust ,
}
}
func testDayparts ( ) [ ] forecast . DaypartDefinition {
return [ ] forecast . 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" } ,
}
}
func resolveForTest ( t * testing . T , id report . ID , now time . Time , location * time . Location ) report . Resolved {
t . Helper ( )
2026-06-15 16:43:06 +00:00
req := report . ResolveRequest { Now : now , Location : location }
if id == report . Daily {
req . Date = now
}
resolved , err := report . Resolve ( id , req )
2026-06-09 20:23:53 +00:00
if err != nil {
t . Fatalf ( "resolve %s: %v" , id , err )
}
return resolved
}
func resolveStormForTest ( t * testing . T , location * time . Location ) report . Resolved {
t . Helper ( )
resolved , err := report . Resolve ( report . Storm , report . ResolveRequest {
Now : mustParse ( "2026-05-29T08:00:00-05:00" ) ,
Location : location ,
StormStart : mustParse ( "2026-05-29T11:30:00-05:00" ) ,
StormEnd : mustParse ( "2026-05-29T13:30:00-05:00" ) ,
} )
if err != nil {
t . Fatalf ( "resolve storm: %v" , err )
}
return resolved
}
func testLocation ( ) * time . Location {
location , err := time . LoadLocation ( "America/Chicago" )
if err != nil {
panic ( err )
}
return location
}
func mustParse ( value string ) time . Time {
parsed , err := time . Parse ( time . RFC3339 , value )
if err != nil {
panic ( err )
}
return parsed
}