package weatherdata import ( "encoding/json" "math" "testing" "time" ) func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) { severity := 3 validFrom := mustParseBundleTime("2026-06-12T13:00:00Z") validTo := mustParseBundleTime("2026-06-13T12:00:00Z") issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z") updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z") run := ConvectiveOutlookRun{ LocationID: "test-grid", LocationName: "Brentwood", AsOf: &updatedAt, IssuedAt: &issuedAt, Product: "convective_outlook", Outlooks: []ConvectiveOutlook{{ ID: "day1-categorical-slight", Provider: "spc", Product: "convective_outlook", Day: 1, OutlookType: "categorical", Label: "SLGT", LabelText: "Slight Risk", Forecaster: "Smith", SeverityRank: &severity, ValidFrom: validFrom, ValidTo: validTo, IssuedAt: &issuedAt, ExpiresAt: &validTo, SourceURL: "https://example.test/source", ImageURL: "https://example.test/image.png", ContainsLocation: true, Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`), }}, Discussions: []ConvectiveOutlookDiscussion{{ Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", Discussion: "Discussion text.", UpdatedAt: &updatedAt, }}, } data, err := json.Marshal(run) if err != nil { t.Fatalf("Marshal() error = %v", err) } var decoded ConvectiveOutlookRun if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" { t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks) } if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` { t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got) } if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity { t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity) } if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" { t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions) } } func TestForecastPeriodHasUsableTimeBounds(t *testing.T) { start := mustParseBundleTime("2026-05-29T13:00:00Z") end := mustParseBundleTime("2026-05-29T14:00:00Z") tests := []struct { name string period ForecastPeriod want bool }{ {name: "valid", period: ForecastPeriod{StartTime: start, EndTime: end}, want: true}, {name: "missing start", period: ForecastPeriod{EndTime: end}}, {name: "missing end", period: ForecastPeriod{StartTime: start}}, {name: "empty range", period: ForecastPeriod{StartTime: start, EndTime: start}}, {name: "reversed range", period: ForecastPeriod{StartTime: end, EndTime: start}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.period.HasUsableTimeBounds(); got != tt.want { t.Fatalf("HasUsableTimeBounds() = %t, want %t", got, tt.want) } }) } } func TestForecastPeriodHasValidPrecipitationProbability(t *testing.T) { for _, tt := range []struct { name string value *float64 want bool }{ {name: "missing", want: true}, {name: "zero", value: ptrProbability(0), want: true}, {name: "one hundred", value: ptrProbability(100), want: true}, {name: "negative", value: ptrProbability(-0.1)}, {name: "above one hundred", value: ptrProbability(100.1)}, {name: "not a number", value: ptrProbability(math.NaN())}, {name: "infinite", value: ptrProbability(math.Inf(1))}, } { t.Run(tt.name, func(t *testing.T) { period := ForecastPeriod{ProbabilityOfPrecipitationPercent: tt.value} if got := period.HasValidPrecipitationProbability(); got != tt.want { t.Fatalf("HasValidPrecipitationProbability() = %t, want %t", got, tt.want) } }) } } func TestWeatherStoryHasUsableContent(t *testing.T) { start := mustParseBundleTime("2026-05-29T13:00:00Z") end := mustParseBundleTime("2026-05-29T14:00:00Z") for _, tt := range []struct { name string story WeatherStory want bool }{ {name: "empty"}, {name: "whitespace title", story: WeatherStory{Title: " \t "}}, {name: "title", story: WeatherStory{Title: "Rain Chances"}, want: true}, {name: "description", story: WeatherStory{Description: "Showers expected."}, want: true}, {name: "alternate text", story: WeatherStory{AltText: "Rain chances graphic"}, want: true}, {name: "download URL", story: WeatherStory{DownloadURL: "https://example.invalid/story.png"}, want: true}, {name: "valid period", story: WeatherStory{StartTime: start, EndTime: end}, want: true}, {name: "reversed period", story: WeatherStory{StartTime: end, EndTime: start}}, } { t.Run(tt.name, func(t *testing.T) { if got := tt.story.HasUsableContent(); got != tt.want { t.Fatalf("HasUsableContent() = %t, want %t", got, tt.want) } }) } } func mustParseBundleTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed } func ptrProbability(value float64) *float64 { return &value }