package facts import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { fetchedAt := mustParse("2026-05-29T10:00:00Z") bundle := &weatherdata.Bundle{ FetchedAt: fetchedAt, Current: &weatherdata.Current{ConditionText: "Clear"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"}, SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{ Product: "convective_outlook", Outlooks: []weatherdata.ConvectiveOutlook{{ ID: "day1-categorical-slight", Label: "SLGT", }}, }, Sources: []weatherdata.Source{{Name: "hourly"}}, Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, } collected := BuildCollected(bundle) if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" { t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected) } if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" { t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks) } if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) } if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" { t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings) } bundle.Sources[0].Name = "changed" bundle.Warnings[0].Source = "changed" if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) } roundTrip := collected.Bundle() if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" { t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks) } } func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location) derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(testBundle(location)), }) if err != nil { t.Fatalf("BuildDerived() error = %v", err) } if len(derived.ValidPeriodHourlyPeriods) != 4 { t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods)) } if len(derived.ValidPeriodNarrativePeriods) != 1 { t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods)) } if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" { t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps) } if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 { t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries) } if len(derived.DaypartSummaries) != 3 { t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries)) } if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" { t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start", derived.PrecipTiming.FirstPrecipitation) } if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final closed window end", derived.PrecipTiming.LastPrecipitation) } if len(derived.PrecipTiming.PrecipitationWindows) != 2 { t.Fatalf("PrecipitationWindows = %#v, want two threshold windows", derived.PrecipTiming.PrecipitationWindows) } if !derived.PrecipTiming.ThunderMentioned { t.Fatal("PrecipTiming.ThunderMentioned = false, want true") } morning := derived.DailySummaries[0].Dayparts[0] if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 { t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning) } } func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location) bundle := testBundle(location) bundle.Narrative = nil bundle.Alerts = nil bundle.Discussion = nil bundle.WeatherStory = nil derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(bundle), }) if err != nil { t.Fatalf("BuildDerived() error = %v", err) } if len(derived.DailySummaries) != 3 { t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries)) } if len(derived.ValidPeriodNarrativePeriods) != 0 { t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods)) } if len(derived.AlertOverlaps) != 0 { t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps) } if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" { t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339)) } } func TestBuildDerivedWeekendAndTomorrow(t *testing.T) { location := testLocation() for _, id := range []report.ID{report.Tomorrow, report.Weekend} { resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location) derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(testBundle(location)), }) if err != nil { t.Fatalf("BuildDerived(%s) error = %v", id, err) } if len(derived.DailySummaries) == 0 { t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id) } } } func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location) bundle := testBundle(location) bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`), json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`), }} bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location) derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(bundle), }) if err != nil { t.Fatalf("BuildDerived() error = %v", err) } if len(derived.ValidPeriodHourlyPeriods) != 4 { t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods)) } if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" || derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods) } if len(derived.ValidPeriodNarrativePeriods) != 1 { t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods)) } if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil { t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary) } if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" { t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation) } if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation) } if len(derived.PrecipTiming.PrecipitationWindows) != 2 { t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows) } if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" { t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps) } if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" { t.Fatalf("Alert overlap end = %s, want clipped to hourly end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339)) } if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want) } if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want) } } func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { location := testLocation() resolved := resolveStormForTest(t, location) derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(testBundle(location)), }) if err != nil { t.Fatalf("BuildDerived() error = %v", err) } if len(derived.ValidPeriodHourlyPeriods) != 2 { t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods)) } if len(derived.ValidPeriodDailyPeriods) != 1 { t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods)) } if derived.StormWindowSummary == nil { t.Fatal("StormWindowSummary = nil, want summary") } if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 { t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary) } if len(derived.StormWindowSummary.AlertOverlaps) != 1 { t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps)) } } func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) { location := testLocation() now := mustParse("2026-05-29T08:00:00-05:00") bundle := testBundle(location) bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location) tests := []struct { name string resolved report.Resolved wantOutlookIDs []string wantDiscussion []string }{ { name: "daily", resolved: resolveForTest(t, report.Daily, now, location), wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}, wantDiscussion: []string{"day1 early", "day1 late"}, }, { name: "daily tomorrow", resolved: resolveForTest(t, report.Tomorrow, now, location), wantOutlookIDs: []string{"sat-enhanced"}, wantDiscussion: []string{"day2"}, }, { name: "three day", resolved: resolveForTest(t, report.ThreeDay, now, location), wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"}, wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"}, }, { name: "weekend", resolved: resolveForTest(t, report.Weekend, now, location), wantOutlookIDs: []string{"sat-enhanced", "sun-slight"}, wantDiscussion: []string{"day2", "day3"}, }, { name: "storm", resolved: resolveStormForTest(t, location), wantOutlookIDs: []string{"fri-storm", "fri-low"}, wantDiscussion: []string{"day1 early", "day1 late"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { derived, err := BuildDerived(BuildDerivedRequest{ Resolved: tt.resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(bundle), }) if err != nil { t.Fatalf("BuildDerived() error = %v", err) } if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") { t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs) } if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") { t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion) } }) } } func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location) resolved.Definition.ID = report.ID("future_report") _, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(testBundle(location)), }) if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) { t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err) } } func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) { location := testLocation() resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location) bundle := testBundle(location) bundle.SPCConvectiveOutlooks = nil derived, err := BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(bundle), }) if err != nil { t.Fatalf("BuildDerived(missing source) error = %v", err) } if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil { t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions) } bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}} derived, err = BuildDerived(BuildDerivedRequest{ Resolved: resolved, Timezone: location.String(), Dayparts: testDayparts(), Collected: BuildCollected(bundle), }) if err != nil { t.Fatalf("BuildDerived(checked empty source) error = %v", err) } if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 { t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks) } if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 { t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions) } } func testBundle(location *time.Location) *weatherdata.Bundle { return &weatherdata.Bundle{ FetchedAt: mustParse("2026-05-29T10:00:00Z"), Current: &weatherdata.Current{ConditionText: "Cloudy"}, Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10), hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35), hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25), hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15), hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5), }}, Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20), }}, Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20), }}, Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`), }}, Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}}, WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"}, Sources: []weatherdata.Source{{Name: "hourly"}}, Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}}, } } func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun { rank1 := 1 rank2 := 2 rank3 := 3 rank4 := 4 rank5 := 5 return &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{ convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"), convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"), convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"), convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"), convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"), convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"), convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"), convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"), }, Discussions: []weatherdata.ConvectiveOutlookDiscussion{ {Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))}, {Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))}, {Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))}, {Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))}, {Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))}, }, } } func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook { return weatherdata.ConvectiveOutlook{ ID: id, Day: day, OutlookType: outlookType, Label: label, SeverityRank: rank, ValidFrom: mustParse(validFrom).In(location), ValidTo: mustParse(validTo).In(location), } } func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string { out := make([]string, 0, len(outlooks)) for _, outlook := range outlooks { out = append(out, outlook.ID) } return out } func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string { out := make([]string, 0, len(discussions)) for _, discussion := range discussions { out = append(out, discussion.Headline) } return out } func ptrTime(value time.Time) *time.Time { return &value } func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod { temperature := 70.0 return weatherdata.ForecastPeriod{ StartTime: mustParse(start).In(location), EndTime: mustParse(end).In(location), TextDescription: text, TemperatureF: &temperature, ProbabilityOfPrecipitationPercent: &precip, WindGustMph: &gust, } } func testDayparts() []forecast.DaypartDefinition { return []forecast.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"}, } } func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved { t.Helper() req := report.ResolveRequest{Now: now, Location: location} if id == report.Daily { req.Date = now } resolved, err := report.Resolve(id, req) if err != nil { t.Fatalf("resolve %s: %v", id, err) } return resolved } func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved { t.Helper() resolved, err := report.Resolve(report.Storm, report.ResolveRequest{ Now: mustParse("2026-05-29T08:00:00-05:00"), Location: location, StormStart: mustParse("2026-05-29T11:30:00-05:00"), StormEnd: mustParse("2026-05-29T13:30:00-05:00"), }) if err != nil { t.Fatalf("resolve storm: %v", err) } return resolved } func testLocation() *time.Location { location, err := time.LoadLocation("America/Chicago") if err != nil { panic(err) } return location } func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed }