Files
weatherreporter/internal/forecast/derive_test.go

389 lines
17 KiB
Go

package forecast
import (
"encoding/json"
"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),
}}}
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)
}
}
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 TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
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", 50, nil, nil, nil, nil),
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
}}}
period := timeutil.Period{
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
}
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
})
if err != nil {
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
}
if len(summaries) != 3 {
t.Fatalf("summaries length = %d, want 3", len(summaries))
}
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
}
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
}
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
}
}
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 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 != DefaultPrecipWindowProbabilityThreshold {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", 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)
}
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 !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 != DefaultPrecipWindowProbabilityThreshold {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
}
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
}
}
func TestThresholdHelpers(t *testing.T) {
if !DifferenceAtLeast(50, 56, 5) {
t.Fatal("DifferenceAtLeast = false, want true")
}
if !CrossesAtOrAbove(29, 32, 32) {
t.Fatal("CrossesAtOrAbove = false, want true")
}
if !CrossesBelow(35, 31, 32) {
t.Fatal("CrossesBelow = false, want true")
}
}
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)
}
}