2026-06-14 04:46:53 +00:00
package generatedtext
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
2026-06-14 08:57:53 -05:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
2026-06-14 04:46:53 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildHourlyRenderContext ( t * testing . T ) {
metadata := testMetadata ( )
snapshot := testSnapshot ( t )
generated := Hourly {
2026-06-14 12:54:32 -05:00
Summary : "Storm chances increase through late morning." ,
ForecastDiscussion : "A front will keep the region unsettled." ,
PrecipitationTiming : "A cold front is moving into the region." ,
Confidence : "Medium confidence in timing." ,
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
collected := testCollected ( )
derived := testDerived ( )
ctx , err := BuildHourlyRenderContext ( metadata , snapshot , generated , collected , derived )
2026-06-14 04:46:53 +00:00
if err != nil {
t . Fatalf ( "BuildHourlyRenderContext() error = %v" , err )
}
2026-06-14 08:57:53 -05:00
if ctx . Report . Title != "Hourly Report" {
t . Fatalf ( "Report.Title = %q, want Hourly Report" , ctx . Report . Title )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Report . LocationName != "Brentwood, MO" {
t . Fatalf ( "Report.LocationName = %q, want Brentwood, MO" , ctx . Report . LocationName )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Report . ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t . Fatalf ( "Report.ValidPeriodLabel = %q, want friendly period" , ctx . Report . ValidPeriodLabel )
2026-06-14 04:46:53 +00:00
}
2026-06-14 12:54:32 -05:00
if ctx . Report . GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" {
t . Fatalf ( "Report.GeneratedAtLabel = %q, want friendly generated-at label" , ctx . Report . GeneratedAtLabel )
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . CurrentConditions == nil || ctx . Modules . CurrentConditions . ConditionText != "Partly cloudy" || ctx . Modules . CurrentConditions . TemperatureF == nil || * ctx . Modules . CurrentConditions . TemperatureF != 74 {
t . Fatalf ( "Modules.CurrentConditions = %#v, want structured current conditions" , ctx . Modules . CurrentConditions )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . HourlyForecast == nil || len ( ctx . Modules . HourlyForecast . Periods ) != 2 {
t . Fatalf ( "Modules.HourlyForecast = %#v, want 2 periods" , ctx . Modules . HourlyForecast )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if period := ctx . Modules . HourlyForecast . Periods [ 1 ] ; period . PeriodBegins != "2026-05-29 at 10:00 AM" || period . TextDescription != "Showers" || period . ProbabilityOfPrecipitationPercent == nil || * period . ProbabilityOfPrecipitationPercent != 70 {
t . Fatalf ( "Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row" , period )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . PrecipTiming == nil || ctx . Modules . PrecipTiming . MaxPopPercent == nil || * ctx . Modules . PrecipTiming . MaxPopPercent != 70 {
t . Fatalf ( "Modules.PrecipTiming = %#v, want max pop" , ctx . Modules . PrecipTiming )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . AlertDigest == nil || len ( ctx . Modules . AlertDigest . Relevant ) != 1 || ctx . Modules . AlertDigest . Relevant [ 0 ] . Event != "Flood Watch" {
t . Fatalf ( "Modules.AlertDigest = %#v, want alert" , ctx . Modules . AlertDigest )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . SPCConvectiveOutlooks == nil || len ( ctx . Modules . SPCConvectiveOutlooks . Outlooks ) != 1 || ctx . Modules . SPCConvectiveOutlooks . Outlooks [ 0 ] . LabelText != "Slight Risk" {
t . Fatalf ( "Modules.SPCConvectiveOutlooks = %#v, want outlook" , ctx . Modules . SPCConvectiveOutlooks )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . AreaForecastDiscussion == nil || ctx . Modules . AreaForecastDiscussion . ShortTerm != "Short-term discussion favors increasing rain coverage." {
t . Fatalf ( "Modules.AreaForecastDiscussion = %#v, want discussion text" , ctx . Modules . AreaForecastDiscussion )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . SPCConvectiveDiscussion == nil || len ( ctx . Modules . SPCConvectiveDiscussion . Discussions ) != 1 || ctx . Modules . SPCConvectiveDiscussion . Discussions [ 0 ] . Headline != "Mesoscale discussion" {
t . Fatalf ( "Modules.SPCConvectiveDiscussion = %#v, want discussion" , ctx . Modules . SPCConvectiveDiscussion )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . WeatherStory == nil || ctx . Modules . WeatherStory . Title != "Morning storms" {
t . Fatalf ( "Modules.WeatherStory = %#v, want story" , ctx . Modules . WeatherStory )
}
if ! ctx . Collected . FetchedAt . Equal ( collected . FetchedAt ) {
t . Fatalf ( "Collected.FetchedAt = %s, want %s" , ctx . Collected . FetchedAt , collected . FetchedAt )
}
if ! ctx . Derived . PrecipTiming . ThunderMentioned {
t . Fatalf ( "Derived.PrecipTiming.ThunderMentioned = false, want true" )
2026-06-14 04:46:53 +00:00
}
rendered , err := reporttemplate . Render ( "hourly" , ctx )
if err != nil {
t . Fatalf ( "Render() error = %v" , err )
}
text := string ( rendered )
for _ , want := range [ ] string {
"# Hourly Report" ,
2026-06-14 12:54:32 -05:00
"**Updated:** Friday, May 29, 2026 at 8:30 AM" ,
2026-06-14 04:46:53 +00:00
"Storm chances increase through late morning." ,
2026-06-14 12:54:32 -05:00
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%." ,
"- **Flood Watch**: Flood Watch until early afternoon" ,
"A cold front is moving into the region." ,
"A front will keep the region unsettled." ,
2026-06-14 04:46:53 +00:00
} {
if ! strings . Contains ( text , want ) {
t . Fatalf ( "rendered template missing %q:\n%s" , want , text )
}
}
}
2026-06-14 08:57:53 -05:00
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules ( t * testing . T ) {
2026-06-14 04:46:53 +00:00
snapshot , err := module . NewSnapshot ( [ ] module . Output {
{ ID : module . CurrentConditions , StanzaName : string ( module . CurrentConditions ) , Value : briefing . CurrentConditionsModule { } } ,
} )
if err != nil {
t . Fatalf ( "NewSnapshot() error = %v" , err )
}
2026-06-14 08:57:53 -05:00
ctx , err := BuildHourlyRenderContext ( testMetadata ( ) , snapshot , Hourly {
2026-06-14 12:54:32 -05:00
Summary : "Storm chances increase." ,
ForecastDiscussion : "A front will keep the region unsettled." ,
2026-06-14 08:57:53 -05:00
} , testCollected ( ) , facts . DerivedFacts { } )
if err != nil {
t . Fatalf ( "BuildHourlyRenderContext() error = %v" , err )
2026-06-14 04:46:53 +00:00
}
2026-06-14 08:57:53 -05:00
if ctx . Modules . HourlyForecast != nil {
t . Fatalf ( "Modules.HourlyForecast = %#v, want nil for omitted module" , ctx . Modules . HourlyForecast )
2026-06-14 04:46:53 +00:00
}
}
func testMetadata ( ) briefing . Metadata {
generatedAt := time . Date ( 2026 , 5 , 29 , 13 , 30 , 0 , 0 , time . UTC )
return briefing . Metadata {
RunID : "run-1" ,
ReportID : report . Hourly ,
PromptID : "weather.hourly_generated_text" ,
GeneratedAt : generatedAt ,
Units : "imperial" ,
Timezone : "America/Chicago" ,
ValidPeriod : timeutil . Period {
Start : generatedAt ,
End : generatedAt . Add ( 6 * time . Hour ) ,
} ,
Location : & briefing . LocationContext {
Name : "Brentwood" ,
Region : "MO" ,
} ,
}
}
2026-06-14 08:57:53 -05:00
func testCollected ( ) facts . CollectedFacts {
return facts . CollectedFacts { FetchedAt : time . Date ( 2026 , 5 , 29 , 13 , 31 , 0 , 0 , time . UTC ) }
}
func testDerived ( ) facts . DerivedFacts {
return facts . DerivedFacts { PrecipTiming : forecast . PrecipTiming { ThunderMentioned : true } }
}
2026-06-14 04:46:53 +00:00
func testSnapshot ( t * testing . T ) module . Snapshot {
t . Helper ( )
snapshot , err := module . NewSnapshot ( [ ] module . Output {
{
ID : module . CurrentConditions ,
StanzaName : string ( module . CurrentConditions ) ,
Value : briefing . CurrentConditionsModule {
ConditionText : "Partly cloudy" ,
2026-06-14 12:54:32 -05:00
ConditionTextLower : "partly cloudy" ,
2026-06-14 17:40:19 -05:00
TemperatureF : intPtr ( 74 ) ,
ApparentTemperatureF : intPtr ( 76 ) ,
RelativeHumidityPercent : intPtr ( 71 ) ,
WindSpeedMph : intPtr ( 8 ) ,
2026-06-14 04:46:53 +00:00
WindDirection : "S" ,
2026-06-14 12:54:32 -05:00
WindDirectionText : "south" ,
2026-06-14 04:46:53 +00:00
} ,
} ,
{
ID : module . HourlyForecast ,
StanzaName : string ( module . HourlyForecast ) ,
Value : briefing . HourlyForecastModule {
Periods : [ ] briefing . HourlyForecastPeriod {
{
2026-06-14 12:54:32 -05:00
HourLabel : "9:00 AM" ,
2026-06-14 04:46:53 +00:00
PeriodBegins : "2026-05-29 at 9:00 AM" ,
TextDescription : "Cloudy" ,
2026-06-14 12:54:32 -05:00
TextDescriptionLower : "cloudy" ,
2026-06-14 04:46:53 +00:00
TemperatureF : floatPtr ( 74 ) ,
ProbabilityOfPrecipitationPercent : floatPtr ( 30 ) ,
2026-06-14 12:54:32 -05:00
MentionPrecipitation : true ,
2026-06-14 04:46:53 +00:00
WindSpeedMph : floatPtr ( 8 ) ,
WindDirection : "S" ,
} ,
{
2026-06-14 12:54:32 -05:00
HourLabel : "10:00 AM" ,
2026-06-14 04:46:53 +00:00
PeriodBegins : "2026-05-29 at 10:00 AM" ,
TextDescription : "Showers" ,
2026-06-14 12:54:32 -05:00
TextDescriptionLower : "showers" ,
2026-06-14 04:46:53 +00:00
TemperatureF : floatPtr ( 75 ) ,
ProbabilityOfPrecipitationPercent : floatPtr ( 70 ) ,
2026-06-14 12:54:32 -05:00
MentionPrecipitation : true ,
2026-06-14 04:46:53 +00:00
WindSpeedMph : floatPtr ( 10 ) ,
WindGustMph : floatPtr ( 18 ) ,
WindDirection : "S" ,
} ,
} ,
} ,
} ,
{
ID : module . PrecipTiming ,
StanzaName : string ( module . PrecipTiming ) ,
Value : briefing . PrecipTimingModule {
MaxPopPercent : intPtr ( 70 ) ,
MaxPopTime : "10 AM" ,
ProbabilityThreshold : 50 ,
PrecipitationWindows : [ ] briefing . PrecipitationWindowModule {
{
2026-06-14 12:54:32 -05:00
PeriodBegins : "2026-05-29 at 10:00 AM" ,
PeriodBeginsHourLabel : "10:00 AM" ,
PeriodEnds : "2026-05-29 at 12:00 PM" ,
PeriodEndsHourLabel : "12:00 PM" ,
MaxPopPercent : intPtr ( 70 ) ,
MaxPopTime : "10 AM" ,
MaxPopHourLabel : "10:00 AM" ,
2026-06-14 04:46:53 +00:00
} ,
} ,
ThunderMentioned : true ,
} ,
} ,
{
ID : module . AlertDigest ,
StanzaName : string ( module . AlertDigest ) ,
Value : briefing . AlertDigestModule {
Checked : true ,
ActiveCount : 1 ,
RelevantCount : 1 ,
Relevant : [ ] briefing . AlertSummary {
{ Event : "Flood Watch" , Headline : "Flood Watch until early afternoon" , Severity : "Moderate" } ,
} ,
} ,
} ,
{
ID : module . SPCConvectiveOutlooks ,
StanzaName : string ( module . SPCConvectiveOutlooks ) ,
Value : briefing . SPCConvectiveOutlooksModule {
Checked : true ,
OutlookCount : 1 ,
Outlooks : [ ] briefing . SPCConvectiveOutlookRecord {
{
Label : "SLGT" ,
LabelText : "Slight Risk" ,
PeriodBegins : "2026-05-29 at 7:00 AM" ,
PeriodEnds : "2026-05-29 at 3:00 PM" ,
} ,
} ,
} ,
} ,
{
ID : module . AreaForecastDiscussion ,
StanzaName : string ( module . AreaForecastDiscussion ) ,
Value : briefing . AreaForecastDiscussionModule {
KeyMessages : [ ] string { "Storms are most likely late morning." } ,
ShortTerm : "Short-term discussion favors increasing rain coverage." ,
} ,
} ,
{
ID : module . SPCConvectiveDiscussion ,
StanzaName : string ( module . SPCConvectiveDiscussion ) ,
Value : briefing . SPCConvectiveDiscussionModule {
IncludedBecause : "categorical severity_rank >= 3" ,
Discussions : [ ] briefing . SPCConvectiveDiscussionRecord {
{ Headline : "Mesoscale discussion" , Summary : "Strong storms may develop late morning." } ,
} ,
} ,
} ,
{
ID : module . WeatherStory ,
StanzaName : string ( module . WeatherStory ) ,
Value : briefing . WeatherStoryModule {
Available : true ,
Title : "Morning storms" ,
Description : "Morning storms remain the main story." ,
} ,
} ,
} )
if err != nil {
t . Fatalf ( "NewSnapshot() error = %v" , err )
}
return snapshot
}
func floatPtr ( value float64 ) * float64 {
return & value
}
func intPtr ( value int ) * int {
return & value
}