508 lines
22 KiB
Go
508 lines
22 KiB
Go
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
|
|
}
|