package generatedtext import ( "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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/reporttemplate" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestBuildHourlyRenderContext(t *testing.T) { metadata := testMetadata() snapshot := testSnapshot(t) generated := Hourly{ Summary: "Storm chances increase through late morning.", Timing: "The main window is 10 AM to noon.", Impacts: "Brief downpours may slow travel.", Confidence: "Medium confidence in timing.", } collected := testCollected() derived := testDerived() ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived) if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) } if ctx.Report.Title != "Hourly Report" { t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title) } if ctx.Report.LocationName != "Brentwood, MO" { t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName) } 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) } 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) } if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 { t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast) } 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) } 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) } 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) } 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) } 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) } 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) } 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") } rendered, err := reporttemplate.Render("hourly", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range []string{ "# Hourly Report", "Storm chances increase through late morning.", "- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph", "- Flood Watch: Flood Watch until early afternoon (Moderate)", "Morning storms - Morning storms remain the main story.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } } func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { 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) } ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ Summary: "Storm chances increase.", Timing: "Late morning.", Impacts: "Brief downpours.", }, testCollected(), facts.DerivedFacts{}) if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) } if ctx.Modules.HourlyForecast != nil { t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast) } } 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", }, } } 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}} } 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", TemperatureF: floatPtr(74), ApparentTemperatureF: floatPtr(76), RelativeHumidityPercent: floatPtr(71), WindSpeedMph: floatPtr(8), WindDirection: "S", }, }, { ID: module.HourlyForecast, StanzaName: string(module.HourlyForecast), Value: briefing.HourlyForecastModule{ Periods: []briefing.HourlyForecastPeriod{ { PeriodBegins: "2026-05-29 at 9:00 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindSpeedMph: floatPtr(8), WindDirection: "S", }, { PeriodBegins: "2026-05-29 at 10:00 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), 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{ { PeriodBegins: "2026-05-29 at 10:00 AM", PeriodEnds: "2026-05-29 at 12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", }, }, 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 }