Files
weatherreporter/internal/reporttemplate/reporttemplate_test.go

1069 lines
34 KiB
Go

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:**", "## 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 "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 "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:**", "## 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"}},
},
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**: 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",
"## Current Conditions",
"## Active Alerts",
"## 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."},
},
},
},
})
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.",
"- **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",
"## 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."},
},
},
},
})
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.",
"- **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",
"## 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."},
},
},
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.",
"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",
"## 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)
}
}
})
}
}
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{"## 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)
}
}
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
}
type testTodayModules struct {
CurrentConditions *testCurrentConditions
Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming
TodayPlanning *testTodayPlanning
}
type testDailyModules struct {
Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming
}
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
}
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
}
}