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.", ForecastDiscussion: "A front will keep the region unsettled.", PrecipitationTiming: "A cold front is moving into the region.", 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.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" { t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) } 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", "**Updated:** Friday, May 29, 2026 at 8:30 AM", "Storm chances increase through late morning.", "- **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.", } { 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.", ForecastDiscussion: "A front will keep the region unsettled.", }, 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 TestBuildTomorrowRenderContext(t *testing.T) { metadata := testTomorrowMetadata() snapshot := testTomorrowSnapshot(t) generated := Tomorrow{ Summary: "Tomorrow starts quiet, then showers become more likely later in the day.", ForecastDiscussion: []string{ "Morning conditions should stay mostly dry.", "Rain chances increase during the afternoon as deeper moisture arrives.", }, PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.", } collected := testCollected() derived := testTomorrowDerived() ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived) if err != nil { t.Fatalf("BuildTomorrowRenderContext() error = %v", err) } if ctx.Report.Title != "Monday's Weather" { t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title) } if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" { t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName) } if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" { t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) } if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow { t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata) } if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast) } if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 { t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary) } if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 { t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries) } if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" { t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts) } if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 { t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming) } if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil { t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) } if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) { t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt) } if len(ctx.Derived.DaypartSummaries) != 2 { t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries) } rendered, err := reporttemplate.Render("tomorrow", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range []string{ "# Monday's Weather", "**Forecast date:** Monday, June 15, 2026", "Tomorrow starts quiet, then showers become more likely later in the day.", "- **Morning:** low 60s and partly cloudy.", "- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.", "## Precipitation Timing", "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "The most likely rain window is from midafternoon into early evening.", "Morning conditions should stay mostly dry.", "Rain chances increase during the afternoon as deeper moisture arrives.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } assertOrderedText(t, text, []string{ "# Monday's Weather", "## Daypart Forecast", "- **Morning:**", "- **Afternoon:**", "## Precipitation Timing", "## Forecast Discussion", }) } func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { snapshot, err := module.NewSnapshot(nil) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{ Summary: "Dry weather is expected tomorrow.", ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, }, testCollected(), facts.DerivedFacts{}) if err != nil { t.Fatalf("BuildTomorrowRenderContext() error = %v", err) } if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 { t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) } rendered, err := reporttemplate.Render("tomorrow", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) if strings.Contains(text, "## Precipitation Timing") { t.Fatalf("rendered template included precipitation section without windows:\n%s", text) } if !strings.Contains(text, "- No daypart forecast details are available.") { t.Fatalf("rendered template missing daypart fallback:\n%s", text) } } 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 testTomorrowMetadata() briefing.Metadata { generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC) return briefing.Metadata{ RunID: "run-tomorrow", ReportID: report.Tomorrow, PromptID: "weather.tomorrow_generated_text", GeneratedAt: generatedAt, Units: "imperial", Timezone: "America/Chicago", ValidPeriod: timeutil.Period{ Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC), }, 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 testTomorrowDerived() facts.DerivedFacts { morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC) afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC) return facts.DerivedFacts{ DaypartSummaries: []forecast.DaypartSummary{ { Name: "morning", Period: timeutil.Period{ Start: morningStart, End: afternoonStart, }, }, { Name: "afternoon", Period: timeutil.Period{ Start: afternoonStart, End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC), }, }, }, } } 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", ConditionTextLower: "partly cloudy", TemperatureF: intPtr(74), ApparentTemperatureF: intPtr(76), RelativeHumidityPercent: intPtr(71), WindSpeedMph: intPtr(8), WindDirection: "S", WindDirectionText: "south", }, }, { ID: module.HourlyForecast, StanzaName: string(module.HourlyForecast), Value: briefing.HourlyForecastModule{ Periods: []briefing.HourlyForecastPeriod{ { HourLabel: "9:00 AM", PeriodBegins: "2026-05-29 at 9:00 AM", TextDescription: "Cloudy", TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), MentionPrecipitation: true, WindSpeedMph: floatPtr(8), WindDirection: "S", }, { HourLabel: "10:00 AM", PeriodBegins: "2026-05-29 at 10:00 AM", TextDescription: "Showers", TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), MentionPrecipitation: true, 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", 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", }, }, 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 testTomorrowSnapshot(t *testing.T) module.Snapshot { t.Helper() snapshot, err := module.NewSnapshot([]module.Output{ { ID: module.Metadata, StanzaName: string(module.Metadata), Value: briefing.MetadataModule{ RunID: "run-tomorrow", ReportID: report.Tomorrow, PromptID: "weather.tomorrow_generated_text", GeneratedAt: testTomorrowMetadata().GeneratedAt, Units: "imperial", Timezone: "America/Chicago", ValidPeriod: testTomorrowMetadata().ValidPeriod, }, }, { ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{ ConditionTextLower: "clear", TemperatureF: intPtr(58), }, }, { ID: module.HourlyForecast, StanzaName: string(module.HourlyForecast), Value: briefing.HourlyForecastModule{ Periods: []briefing.HourlyForecastPeriod{ {HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)}, }, }, }, { ID: module.DerivedDailySummary, StanzaName: string(module.DerivedDailySummary), Value: briefing.DerivedDailySummaryModule{ Date: "Monday, June 15, 2026", HighTempF: intPtr(74), LowTempF: intPtr(61), DailyPrecipitationProbability: intPtr(70), }, }, { ID: module.DerivedDaypartSummaries, StanzaName: string(module.DerivedDaypartSummaries), Value: map[string]briefing.DerivedDaypartSummaryModule{ "afternoon": { DisplayName: "Afternoon", TemperaturePhraseF: "mid 70s", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, "morning": { DisplayName: "Morning", TemperaturePhraseF: "low 60s", DominantConditionLower: "partly cloudy", }, }, }, { ID: module.PrecipTiming, StanzaName: string(module.PrecipTiming), Value: briefing.PrecipTimingModule{ MaxPopPercent: intPtr(70), MaxPopTime: "3 PM", PrecipitationWindows: []briefing.PrecipitationWindowModule{ {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, }, }, }, { ID: module.AlertDigest, StanzaName: string(module.AlertDigest), Value: briefing.AlertDigestModule{ Checked: true, }, }, { ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: briefing.SPCConvectiveOutlooksModule{ Checked: true, }, }, { ID: module.AreaForecastDiscussion, StanzaName: string(module.AreaForecastDiscussion), Value: briefing.AreaForecastDiscussionModule{ KeyMessages: []string{"Rain chances increase late."}, }, }, { ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: briefing.SPCConvectiveDiscussionModule{ IncludedBecause: "convective outlook", }, }, { ID: module.WeatherStory, StanzaName: string(module.WeatherStory), Value: briefing.WeatherStoryModule{ Available: true, Title: "Rain returns", }, }, { ID: module.TomorrowPlanning, StanzaName: string(module.TomorrowPlanning), Value: briefing.TomorrowPlanningModule{ MorningReadiness: []string{"Take sunglasses early."}, }, }, }) 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 } func assertOrderedText(t *testing.T, text string, ordered []string) { t.Helper() previousIndex := -1 for _, want := range ordered { index := strings.Index(text, want) if index < 0 { t.Fatalf("text missing %q:\n%s", want, text) } if index <= previousIndex { t.Fatalf("%q appears out of order in:\n%s", want, text) } previousIndex = index } }