package generatedtext import ( "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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.", } ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated) if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) } if ctx.ReportTitle != "Hourly Report" { t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle) } if ctx.LocationName != "Brentwood, MO" { t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName) } if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" { t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod) } if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." { t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions) } if len(ctx.HourlyForecast) != 2 { t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast)) } if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" { t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row) } if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") { t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming) } if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" { t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts) } if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" { t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks) } if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." { t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm) } if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." { t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions) } if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." { t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory) } 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 TestBuildHourlyRenderContextRequiresCoreStanzas(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) } _, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ Summary: "Storm chances increase.", Timing: "Late morning.", Impacts: "Brief downpours.", }) if err == nil { t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error") } if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) { t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err) } } 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 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 }