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 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 != 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) } 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 != 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) } }