281 lines
11 KiB
Go
281 lines
11 KiB
Go
package forecast
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
)
|
|
|
|
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 thunderstorm condition", morning.DominantCondition)
|
|
}
|
|
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
|
|
t.Fatalf("morning indicators = %#v, want thunder and 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 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].Indicators.Thunder {
|
|
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
|
}
|
|
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 := &Bundle{Hourly: &ForecastRun{Periods: []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 := &Bundle{Hourly: &ForecastRun{Periods: []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(&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 := &Bundle{Hourly: &ForecastRun{Periods: []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 := &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 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) *Bundle {
|
|
return &Bundle{
|
|
Hourly: &ForecastRun{Periods: []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: &ForecastRun{Periods: []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: &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: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
|
Sources: []Source{{Name: "hourly"}},
|
|
Warnings: []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) ForecastPeriod {
|
|
startTime := mustParse(start).In(location)
|
|
endTime := mustParse(end).In(location)
|
|
temp := temperature
|
|
return 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)
|
|
}
|
|
}
|