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 }}", "## Summary", "## Hourly Forecast", "## Weather Story"} { 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,timing,impacts" { t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required) } for _, field := range []string{"summary", "timing", "impacts", "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: "May 29, 8:30 AM", }, GeneratedText: testGeneratedText{ Summary: "Storm chances increase through late morning.", Timing: "The main window is 10 AM to noon.", Impacts: "Brief downpours may slow travel.", Confidence: "Medium confidence in timing.", }, Modules: testModules{ CurrentConditions: &testCurrentConditions{ ConditionText: "Partly cloudy", TemperatureF: floatPtr(74), ApparentTemperatureF: floatPtr(76), RelativeHumidityPercent: floatPtr(71), WindDirection: "S", WindSpeedMph: floatPtr(8), }, HourlyForecast: &testHourlyForecast{ Periods: []testHourlyPeriod{ {PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)}, {PeriodBegins: "10 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), WindDirection: "S", WindSpeedMph: floatPtr(10)}, }, }, PrecipTiming: &testPrecipTiming{ MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", PrecipitationWindows: []testPrecipWindow{ {PeriodBegins: "10 AM", PeriodEnds: "12 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 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", "Valid: May 29, 8:30 AM to 2:30 PM", "Storm chances increase through late morning.", "## Confidence", "- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph", "- Flood Watch: Flood Watch until 2:30 PM (Moderate)", "Short-term discussion favors increasing rain coverage.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } assertOrderedText(t, text, []string{ "# Hourly Report", "## Summary", "## Timing", "## Impacts", "## Confidence", "## Current Conditions", "## Hourly Forecast", "## Precipitation Timing", "## Alerts", "## SPC Outlooks", "## Forecast Discussion", "## SPC Discussion", "## Weather Story", }) } 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 Timing string Impacts 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 TemperatureF *float64 ApparentTemperatureF *float64 RelativeHumidityPercent *float64 WindSpeedMph *float64 WindDirection string } type testHourlyForecast struct { Periods []testHourlyPeriod } type testHourlyPeriod struct { PeriodBegins string Name string TextDescription string TemperatureF *float64 WindSpeedMph *float64 WindGustMph *float64 WindDirection string ProbabilityOfPrecipitationPercent *float64 } type testPrecipTiming struct { MaxPopPercent *int MaxPopTime string PrecipitationWindows []testPrecipWindow ThunderMentioned bool } type testPrecipWindow struct { PeriodBegins string PeriodEnds string MaxPopPercent *int MaxPopTime 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 } }