1014 lines
32 KiB
Go
1014 lines
32 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"},
|
|
},
|
|
},
|
|
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 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"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
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**: Precipitation is expected during this period. 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"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
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**: Precipitation is expected during this period. 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"},
|
|
},
|
|
},
|
|
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**: Precipitation is expected during this period. 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|