672 lines
29 KiB
Go
672 lines
29 KiB
Go
package forecast
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
bundle := testBundle(location)
|
|
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
|
dayparts := []DaypartDefinition{
|
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
|
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
|
{Name: "evening", Start: "18:00", End: "24:00"},
|
|
}
|
|
|
|
summary, err := BuildDailySummary(bundle, date, location, dayparts)
|
|
if err != nil {
|
|
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
}
|
|
|
|
if len(summary.Dayparts) != 4 {
|
|
t.Fatalf("Dayparts length = %d, want 4", len(summary.Dayparts))
|
|
}
|
|
morning := summary.Dayparts[1]
|
|
if len(morning.HourlyPeriods) != 2 {
|
|
t.Fatalf("morning periods = %d, want 2", len(morning.HourlyPeriods))
|
|
}
|
|
assertRange(t, "morning temperature", morning.Temperature, 58, 72)
|
|
if morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 70 {
|
|
t.Fatalf("morning max precip = %#v, want 70", morning.MaxPrecipitationProbability)
|
|
}
|
|
if morning.PeakWindGust == nil || morning.PeakWindGust.Value != 40 {
|
|
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
|
}
|
|
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
|
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
|
|
}
|
|
if !morning.Indicators.Wind {
|
|
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
|
}
|
|
|
|
afternoon := summary.Dayparts[2]
|
|
assertRange(t, "afternoon apparent", afternoon.ApparentTemperature, 100, 100)
|
|
if !afternoon.Indicators.Heat {
|
|
t.Fatalf("afternoon indicators = %#v, want heat", afternoon.Indicators)
|
|
}
|
|
if len(summary.NarrativePeriods) != 1 {
|
|
t.Fatalf("NarrativePeriods length = %d, want 1", len(summary.NarrativePeriods))
|
|
}
|
|
if len(summary.AlertOverlaps) != 1 {
|
|
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
|
}
|
|
if len(morning.AlertOverlaps) != 1 {
|
|
t.Fatalf("morning AlertOverlaps length = %d, want 1", len(morning.AlertOverlaps))
|
|
}
|
|
|
|
if _, err := json.Marshal(summary.Dayparts); err != nil {
|
|
t.Fatalf("daypart summaries are not JSON inspectable: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|
data, err := os.ReadFile(filepath.Join("testdata", "daily_bundle.json"))
|
|
if err != nil {
|
|
t.Fatalf("read fixture bundle: %v", err)
|
|
}
|
|
var bundle weatherdata.Bundle
|
|
if err := json.Unmarshal(data, &bundle); err != nil {
|
|
t.Fatalf("decode fixture bundle: %v", err)
|
|
}
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
summary, err := BuildDailySummary(&bundle, mustParse("2026-05-29T12:00:00-05:00"), location, []DaypartDefinition{
|
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
|
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
}
|
|
if len(summary.Dayparts) != 2 {
|
|
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
|
}
|
|
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
|
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
|
}
|
|
if len(summary.AlertOverlaps) != 1 {
|
|
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
|
}
|
|
}
|
|
|
|
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
|
bundle := &weatherdata.Bundle{
|
|
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
|
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
|
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
|
}},
|
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
|
json.RawMessage(`{"event":"Overnight Advisory","onset":"2026-05-30T01:00:00-05:00","ends":"2026-05-30T05:00:00-05:00"}`),
|
|
json.RawMessage(`{"event":"After Night","onset":"2026-05-30T06:00:00-05:00","ends":"2026-05-30T07:00:00-05:00"}`),
|
|
}},
|
|
}
|
|
|
|
summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{
|
|
{Name: "night", Start: "22:00", End: "06:00"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
}
|
|
night := summary.Dayparts[0]
|
|
if len(night.HourlyPeriods) != 2 {
|
|
t.Fatalf("night periods = %d, want 2", len(night.HourlyPeriods))
|
|
}
|
|
if !night.Indicators.Snow || !night.Indicators.Fog || !night.Indicators.Cold {
|
|
t.Fatalf("night indicators = %#v, want snow, fog, and cold", night.Indicators)
|
|
}
|
|
if len(summary.AlertOverlaps) != 0 {
|
|
t.Fatalf("daily AlertOverlaps = %#v, want next-day alerts excluded", summary.AlertOverlaps)
|
|
}
|
|
if len(night.AlertOverlaps) != 1 || night.AlertOverlaps[0].Event != "Overnight Advisory" {
|
|
t.Fatalf("night AlertOverlaps = %#v, want complete overnight overlap", night.AlertOverlaps)
|
|
}
|
|
if night.AlertOverlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-30T01:00:00-05:00" || night.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-30T05:00:00-05:00" {
|
|
t.Fatalf("night alert overlap = %#v, want source alert clipped to the overnight window", night.AlertOverlaps[0].Overlap)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeDaypartNormalizesMetricFallbacks(t *testing.T) {
|
|
period := timeutil.Period{Start: mustParse("2026-05-29T06:00:00-05:00"), End: mustParse("2026-05-29T07:00:00-05:00")}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
temperature float64
|
|
windSpeed float64
|
|
windGust float64
|
|
want Indicators
|
|
}{
|
|
{name: "below thresholds", temperature: 94, windSpeed: 24, windGust: 34},
|
|
{name: "at thresholds", temperature: 95, windSpeed: 25, windGust: 35, want: Indicators{Heat: true, Wind: true}},
|
|
{name: "above thresholds", temperature: 96, windSpeed: 26, windGust: 36, want: Indicators{Heat: true, Wind: true}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature), WindSpeedMph: ptr(tt.windSpeed), WindGustMph: ptr(tt.windGust)}
|
|
metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9), WindSpeedKmh: ptr(tt.windSpeed * 1.609344), WindGustKmh: ptr(tt.windGust * 1.609344)}
|
|
both := metric
|
|
both.TemperatureF = ptr(tt.temperature)
|
|
both.WindSpeedMph = ptr(tt.windSpeed)
|
|
both.WindGustMph = ptr(tt.windGust)
|
|
both.TemperatureC = ptr(-40)
|
|
both.WindSpeedKmh = ptr(0)
|
|
both.WindGustKmh = ptr(0)
|
|
|
|
for _, source := range []struct {
|
|
name string
|
|
period weatherdata.ForecastPeriod
|
|
}{
|
|
{name: "us customary", period: us},
|
|
{name: "metric", period: metric},
|
|
{name: "both", period: both},
|
|
} {
|
|
t.Run(source.name, func(t *testing.T) {
|
|
if got := numericIndicators(source.period); got != tt.want {
|
|
t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
temperature float64
|
|
want Indicators
|
|
}{
|
|
{name: "cold above threshold", temperature: 33},
|
|
{name: "cold at threshold", temperature: 32, want: Indicators{Cold: true}},
|
|
{name: "cold below threshold", temperature: 31, want: Indicators{Cold: true}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature)}
|
|
metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9)}
|
|
both := metric
|
|
both.TemperatureF = ptr(tt.temperature)
|
|
both.TemperatureC = ptr(40)
|
|
|
|
for _, source := range []struct {
|
|
name string
|
|
period weatherdata.ForecastPeriod
|
|
}{
|
|
{name: "us customary", period: us},
|
|
{name: "metric", period: metric},
|
|
{name: "both", period: both},
|
|
} {
|
|
t.Run(source.name, func(t *testing.T) {
|
|
if got := numericIndicators(source.period); got != tt.want {
|
|
t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
us := weatherdata.ForecastPeriod{
|
|
StartTime: period.Start,
|
|
EndTime: period.End,
|
|
TemperatureF: ptr(68),
|
|
TemperatureFMin: ptr(50),
|
|
TemperatureFMax: ptr(86),
|
|
ApparentTemperatureF: ptr(69.8),
|
|
WindSpeedMph: ptr(31.068559),
|
|
WindGustMph: ptr(37.282271),
|
|
}
|
|
metric := weatherdata.ForecastPeriod{
|
|
StartTime: period.Start,
|
|
EndTime: period.End,
|
|
TemperatureC: ptr(20),
|
|
TemperatureCMin: ptr(10),
|
|
TemperatureCMax: ptr(30),
|
|
ApparentTemperatureC: ptr(21),
|
|
WindSpeedKmh: ptr(50),
|
|
WindGustKmh: ptr(60),
|
|
}
|
|
both := metric
|
|
both.TemperatureF = us.TemperatureF
|
|
both.TemperatureFMin = us.TemperatureFMin
|
|
both.TemperatureFMax = us.TemperatureFMax
|
|
both.ApparentTemperatureF = us.ApparentTemperatureF
|
|
both.WindSpeedMph = us.WindSpeedMph
|
|
both.WindGustMph = us.WindGustMph
|
|
both.TemperatureC = ptr(-40)
|
|
both.TemperatureCMin = ptr(-40)
|
|
both.TemperatureCMax = ptr(-40)
|
|
both.ApparentTemperatureC = ptr(-40)
|
|
both.WindSpeedKmh = ptr(0)
|
|
both.WindGustKmh = ptr(0)
|
|
|
|
for _, source := range []struct {
|
|
name string
|
|
period weatherdata.ForecastPeriod
|
|
}{
|
|
{name: "us customary", period: us},
|
|
{name: "metric", period: metric},
|
|
{name: "both", period: both},
|
|
} {
|
|
t.Run(source.name, func(t *testing.T) {
|
|
summary := SummarizeDaypart("morning", period, []weatherdata.ForecastPeriod{source.period})
|
|
assertRangeClose(t, "temperature", summary.Temperature, 50, 86)
|
|
assertRangeClose(t, "apparent temperature", summary.ApparentTemperature, 69.8, 69.8)
|
|
assertTimedValueClose(t, "peak wind speed", summary.PeakWindSpeed, 31.068559)
|
|
assertTimedValueClose(t, "peak wind gust", summary.PeakWindGust, 37.282271)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
|
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
|
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
|
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
|
|
}}}
|
|
|
|
summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{
|
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
}
|
|
morning := summary.Dayparts[0]
|
|
if len(morning.HourlyPeriods) != 1 {
|
|
t.Fatalf("morning periods = %d, want only start-boundary period", len(morning.HourlyPeriods))
|
|
}
|
|
if morning.HourlyPeriods[0].TextDescription != "Start" {
|
|
t.Fatalf("selected period = %q, want Start", morning.HourlyPeriods[0].TextDescription)
|
|
}
|
|
}
|
|
|
|
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
|
location := time.UTC
|
|
_, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{
|
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("BuildDailySummary() error = nil, want missing hourly error")
|
|
}
|
|
}
|
|
|
|
func TestBuildDailySummaryRejectsInvalidPrecipitationProbability(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
for _, probability := range []float64{-1, 101, math.NaN()} {
|
|
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Showers", 70, nil, ptr(probability), nil, nil),
|
|
}}}
|
|
_, err := BuildDailySummary(bundle, mustParse("2026-05-29T12:00:00-05:00"), location, []DaypartDefinition{{Name: "morning", Start: "06:00", End: "12:00"}})
|
|
if err == nil {
|
|
t.Fatalf("BuildDailySummary(%v) error = nil, want invalid precipitation probability", probability)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIndicatorsForTextClassifyIceVocabulary(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
text string
|
|
want Indicators
|
|
}{
|
|
{name: "ice", text: "Ice likely", want: Indicators{Ice: true}},
|
|
{name: "icy mixed case", text: "ICY roads", want: Indicators{Ice: true}},
|
|
{name: "freezing punctuation", text: "Freezing-rain possible", want: Indicators{Ice: true}},
|
|
{name: "sleet punctuation", text: "Sleet, then rain", want: Indicators{Ice: true}},
|
|
{name: "unrelated nice", text: "Nice weather"},
|
|
{name: "unrelated spicy", text: "Spicy conditions"},
|
|
{name: "other conditions unchanged", text: "Snow and fog with gusts", want: Indicators{Snow: true, Fog: true, Wind: true}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := indicatorsForText(tt.text); got != tt.want {
|
|
t.Fatalf("indicatorsForText(%q) = %#v, want %#v", tt.text, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlertOverlap(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
|
period := timeutil.Period{
|
|
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
|
|
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
|
|
}
|
|
|
|
overlaps := AlertOverlaps(alertRun, period)
|
|
if len(overlaps) != 1 {
|
|
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
|
|
}
|
|
if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
|
t.Fatalf("overlap start = %s, want alert start", overlaps[0].Overlap.Start.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
|
t.Fatalf("overlap end = %s, want period end", overlaps[0].Overlap.End.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) {
|
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.\n\nUse extra\tcaution.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
|
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
|
period := timeutil.Period{
|
|
Start: mustParse("2026-06-17T12:00:00-05:00"),
|
|
End: mustParse("2026-06-17T21:00:00-05:00"),
|
|
}
|
|
|
|
overlaps := AlertOverlaps(alertRun, period)
|
|
if len(overlaps) != 1 {
|
|
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
|
|
}
|
|
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
|
|
t.Fatalf("alert period start = %s, want onset", overlaps[0].Period.Start.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
|
|
t.Fatalf("alert period end = %s, want ends", overlaps[0].Period.End.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
|
|
t.Fatalf("overlap start = %s, want onset", overlaps[0].Overlap.Start.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
|
|
t.Fatalf("overlap end = %s, want alert ends", overlaps[0].Overlap.End.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." {
|
|
t.Fatalf("Description = %q, want preserved description", overlaps[0].Description)
|
|
}
|
|
if overlaps[0].Instruction != "Secure outdoor objects. Use extra caution." {
|
|
t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction)
|
|
}
|
|
}
|
|
|
|
func TestCollapseWhitespace(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want string
|
|
}{
|
|
{name: "newlines", value: "Secure outdoor objects.\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
|
|
{name: "crlf", value: "Secure outdoor objects.\r\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
|
|
{name: "tabs and repeated spaces", value: "Secure\toutdoor objects.", want: "Secure outdoor objects."},
|
|
{name: "leading trailing", value: " Secure outdoor objects. ", want: "Secure outdoor objects."},
|
|
{name: "empty", value: "", want: ""},
|
|
{name: "all whitespace", value: " \n\t\r\n ", want: ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := collapseWhitespace(tt.value); got != tt.want {
|
|
t.Fatalf("collapseWhitespace(%q) = %q, want %q", tt.value, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) {
|
|
location, err := time.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("load location: %v", err)
|
|
}
|
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
|
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
|
june16 := timeutil.CivilDay(time.Date(2026, 6, 16, 12, 0, 0, 0, location), location)
|
|
june17Afternoon := timeutil.Period{
|
|
Start: time.Date(2026, 6, 17, 12, 0, 0, 0, location),
|
|
End: time.Date(2026, 6, 17, 21, 0, 0, 0, location),
|
|
}
|
|
|
|
if overlaps := AlertOverlaps(alertRun, june16); len(overlaps) != 0 {
|
|
t.Fatalf("June 16 overlaps length = %d, want none: %#v", len(overlaps), overlaps)
|
|
}
|
|
if overlaps := AlertOverlaps(alertRun, june17Afternoon); len(overlaps) != 1 {
|
|
t.Fatalf("June 17 afternoon overlaps length = %d, want 1: %#v", len(overlaps), overlaps)
|
|
}
|
|
}
|
|
|
|
func TestAlertOverlapSupportsOlderEffectiveExpiresPayload(t *testing.T) {
|
|
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
|
period := timeutil.Period{
|
|
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
|
End: mustParse("2026-05-29T11:00:00-05:00"),
|
|
}
|
|
|
|
overlaps := AlertOverlaps(alertRun, period)
|
|
if len(overlaps) != 1 {
|
|
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
|
|
}
|
|
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
|
t.Fatalf("alert period start = %s, want effective fallback", overlaps[0].Period.Start.Format(time.RFC3339))
|
|
}
|
|
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
|
t.Fatalf("alert period end = %s, want expires fallback", overlaps[0].Period.End.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func TestAlertOverlapSkipsInvalidSelectedPeriod(t *testing.T) {
|
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Invalid event period","severity":"Moderate","onset":"2026-06-17T18:00:00Z","ends":"2026-06-17T08:45:00Z"}`)
|
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
|
period := timeutil.Period{
|
|
Start: mustParse("2026-06-17T00:00:00Z"),
|
|
End: mustParse("2026-06-18T00:00:00Z"),
|
|
}
|
|
|
|
if overlaps := AlertOverlaps(alertRun, period); len(overlaps) != 0 {
|
|
t.Fatalf("overlaps length = %d, want invalid alert skipped: %#v", len(overlaps), overlaps)
|
|
}
|
|
}
|
|
|
|
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
periods := []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
|
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Brief lull", 70, nil, ptr(39.999), nil, nil),
|
|
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Unknown rain chance", 70, nil, nil, nil, nil),
|
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(40), nil, nil),
|
|
}
|
|
|
|
timing := BuildPrecipTiming(periods)
|
|
|
|
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
|
t.Fatalf("FirstPrecipitation = %#v, want first threshold window start", timing.FirstPrecipitation)
|
|
}
|
|
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
|
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
|
|
}
|
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
|
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
|
}
|
|
if timing.ProbabilityThreshold != 40 {
|
|
t.Fatalf("ProbabilityThreshold = %v, want 40", timing.ProbabilityThreshold)
|
|
}
|
|
if len(timing.PrecipitationWindows) != 2 {
|
|
t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows)
|
|
}
|
|
first := timing.PrecipitationWindows[0]
|
|
if first.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || first.End == nil || first.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
|
t.Fatalf("first window = %#v, want 9-11 AM", first)
|
|
}
|
|
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
|
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
|
|
}
|
|
if len(first.TextDescriptions) != 2 || first.TextDescriptions[0] != "Showers" || first.TextDescriptions[1] != "Rain likely" {
|
|
t.Fatalf("first window text descriptions = %#v, want contributing hourly descriptions", first.TextDescriptions)
|
|
}
|
|
second := timing.PrecipitationWindows[1]
|
|
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
|
t.Fatalf("second window = %#v, want noon-1 PM", second)
|
|
}
|
|
if len(second.TextDescriptions) != 1 || second.TextDescriptions[0] != "Thunderstorms" {
|
|
t.Fatalf("second window text descriptions = %#v, want thunderstorm description", second.TextDescriptions)
|
|
}
|
|
if !timing.ThunderMentioned {
|
|
t.Fatal("ThunderMentioned = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestBuildPrecipTimingLeavesFinalWindowOpen(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
periods := []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(45), nil, nil),
|
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
|
}
|
|
|
|
timing := BuildPrecipTiming(periods)
|
|
|
|
if len(timing.PrecipitationWindows) != 1 {
|
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
|
}
|
|
if timing.PrecipitationWindows[0].End != nil {
|
|
t.Fatalf("open window End = %v, want nil", timing.PrecipitationWindows[0].End)
|
|
}
|
|
if timing.LastPrecipitation != nil {
|
|
t.Fatalf("LastPrecipitation = %#v, want nil for open final window", timing.LastPrecipitation)
|
|
}
|
|
}
|
|
|
|
func TestBuildPrecipTimingSupportsNonDefaultThreshold(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
periods := []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 70, nil, ptr(50), nil, nil),
|
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Showers", 70, nil, ptr(55), nil, nil),
|
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Drying out", 70, nil, ptr(20), nil, nil),
|
|
}
|
|
|
|
timing := buildPrecipTimingWithThreshold(periods, 55)
|
|
|
|
if timing.ProbabilityThreshold != 55 {
|
|
t.Fatalf("ProbabilityThreshold = %v, want 55", timing.ProbabilityThreshold)
|
|
}
|
|
if len(timing.PrecipitationWindows) != 1 {
|
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
|
}
|
|
window := timing.PrecipitationWindows[0]
|
|
if window.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || window.End == nil || window.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
|
t.Fatalf("window = %#v, want 9-11 AM", window)
|
|
}
|
|
}
|
|
|
|
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
|
location := time.FixedZone("Test", -5*60*60)
|
|
periods := []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Sunny", 70, nil, ptr(0), nil, nil),
|
|
}
|
|
|
|
timing := BuildPrecipTiming(periods)
|
|
|
|
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned {
|
|
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
|
|
}
|
|
if timing.ProbabilityThreshold != 40 {
|
|
t.Fatalf("ProbabilityThreshold = %v, want 40", timing.ProbabilityThreshold)
|
|
}
|
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
|
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
|
}
|
|
}
|
|
|
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
|
return &weatherdata.Bundle{
|
|
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
|
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
|
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
|
|
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
|
|
}},
|
|
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
|
|
}},
|
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
|
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
|
|
}},
|
|
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
|
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
|
}
|
|
}
|
|
|
|
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod {
|
|
startTime := mustParse(start).In(location)
|
|
endTime := mustParse(end).In(location)
|
|
temp := temperature
|
|
return weatherdata.ForecastPeriod{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
TextDescription: text,
|
|
TemperatureF: &temp,
|
|
ApparentTemperatureF: apparent,
|
|
ProbabilityOfPrecipitationPercent: precip,
|
|
WindSpeedMph: wind,
|
|
WindGustMph: gust,
|
|
}
|
|
}
|
|
|
|
func mustParse(value string) time.Time {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func ptr(value float64) *float64 {
|
|
return &value
|
|
}
|
|
|
|
func assertRange(t *testing.T, name string, got Range, wantMin float64, wantMax float64) {
|
|
t.Helper()
|
|
if got.Min == nil || got.Max == nil {
|
|
t.Fatalf("%s = %#v, want min and max", name, got)
|
|
}
|
|
if *got.Min != wantMin || *got.Max != wantMax {
|
|
t.Fatalf("%s = [%v,%v], want [%v,%v]", name, *got.Min, *got.Max, wantMin, wantMax)
|
|
}
|
|
}
|
|
|
|
func assertRangeClose(t *testing.T, name string, got Range, wantMin float64, wantMax float64) {
|
|
t.Helper()
|
|
if got.Min == nil || got.Max == nil || math.Abs(*got.Min-wantMin) > 0.000001 || math.Abs(*got.Max-wantMax) > 0.000001 {
|
|
t.Fatalf("%s = %#v, want range [%v, %v]", name, got, wantMin, wantMax)
|
|
}
|
|
}
|
|
|
|
func assertTimedValueClose(t *testing.T, name string, got *TimedValue, want float64) {
|
|
t.Helper()
|
|
if got == nil || math.Abs(got.Value-want) > 0.000001 {
|
|
t.Fatalf("%s = %#v, want %v", name, got, want)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalDaypartKey(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
value string
|
|
want string
|
|
}{
|
|
{name: "blank", value: " \t ", want: ""},
|
|
{name: "casing", value: "MORNING", want: "morning"},
|
|
{name: "punctuation runs", value: " Morning--Commute / School ", want: "morning_commute_school"},
|
|
{name: "Unicode", value: "Déjà Vu", want: "déjà_vu"},
|
|
{name: "letters and digits", value: "Day 1 Outlook", want: "day_1_outlook"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := CanonicalDaypartKey(tt.value); got != tt.want {
|
|
t.Fatalf("CanonicalDaypartKey(%q) = %q, want %q", tt.value, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
if CanonicalDaypartKey("Morning commute") != CanonicalDaypartKey("MORNING--COMMUTE") {
|
|
t.Fatal("equivalent configured names must have the same canonical daypart key")
|
|
}
|
|
}
|