package reporttemplate import ( "encoding/json" "os" "strings" "testing" ) func TestTemplateLookup(t *testing.T) { source, err := Template("hourly") if err != nil { t.Fatalf("Template() error = %v", err) } for _, want := range []string{"# {{ .Report.Title }}", "**Updated:**", `{{ template "alert_digest" . }}`, "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } } } func TestTomorrowTemplateLookup(t *testing.T) { source, err := Template("tomorrow") if err != nil { t.Fatalf("Template() error = %v", err) } for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } } } func TestDailyTemplateLookup(t *testing.T) { source, err := Template("daily") if err != nil { t.Fatalf("Template() error = %v", err) } for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } } } func TestTodayTemplateLookup(t *testing.T) { source, err := Template("today") if err != nil { t.Fatalf("Template() error = %v", err) } for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, "## Current Conditions", `{{ template "today_daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } } if strings.Contains(source, "## Planning Notes") { t.Fatalf("template includes Planning Notes section:\n%s", source) } } func TestSchemaLookup(t *testing.T) { data, err := Schema("hourly") if err != nil { t.Fatalf("Schema() error = %v", err) } assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"}) } func TestTomorrowSchemaLookup(t *testing.T) { data, err := Schema("tomorrow") if err != nil { t.Fatalf("Schema() error = %v", err) } schema := assertSchema(t, data, "summary,forecast_discussion") property, ok := schema.Properties["forecast_discussion"].(map[string]any) if !ok { t.Fatal("schema property forecast_discussion missing or invalid") } if property["type"] != "array" { t.Fatalf("forecast_discussion type = %v, want array", property["type"]) } if property["minItems"] != float64(1) { t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) } items, ok := property["items"].(map[string]any) if !ok || items["type"] != "string" { t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) } for _, field := range []string{"summary", "precipitation_timing", "confidence"} { property, ok := schema.Properties[field].(map[string]any) if !ok { t.Fatalf("schema property %q missing or invalid", field) } if property["type"] != "string" { t.Fatalf("schema property %q type = %v, want string", field, property["type"]) } } } func TestDailySchemaLookup(t *testing.T) { data, err := Schema("daily") if err != nil { t.Fatalf("Schema() error = %v", err) } schema := assertSchema(t, data, "summary,forecast_discussion") property, ok := schema.Properties["forecast_discussion"].(map[string]any) if !ok { t.Fatal("schema property forecast_discussion missing or invalid") } if property["type"] != "array" { t.Fatalf("forecast_discussion type = %v, want array", property["type"]) } if property["minItems"] != float64(1) { t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) } items, ok := property["items"].(map[string]any) if !ok || items["type"] != "string" { t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) } for _, field := range []string{"summary", "precipitation_timing", "confidence"} { property, ok := schema.Properties[field].(map[string]any) if !ok { t.Fatalf("schema property %q missing or invalid", field) } if property["type"] != "string" { t.Fatalf("schema property %q type = %v, want string", field, property["type"]) } } } func TestTodaySchemaLookup(t *testing.T) { data, err := Schema("today") if err != nil { t.Fatalf("Schema() error = %v", err) } schema := assertSchema(t, data, "summary,forecast_discussion") property, ok := schema.Properties["forecast_discussion"].(map[string]any) if !ok { t.Fatal("schema property forecast_discussion missing or invalid") } if property["type"] != "array" { t.Fatalf("forecast_discussion type = %v, want array", property["type"]) } if property["minItems"] != float64(1) { t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) } items, ok := property["items"].(map[string]any) if !ok || items["type"] != "string" { t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) } for _, field := range []string{"summary", "precipitation_timing", "confidence"} { property, ok := schema.Properties[field].(map[string]any) if !ok { t.Fatalf("schema property %q missing or invalid", field) } if property["type"] != "string" { t.Fatalf("schema property %q type = %v, want string", field, property["type"]) } } } func assertStringSchema(t *testing.T, data []byte, required string, fields []string) { t.Helper() schema := assertSchema(t, data, required) for _, field := range fields { property, ok := schema.Properties[field].(map[string]any) if !ok { t.Fatalf("schema property %q missing or invalid", field) } if property["type"] != "string" { t.Fatalf("schema property %q type = %v, want string", field, property["type"]) } } } func assertSchema(t *testing.T, data []byte, required string) struct { Type string `json:"type"` AdditionalProperties bool `json:"additionalProperties"` Required []string `json:"required"` Properties map[string]any `json:"properties"` } { t.Helper() var schema struct { Type string `json:"type"` AdditionalProperties bool `json:"additionalProperties"` Required []string `json:"required"` Properties map[string]any `json:"properties"` } if err := json.Unmarshal(data, &schema); err != nil { t.Fatalf("schema is invalid JSON: %v", err) } if schema.Type != "object" { t.Fatalf("schema type = %q, want object", schema.Type) } if schema.AdditionalProperties { t.Fatal("additionalProperties = true, want false") } if strings.Join(schema.Required, ",") != required { t.Fatalf("required = %#v, want %s", schema.Required, required) } return schema } func TestRenderHourly(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{ Title: "Hourly Report", LocationName: "Brentwood", ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM", GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM", }, GeneratedText: testGeneratedText{ 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.", }, Modules: testModules{ CurrentConditions: &testCurrentConditions{ ConditionText: "Partly cloudy", ConditionTextLower: "partly cloudy", TemperatureF: intPtr(74), ApparentTemperatureF: intPtr(76), RelativeHumidityPercent: intPtr(71), WindDirection: "S", WindDirectionText: "south", WindSpeedMph: intPtr(8), }, HourlyForecast: &testHourlyForecast{ Periods: []testHourlyPeriod{ {HourLabel: "9:00 AM", TextDescription: "Cloudy", TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(19), WindDirection: "S", WindSpeedMph: floatPtr(8)}, {HourLabel: "10:00 AM", TextDescription: "Showers", TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), MentionPrecipitation: true, WindDirection: "S", WindSpeedMph: floatPtr(10)}, }, }, PrecipTiming: &testPrecipTiming{ MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", PrecipitationWindows: []testPrecipWindow{ {PeriodBegins: "10 AM", PeriodBeginsHourLabel: "10:00 AM", PeriodEnds: "12 PM", PeriodEndsHourLabel: "12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", MaxPopHourLabel: "10:00 AM", ExpectationPhrase: "Expect showers."}, }, }, AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "May 29 at 8:00 AM", PeriodEnds: "May 29 at 2:00 PM"}}, }, SPCConvectiveDiscussion: &testSPCDiscussion{ Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}}, }, AreaForecastDiscussion: &testForecastDiscussion{ KeyMessages: []string{"Storms are most likely late morning."}, ShortTerm: "Short-term discussion favors increasing rain coverage.", }, WeatherStory: &testWeatherStory{ Available: true, Title: "Morning storms", Description: "Morning storms remain the main story.", }, }, }) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range []string{ "# Hourly Report", "**Updated:** Saturday, June 14, 2026 at 9:14 AM", "Storm chances increase through late morning.", "Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.", "## Alert Digest", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.", "- **9:00 AM:** 74°F and cloudy.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.", "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, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") { t.Fatalf("rendered template included omitted details:\n%s", text) } assertOrderedText(t, text, []string{ "# Hourly Report", "## Alert Digest", "## Current Conditions", "## Hourly Forecast", "## Precipitation Timing", "## Forecast Discussion", }) } func TestRenderTomorrow(t *testing.T) { rendered, err := Render("tomorrow", testTomorrowRenderContext{ Report: testTomorrowReportContext{ Title: "Monday's Weather", ForecastDateLabel: "Monday, June 15, 2026", GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", }, GeneratedText: testTomorrowGeneratedText{ Summary: "Tomorrow starts dry before showers return later in the day.", ForecastDiscussion: []string{ "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", }, PrecipitationTiming: "A few showers may linger into early evening.", }, Modules: testTomorrowModules{ Dayparts: []testTomorrowDaypart{ { Key: "overnight", Summary: testDaypartSummary{ DisplayName: "Overnight", TemperatureTrend: "falling", TemperatureStartPhraseF: "mid 60s", TemperatureEndPhraseF: "upper 50s", DominantConditionDisplay: "Partly cloudy", DominantConditionLower: "partly cloudy", }, }, { Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", TemperatureTrend: "rising", TemperatureStartPhraseF: "upper 50s", TemperatureEndPhraseF: "upper 60s", DominantConditionDisplay: "Sunny", DominantConditionLower: "sunny", }, }, { Key: "afternoon", Summary: testDaypartSummary{ DisplayName: "Afternoon", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "upper 70s", DominantConditionDisplay: "Sunny", DominantConditionLower: "sunny", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, }, }, PrecipTiming: &testPrecipTiming{ PrecipitationWindows: []testPrecipWindow{ {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."}, }, }, AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, }, }, }) 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:** Sunday, June 14, 2026 at 9:14 AM", "Tomorrow starts dry before showers return later in the day.", "## Alert Digest", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", "A few showers may linger into early evening.", "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } for _, unwanted := range []string{ "upper 50s.\n\n- **Morning:**", "upper 60s.\n\n- **Afternoon:**", } { if strings.Contains(text, unwanted) { t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text) } } assertOrderedText(t, text, []string{ "# Monday's Weather", "Tomorrow starts dry before showers return later in the day.", "## Alert Digest", "## Daypart Forecast", "- **Overnight:**", "- **Morning:**", "- **Afternoon:**", "## Precipitation Timing", "## Forecast Discussion", "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", }) } func TestRenderDaily(t *testing.T) { rendered, err := Render("daily", testDailyRenderContext{ Report: testDailyReportContext{ Title: "Monday's Weather", ForecastDateLabel: "Monday, June 15, 2026", GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", }, GeneratedText: testDailyGeneratedText{ Summary: "The selected day starts dry before showers return later in the day.", ForecastDiscussion: []string{ "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", }, PrecipitationTiming: "A few showers may linger into early evening.", }, Modules: testDailyModules{ Dayparts: []testDailyDaypart{ { Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", TemperatureTrend: "rising", TemperatureStartPhraseF: "upper 50s", TemperatureEndPhraseF: "upper 60s", DominantConditionDisplay: "Sunny", DominantConditionLower: "sunny", }, }, { Key: "afternoon", Summary: testDaypartSummary{ DisplayName: "Afternoon", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "upper 70s", DominantConditionDisplay: "Showers", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, }, }, PrecipTiming: &testPrecipTiming{ PrecipitationWindows: []testPrecipWindow{ {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."}, }, }, AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 3:00 PM", PeriodEnds: "June 15 at 6:00 PM", Description: "Monitor creek levels."}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ RiskDigest: []testSPCRiskDigest{{LabelText: "Enhanced Risk", RiskLabel: "Enhanced risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, }, }, }) 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:** Sunday, June 14, 2026 at 9:14 AM", "The selected day starts dry before showers return later in the day.", "## Alert Digest", "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.", "- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", "A few showers may linger into early evening.", "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } assertOrderedText(t, text, []string{ "# Monday's Weather", "The selected day starts dry before showers return later in the day.", "## Alert Digest", "## Daypart Forecast", "- **Morning:**", "- **Afternoon:**", "## Precipitation Timing", "## Forecast Discussion", }) } func TestRenderToday(t *testing.T) { rendered, err := Render("today", testTodayRenderContext{ Report: testTodayReportContext{ Title: "Today's Weather", ForecastDateLabel: "Monday, June 15, 2026", GeneratedAtLabel: "Monday, June 15, 2026 at 7:14 AM", }, GeneratedText: testTomorrowGeneratedText{ Summary: "Today starts dry before showers return later in the day.", ForecastDiscussion: []string{ "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", }, PrecipitationTiming: "A few showers may linger into early evening.", }, Modules: testTodayModules{ CurrentConditions: &testCurrentConditions{ ConditionTextLower: "clear", TemperatureF: intPtr(58), ApparentTemperatureF: intPtr(57), RelativeHumidityPercent: intPtr(61), WindDirectionText: "northwest", WindSpeedMph: intPtr(9), }, Dayparts: []testTomorrowDaypart{ { Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", TemperatureTrend: "rising", TemperatureStartPhraseF: "upper 50s", TemperatureEndPhraseF: "upper 60s", DominantConditionDisplay: "Sunny", DominantConditionLower: "sunny", }, }, { Key: "afternoon", Summary: testDaypartSummary{ DisplayName: "Afternoon", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "upper 70s", DominantConditionDisplay: "Showers", DominantConditionLower: "showers", MaxPopPercent: intPtr(70), MaxPopTimeLabel: "3:00 PM", MentionPrecipitation: true, }, }, { Key: "evening", Summary: testDaypartSummary{ DisplayName: "Evening", }, }, }, PrecipTiming: &testPrecipTiming{ PrecipitationWindows: []testPrecipWindow{ {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."}, }, }, AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, }, TodayPlanning: &testTodayPlanning{ MorningReadiness: []string{"Morning weather looks routine."}, LateDayChangeWatch: []string{"Watch late-day shower timing."}, }, }, }) 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", "**Updated:** Monday, June 15, 2026 at 7:14 AM", "Today starts dry before showers return later in the day.", "## Alert Digest", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "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:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", "A few showers may linger into early evening.", "Clouds increase after sunrise.", "Rain chances peak during the afternoon.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } assertOrderedText(t, text, []string{ "# Today's Weather", "Today starts dry before showers return later in the day.", "## Alert Digest", "## Current Conditions", "## Daypart Forecast", "- **Morning:**", "- **Afternoon:**", "## Precipitation Timing", "## Forecast Discussion", }) for _, unwanted := range []string{"## Planning Notes", "Morning weather looks routine.", "Watch late-day shower timing.", "- **Evening:**", "Forecast details are limited"} { if strings.Contains(text, unwanted) { t.Fatalf("rendered template included %q:\n%s", unwanted, text) } } } func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) { tests := []struct { name string template string context any want []string }{ { name: "daily", template: "daily", context: testDailyRenderContext{ Report: testDailyReportContext{Title: "Daily", ForecastDateLabel: "Monday, June 15, 2026"}, GeneratedText: testDailyGeneratedText{Summary: "Daily summary."}, Modules: testDailyModules{Dayparts: []testDailyDaypart{ {Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", DominantConditionDisplay: "Showers", TemperaturePhraseF: "upper 60s", }}, }}, }, want: []string{"- **Morning:** Showers, with temperatures in the upper 60s."}, }, { name: "today", template: "today", context: testTodayRenderContext{ Report: testTodayReportContext{Title: "Today", ForecastDateLabel: "Monday, June 15, 2026"}, GeneratedText: testTomorrowGeneratedText{Summary: "Today summary."}, Modules: testTodayModules{Dayparts: []testTomorrowDaypart{ {Key: "afternoon", Summary: testDaypartSummary{ DisplayName: "Afternoon", DominantConditionDisplay: "Storms", TemperaturePeakPhraseF: "low 80s", TemperatureTrend: "peaking", MaxPopPercent: intPtr(70), MentionPrecipitation: true, }}, }}, }, want: []string{"- **Afternoon:** Storms, with temperatures peaking in the low 80s. Chance of precipitation is 70%."}, }, { name: "tomorrow", template: "tomorrow", context: testTomorrowRenderContext{ Report: testTomorrowReportContext{Title: "Tomorrow", ForecastDateLabel: "Tuesday, June 16, 2026"}, GeneratedText: testTomorrowGeneratedText{Summary: "Tomorrow summary."}, Modules: testTomorrowModules{Dayparts: []testTomorrowDaypart{ {Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", DominantConditionDisplay: "Clear", TemperatureStartPhraseF: "upper 50s", TemperatureEndPhraseF: "upper 60s", TemperatureTrend: "rising", }}, }}, }, want: []string{"- **Morning:** Clear, with temperatures rising from the upper 50s to the upper 60s."}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rendered, err := Render(tt.template, tt.context) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range tt.want { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } if strings.Contains(text, "## Alert Digest") { t.Fatalf("rendered template includes empty Alerts and Risk Products section:\n%s", text) } if strings.Contains(text, "\n\n\n") { t.Fatalf("rendered template includes excess blank lines:\n%s", text) } }) } } func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) { rendered, err := Render("tomorrow", testTomorrowRenderContext{ Report: testTomorrowReportContext{ Title: "Monday's Weather", ForecastDateLabel: "Monday, June 15, 2026", GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", }, GeneratedText: testTomorrowGeneratedText{ Summary: "Dry weather is expected tomorrow.", ForecastDiscussion: []string{"High pressure keeps rain chances low."}, PrecipitationTiming: "Any stray shower chance is too low to highlight.", }, Modules: testTomorrowModules{ Dayparts: []testTomorrowDaypart{ { Key: "morning", Summary: testDaypartSummary{ DisplayName: "Morning", TemperatureTrend: "steady", TemperatureSteadyPhraseF: "low 60s", DominantConditionDisplay: "Clear", DominantConditionLower: "clear", }, }, }, PrecipTiming: &testPrecipTiming{}, }, }) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, unwanted := range []string{"## Precipitation Timing", "Any stray shower chance is too low to highlight."} { if strings.Contains(text, unwanted) { t.Fatalf("dry render includes %q:\n%s", unwanted, text) } } } func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{ Title: "Hourly Report", GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM", }, GeneratedText: testGeneratedText{ Summary: "Dry weather is expected through the next several hours.", ForecastDiscussion: "Quiet conditions should persist through midday.", }, Modules: testModules{ CurrentConditions: &testCurrentConditions{ ConditionTextLower: "cloudy", TemperatureF: intPtr(72), }, HourlyForecast: &testHourlyForecast{ Periods: []testHourlyPeriod{ {HourLabel: "9:00 AM", TextDescriptionLower: "mostly cloudy", TemperatureF: floatPtr(71), ProbabilityOfPrecipitationPercent: floatPtr(10)}, }, }, AlertDigest: &testAlertDigest{}, PrecipTiming: &testPrecipTiming{ MaxPopPercent: intPtr(10), }, }, }) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, unwanted := range []string{"## Alert Digest", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { if strings.Contains(text, unwanted) { t.Fatalf("clear render includes %q:\n%s", unwanted, text) } } if strings.Contains(text, "\n\n\n") { t.Fatalf("clear render includes excess blank lines:\n%s", text) } for _, want := range []string{ "Currently, it is 72°F and cloudy.\n\n## Hourly Forecast", "- **9:00 AM:** 71°F and mostly cloudy.\n\n## Forecast Discussion", } { if !strings.Contains(text, want) { t.Fatalf("clear render missing spacing %q:\n%s", want, text) } } } func TestRenderHourlyUsesSharedOpenEndedPrecipitationTiming(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{ Title: "Hourly Report", GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM", }, GeneratedText: testGeneratedText{ Summary: "Rain chances increase through midday.", ForecastDiscussion: "Showers may continue beyond the report period.", PrecipitationTiming: "Plan for wet roads through the end of the period.", }, Modules: testModules{ CurrentConditions: &testCurrentConditions{}, HourlyForecast: &testHourlyForecast{}, AlertDigest: &testAlertDigest{}, PrecipTiming: &testPrecipTiming{ PrecipitationWindows: []testPrecipWindow{ { PeriodBeginsHourLabel: "8:00 AM", MaxPopPercent: intPtr(60), MaxPopHourLabel: "10:00 AM", ExpectationPhrase: "Showers likely.", }, }, }, }, }) if err != nil { t.Fatalf("Render() error = %v", err) } text := string(rendered) for _, want := range []string{ "## Precipitation Timing", "- **Starting at 8:00 AM**: Showers likely. The peak precipitation chance is 60% at 10:00 AM.", "Plan for wet roads through the end of the period.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } } func TestUnknownAssetsReturnActionableErrors(t *testing.T) { if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) { t.Fatalf("Template() error = %v, want unknown template", err) } if _, err := Schema("missing"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "missing"`) { t.Fatalf("Schema() error = %v, want unknown schema", err) } if _, err := Render("missing", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) { t.Fatalf("Render() error = %v, want unknown template", err) } } func TestDailyPromptAssetExists(t *testing.T) { data, err := os.ReadFile("prompts/daily.generated_text.md") if err != nil { t.Fatalf("read Daily prompt asset: %v", err) } for _, want := range []string{"TASK:", "`summary`", "`forecast_discussion`", "`daily_planning`"} { if !strings.Contains(string(data), want) { t.Fatalf("Daily prompt asset missing %q:\n%s", want, string(data)) } } } func TestRenderFailsForMissingContextFields(t *testing.T) { _, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}}) if err == nil { t.Fatal("Render() error = nil, want missing field error") } if !strings.Contains(err.Error(), `render report template "hourly"`) { t.Fatalf("Render() error = %v, want render context", err) } } type testRenderContext struct { Report testReportContext GeneratedText testGeneratedText Modules testModules } type testTomorrowRenderContext struct { Report testTomorrowReportContext GeneratedText testTomorrowGeneratedText Modules testTomorrowModules } type testTodayRenderContext struct { Report testTodayReportContext GeneratedText testTomorrowGeneratedText Modules testTodayModules } type testDailyRenderContext struct { Report testDailyReportContext GeneratedText testDailyGeneratedText Modules testDailyModules } type testReportContext struct { Title string LocationName string ValidPeriodLabel string GeneratedAtLabel string } type testGeneratedText struct { Summary string ForecastDiscussion string PrecipitationTiming string Confidence string } type testTomorrowReportContext struct { Title string ForecastDateLabel string GeneratedAtLabel string } type testTodayReportContext struct { Title string ForecastDateLabel string GeneratedAtLabel string } type testDailyReportContext struct { Title string ForecastDateLabel string GeneratedAtLabel string } type testTomorrowGeneratedText struct { Summary string ForecastDiscussion []string PrecipitationTiming string Confidence string } type testDailyGeneratedText struct { Summary string ForecastDiscussion []string PrecipitationTiming string Confidence string } type testModules struct { CurrentConditions *testCurrentConditions HourlyForecast *testHourlyForecast PrecipTiming *testPrecipTiming AlertDigest *testAlertDigest SPCConvectiveOutlooks *testSPCOutlooks AreaForecastDiscussion *testForecastDiscussion SPCConvectiveDiscussion *testSPCDiscussion WeatherStory *testWeatherStory } type testTomorrowModules struct { Dayparts []testTomorrowDaypart PrecipTiming *testPrecipTiming AlertDigest *testAlertDigest SPCConvectiveOutlooks *testSPCOutlooks } type testTodayModules struct { CurrentConditions *testCurrentConditions Dayparts []testTomorrowDaypart PrecipTiming *testPrecipTiming AlertDigest *testAlertDigest SPCConvectiveOutlooks *testSPCOutlooks TodayPlanning *testTodayPlanning } type testDailyModules struct { Dayparts []testDailyDaypart PrecipTiming *testPrecipTiming AlertDigest *testAlertDigest SPCConvectiveOutlooks *testSPCOutlooks } type testTodayPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string OutdoorPlanning []string LateDayChangeWatch []string } type testTomorrowDaypart struct { Key string Summary testDaypartSummary } type testDailyDaypart struct { Key string Summary testDaypartSummary } type testDaypartSummary struct { DisplayName string TempRangeF string TemperaturePhraseF string TemperatureTrend string TemperatureStartPhraseF string TemperatureEndPhraseF string TemperaturePeakPhraseF string TemperatureSteadyPhraseF string MaxPopPercent *int MaxPopTime string MaxPopTimeLabel string MentionPrecipitation bool DominantCondition string DominantConditionLower string DominantConditionDisplay string } type testCurrentConditions struct { ConditionText string ConditionTextLower string TemperatureF *int ApparentTemperatureF *int RelativeHumidityPercent *int WindSpeedMph *int WindDirection string WindDirectionText string } type testHourlyForecast struct { Periods []testHourlyPeriod } type testHourlyPeriod struct { HourLabel string PeriodBegins string Name string TextDescription string TextDescriptionLower string TemperatureF *float64 WindSpeedMph *float64 WindGustMph *float64 WindDirection string ProbabilityOfPrecipitationPercent *float64 MentionPrecipitation bool } type testPrecipTiming struct { MaxPopPercent *int MaxPopTime string PrecipitationWindows []testPrecipWindow ThunderMentioned bool } type testPrecipWindow struct { PeriodBegins string PeriodBeginsHourLabel string PeriodEnds string PeriodEndsHourLabel string MaxPopPercent *int MaxPopTime string MaxPopHourLabel string PrecipitationType string ExpectationPhrase string } type testAlertDigest struct { Missing bool Relevant []testAlert } type testAlert struct { Event string Headline string Severity string PeriodBegins string PeriodEnds string Instruction string Description string } type testSPCOutlooks struct { Outlooks []testSPCOutlook RiskDigest []testSPCRiskDigest } type testSPCOutlook struct { Label string LabelText string OutlookType string PeriodBegins string PeriodEnds string } type testSPCRiskDigest struct { LabelText string RiskLabel string PeriodBegins string PeriodEnds string } type testForecastDiscussion struct { KeyMessages []string ShortTerm string } type testSPCDiscussion struct { Discussions []testSPCDiscussionRecord } type testSPCDiscussionRecord struct { Headline string Summary string Discussion string } type testWeatherStory struct { Available bool Title string Description string } 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 } }