package reporttemplate import ( "encoding/json" "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:**", "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } } } func TestSchemaLookup(t *testing.T) { data, err := Schema("hourly") if err != nil { t.Fatalf("Schema() error = %v", err) } 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, ",") != "summary,forecast_discussion" { t.Fatalf("required = %#v, want summary/forecast_discussion", schema.Required) } for _, field := range []string{"summary", "forecast_discussion", "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 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: floatPtr(74), ApparentTemperatureF: floatPtr(76), RelativeHumidityPercent: floatPtr(71), WindDirection: "S", WindDirectionText: "south", WindSpeedMph: floatPtr(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"}, }, }, AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 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.", "## Active Alerts", "- **Flood Watch**: Flood Watch until 2:30 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**: Precipitation is expected during this period. 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", "## Current Conditions", "## Active Alerts", "## Hourly Forecast", "## Precipitation Timing", "## Forecast Discussion", }) } 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: floatPtr(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{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { if strings.Contains(text, unwanted) { t.Fatalf("clear render includes %q:\n%s", unwanted, text) } } } func TestUnknownAssetsReturnActionableErrors(t *testing.T) { if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { t.Fatalf("Template() error = %v, want unknown template", err) } if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) { t.Fatalf("Schema() error = %v, want unknown schema", err) } if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { t.Fatalf("Render() error = %v, want unknown template", err) } } 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 testReportContext struct { Title string LocationName string ValidPeriodLabel string GeneratedAtLabel string } type testGeneratedText 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 testCurrentConditions struct { ConditionText string ConditionTextLower string TemperatureF *float64 ApparentTemperatureF *float64 RelativeHumidityPercent *float64 WindSpeedMph *float64 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 } type testAlertDigest struct { Missing bool Relevant []testAlert } type testAlert struct { Event string Headline string Severity string } type testSPCOutlooks struct { Outlooks []testSPCOutlook } type testSPCOutlook struct { Label string LabelText string OutlookType 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 } }