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.", } 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.CurrentConditions.ConditionTextLower != "partly cloudy" || ctx.Modules.CurrentConditions.WindDirectionText != "south" { t.Fatalf("Modules.CurrentConditions helpers = %#v, want rich template helper fields", 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 period := ctx.Modules.HourlyForecast.Periods[1]; period.HourLabel != "10:00 AM" || period.TextDescriptionLower != "showers" || !period.MentionPrecipitation { t.Fatalf("Modules.HourlyForecast.Periods[1] helpers = %#v, want rich template helper fields", 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 in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.", "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) } } if strings.Contains(text, "Avoid low-water crossings.") { t.Fatalf("rendered template includes alert instruction:\n%s", 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.CurrentConditions == nil { t.Fatal("Modules.CurrentConditions = nil, want populated module") } if ctx.Modules.HourlyForecast != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.AlertDigest != nil { t.Fatalf("Modules = %#v, want omitted optional modules to remain nil", ctx.Modules) } } func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) { snapshot, err := module.NewSnapshot([]module.Output{ {ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: "not a current conditions stanza"}, }) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } _, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ Summary: "Storm chances increase.", ForecastDiscussion: "A front will keep the region unsettled.", }, testCollected(), facts.DerivedFacts{}) if err == nil { t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error") } if !strings.Contains(err.Error(), string(module.CurrentConditions)) { t.Fatalf("BuildHourlyRenderContext() error = %v, want module ID", err) } } func TestBuildTodayRenderContext(t *testing.T) { metadata := testTodayMetadata() snapshot := testTodaySnapshot(t) generated := Today{ Summary: "Today 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 := testTodayDerived() ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, collected, derived) if err != nil { t.Fatalf("BuildTodayRenderContext() error = %v", err) } if ctx.Report.Title != "Today's Weather" { t.Fatalf("Report.Title = %q, want Today'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 != "Monday, June 15, 2026 at 7: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.Today { t.Fatalf("Modules.Metadata = %#v, want today 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 morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { t.Fatalf("today rich morning daypart helpers = %#v, want render-context helper fields", morning) } if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { t.Fatalf("today rich afternoon daypart = %#v, want max pop time label helper", afternoon) } 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.TodayPlanning == nil { t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) } if ctx.Modules.TodayPlanning.MorningReadiness[0] != "Take sunglasses early." { t.Fatalf("Modules.TodayPlanning = %#v, want today planning facts", ctx.Modules.TodayPlanning) } 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("today", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range []string{ "# Today's Weather", "**Forecast date:** Monday, June 15, 2026", "Today starts quiet, then showers become more likely later in the day.", "Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.", "- **Morning:** Partly cloudy, with temperatures in the low 60s.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "## Precipitation Timing", "- **3:00 PM** to **6:00 PM**: Expect showers. 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{ "# Today's Weather", "## Current Conditions", "## Daypart Forecast", "- **Morning:**", "- **Afternoon:**", "## Precipitation Timing", "## Forecast Discussion", }) if strings.Contains(text, "## Planning Notes") || strings.Contains(text, "- Take sunglasses early.") || strings.Contains(text, "Forecast details are limited") { t.Fatalf("rendered template included removed Today template content:\n%s", text) } } func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) { metadata := testTodayMetadata() snapshot := testTodaySnapshot(t) for i := range snapshot.Outputs { if snapshot.Outputs[i].ID != module.DerivedDaypartSummaries { continue } dayparts := snapshot.Outputs[i].Value.(map[string]briefing.DerivedDaypartSummaryModule) morning := dayparts["morning"] morning.DisplayName = "Mañana" dayparts["morning"] = morning } ctx, err := BuildTodayRenderContext(metadata, snapshot, Today{ Summary: "Today summary.", ForecastDiscussion: []string{"Morning conditions stay quiet."}, }, testCollected(), testTodayDerived()) if err != nil { t.Fatalf("BuildTodayRenderContext() error = %v", err) } rendered, err := reporttemplate.Render("today", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } if !strings.Contains(string(rendered), "Mañana") { t.Fatalf("rendered report = %q, want Unicode daypart display name", rendered) } } func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) { snapshot, err := module.NewSnapshot(nil) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{ Summary: "Dry weather is expected today.", ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, }, testCollected(), facts.DerivedFacts{}) if err != nil { t.Fatalf("BuildTodayRenderContext() error = %v", err) } if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 || ctx.Modules.TodayPlanning != nil { t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) } rendered, err := reporttemplate.Render("today", ctx) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, unwanted := range []string{"## Current Conditions", "## Precipitation Timing", "## Planning Notes", "Forecast details are limited"} { if strings.Contains(text, unwanted) { t.Fatalf("rendered template included %q without module data:\n%s", unwanted, text) } } if !strings.Contains(text, "- No daypart forecast details are available.") { t.Fatalf("rendered template missing daypart fallback:\n%s", text) } } func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *testing.T) { type result struct { title string forecastDateLabel string generatedAtLabel string timezone string validPeriod timeutil.Period metadata bool current bool hourly bool dailySummary bool daypartSummaries bool dayparts int precip bool alerts bool outlooks bool discussion bool spcDiscussion bool story bool planning bool collected bool derived bool } tests := []struct { name string wantTitle string wantGeneratedLabel string wantDayparts int build func(t *testing.T) result }{ { name: "daily", wantTitle: "Monday's Weather", wantGeneratedLabel: "Saturday, June 13, 2026 at 9:14 AM", wantDayparts: 3, build: func(t *testing.T) result { t.Helper() collected := testCollected() derived := testDailyDerived() ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{ Summary: "Daily summary.", ForecastDiscussion: []string{"Daily discussion."}, }, collected, derived) if err != nil { t.Fatalf("BuildDailyRenderContext() error = %v", err) } return result{ title: ctx.Report.Title, forecastDateLabel: ctx.Report.ForecastDateLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel, timezone: ctx.Report.Timezone, validPeriod: ctx.Report.ValidPeriod, metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily, current: ctx.Modules.CurrentConditions != nil, hourly: ctx.Modules.HourlyForecast != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil, daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, dayparts: len(ctx.Modules.Dayparts), precip: ctx.Modules.PrecipTiming != nil, alerts: ctx.Modules.AlertDigest != nil, outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, discussion: ctx.Modules.AreaForecastDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, story: ctx.Modules.WeatherStory != nil, planning: ctx.Modules.DailyPlanning != nil, collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), } }, }, { name: "today", wantTitle: "Today's Weather", wantGeneratedLabel: "Monday, June 15, 2026 at 7:14 AM", wantDayparts: 2, build: func(t *testing.T) result { t.Helper() collected := testCollected() derived := testTodayDerived() ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{ Summary: "Today summary.", ForecastDiscussion: []string{"Today discussion."}, }, collected, derived) if err != nil { t.Fatalf("BuildTodayRenderContext() error = %v", err) } return result{ title: ctx.Report.Title, forecastDateLabel: ctx.Report.ForecastDateLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel, timezone: ctx.Report.Timezone, validPeriod: ctx.Report.ValidPeriod, metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today, current: ctx.Modules.CurrentConditions != nil, hourly: ctx.Modules.HourlyForecast != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil, daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, dayparts: len(ctx.Modules.Dayparts), precip: ctx.Modules.PrecipTiming != nil, alerts: ctx.Modules.AlertDigest != nil, outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, discussion: ctx.Modules.AreaForecastDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, story: ctx.Modules.WeatherStory != nil, planning: ctx.Modules.TodayPlanning != nil, collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), } }, }, { name: "tomorrow", wantTitle: "Monday's Weather", wantGeneratedLabel: "Sunday, June 14, 2026 at 9:14 AM", wantDayparts: 2, build: func(t *testing.T) result { t.Helper() collected := testCollected() derived := testTomorrowDerived() ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{ Summary: "Tomorrow summary.", ForecastDiscussion: []string{"Tomorrow discussion."}, }, collected, derived) if err != nil { t.Fatalf("BuildTomorrowRenderContext() error = %v", err) } return result{ title: ctx.Report.Title, forecastDateLabel: ctx.Report.ForecastDateLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel, timezone: ctx.Report.Timezone, validPeriod: ctx.Report.ValidPeriod, metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow, current: ctx.Modules.CurrentConditions != nil, hourly: ctx.Modules.HourlyForecast != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil, daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, dayparts: len(ctx.Modules.Dayparts), precip: ctx.Modules.PrecipTiming != nil, alerts: ctx.Modules.AlertDigest != nil, outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, discussion: ctx.Modules.AreaForecastDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, story: ctx.Modules.WeatherStory != nil, planning: ctx.Modules.TomorrowPlanning != nil, collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), } }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { result := test.build(t) if result.title != test.wantTitle { t.Fatalf("title = %q, want %q", result.title, test.wantTitle) } if result.forecastDateLabel != "Monday, June 15, 2026" { t.Fatalf("forecastDateLabel = %q, want Monday, June 15, 2026", result.forecastDateLabel) } if result.generatedAtLabel != test.wantGeneratedLabel { t.Fatalf("generatedAtLabel = %q, want %q", result.generatedAtLabel, test.wantGeneratedLabel) } if result.timezone != "America/Chicago" { t.Fatalf("timezone = %q, want America/Chicago", result.timezone) } if !result.validPeriod.IsValid() { t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod) } if !result.metadata || !result.current || !result.hourly || !result.dailySummary || !result.daypartSummaries || !result.precip || !result.alerts || !result.outlooks || !result.discussion || !result.spcDiscussion || !result.story { t.Fatalf("common modules = %#v, want all shared modules populated", result) } if result.dayparts != test.wantDayparts { t.Fatalf("dayparts = %d, want %d", result.dayparts, test.wantDayparts) } if !result.planning { t.Fatalf("planning = false, want %s planning module populated", test.name) } if !result.collected || !result.derived { t.Fatalf("facts passthrough = collected:%t derived:%t, want both true", result.collected, result.derived) } }) } } func TestBuildDailyRenderContext(t *testing.T) { metadata := testDailyMetadata() snapshot := testDailySnapshot(t) generated := Daily{ Summary: "The selected day 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 := testDailyDerived() ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived) if err != nil { t.Fatalf("BuildDailyRenderContext() 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 != "Saturday, June 13, 2026 at 9:14 AM" { t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) } if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" { t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone) } if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Daily { t.Fatalf("Modules.Metadata = %#v, want daily 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) != 3 { t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries) } if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" { t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", ctx.Modules.Dayparts) } if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { t.Fatalf("daily rich morning daypart helpers = %#v, want render-context helper fields", morning) } if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { t.Fatalf("daily rich afternoon daypart = %#v, want max pop time label helper", afternoon) } 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.OutdoorWindows == nil || ctx.Modules.DailyPlanning == nil { t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) } if ctx.Modules.OutdoorWindows.Best == nil || ctx.Modules.OutdoorWindows.Best.Daypart != "morning" { t.Fatalf("Modules.OutdoorWindows = %#v, want morning best window", ctx.Modules.OutdoorWindows) } if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." { t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning) } 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("daily", 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", "**Updated:** Saturday, June 13, 2026 at 9:14 AM", "The selected day starts quiet, then showers become more likely later in the day.", "- **Morning:** Partly cloudy, with temperatures in the low 60s.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "- **Evening:** Mostly cloudy, with temperatures in the upper 60s.", "## Precipitation Timing", "- **3:00 PM** to **6:00 PM**: Expect showers. 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:**", "- **Evening:**", "## Precipitation Timing", "## Forecast Discussion", }) } func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) { snapshot, err := module.NewSnapshot(nil) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{ Summary: "Dry weather is expected for the selected day.", ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, }, testCollected(), facts.DerivedFacts{}) if err != nil { t.Fatalf("BuildDailyRenderContext() error = %v", err) } if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 { t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) } rendered, err := reporttemplate.Render("daily", 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 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 morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { t.Fatalf("tomorrow rich morning daypart helpers = %#v, want render-context helper fields", morning) } if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { t.Fatalf("tomorrow rich afternoon daypart = %#v, want max pop time label helper", afternoon) } 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:** Partly cloudy, with temperatures in the low 60s.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "## Precipitation Timing", "- **3:00 PM** to **6:00 PM**: Expect showers. 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.Metadata != nil || ctx.Modules.CurrentConditions != nil || 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.PreparedIdentity { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) return briefing.PreparedIdentity{ 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.PreparedIdentity { generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC) return briefing.PreparedIdentity{ 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 testDailyMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC) return briefing.PreparedIdentity{ RunID: "run-daily", ReportID: report.Daily, PromptID: "weather.daily_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 testTodayMetadata() briefing.PreparedIdentity { generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC) return briefing.PreparedIdentity{ RunID: "run-today", ReportID: report.Today, PromptID: "weather.today_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 { return testCivilDayDerived() } func testDailyDerived() facts.DerivedFacts { return testCivilDayDerived() } func testTodayDerived() facts.DerivedFacts { return testCivilDayDerived() } func testCivilDayDerived() 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 testTodaySnapshot(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-today", ReportID: report.Today, PromptID: "weather.today_generated_text", GeneratedAt: testTodayMetadata().GeneratedAt, Units: "imperial", Timezone: "America/Chicago", ValidPeriod: testTodayMetadata().ValidPeriod, }, }, { ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{ ConditionTextLower: "clear", TemperatureF: intPtr(58), ApparentTemperatureF: intPtr(57), RelativeHumidityPercent: intPtr(61), WindSpeedMph: intPtr(9), WindDirectionText: "northwest", }, }, { 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", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "mid 70s", DominantConditionDisplay: "Showers", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, "morning": { DisplayName: "Morning", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "low 60s", DominantConditionDisplay: "Partly cloudy", 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", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."}, }, }, }, { 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.TodayPlanning, StanzaName: string(module.TodayPlanning), Value: briefing.TodayPlanningModule{ MorningReadiness: []string{"Take sunglasses early."}, OutdoorPlanning: []string{"Best outdoor window: Morning (quiet weather)."}, }, }, }) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } return snapshot } func testDailySnapshot(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-daily", ReportID: report.Daily, PromptID: "weather.daily_generated_text", GeneratedAt: testDailyMetadata().GeneratedAt, Units: "imperial", Timezone: "America/Chicago", ValidPeriod: testDailyMetadata().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", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "mid 70s", DominantConditionDisplay: "Showers", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, "evening": { DisplayName: "Evening", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "upper 60s", DominantConditionDisplay: "Mostly cloudy", DominantConditionLower: "mostly cloudy", }, "morning": { DisplayName: "Morning", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "low 60s", DominantConditionDisplay: "Partly cloudy", 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", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."}, }, }, }, { 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.OutdoorWindows, StanzaName: string(module.OutdoorWindows), Value: briefing.OutdoorWindowsModule{ Best: &briefing.OutdoorWindowModule{ Daypart: "morning", Reasons: []string{"quiet weather"}, }, Worst: &briefing.OutdoorWindowModule{ Daypart: "afternoon", Reasons: []string{"high precipitation chance"}, }, }, }, { ID: module.DailyPlanning, StanzaName: string(module.DailyPlanning), Value: briefing.DailyPlanningModule{ MorningReadiness: []string{"Take sunglasses early."}, }, }, }) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } return snapshot } 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", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}, }, }, }, { 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", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "mid 70s", DominantConditionDisplay: "Showers", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, "morning": { DisplayName: "Morning", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "low 60s", DominantConditionDisplay: "Partly cloudy", 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", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."}, }, }, }, { 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 } }