325 lines
10 KiB
Go
325 lines
10 KiB
Go
package nws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
|
)
|
|
|
|
func TestBuildHourlyForecastUsesShortForecastAsTextDescription(t *testing.T) {
|
|
parsed := nwsHourlyForecastResponse{}
|
|
parsed.Properties.GeneratedAt = "2026-03-16T18:00:00Z"
|
|
parsed.Properties.Periods = []nwsHourlyForecastPeriod{
|
|
{
|
|
StartTime: "2026-03-16T19:00:00Z",
|
|
EndTime: "2026-03-16T20:00:00Z",
|
|
ShortForecast: " Mostly Cloudy ",
|
|
DetailedForecast: "Clouds increasing overnight.",
|
|
Icon: "https://example.invalid/icon",
|
|
},
|
|
}
|
|
|
|
run, effectiveAt, err := buildHourlyForecast(parsed)
|
|
if err != nil {
|
|
t.Fatalf("buildHourlyForecast() error = %v", err)
|
|
}
|
|
if len(run.Periods) != 1 {
|
|
t.Fatalf("periods len = %d, want 1", len(run.Periods))
|
|
}
|
|
if got, want := run.Periods[0].TextDescription, "Mostly Cloudy"; got != want {
|
|
t.Fatalf("TextDescription = %q, want %q", got, want)
|
|
}
|
|
|
|
wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC)
|
|
if !run.IssuedAt.Equal(wantIssued) {
|
|
t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
if !effectiveAt.Equal(wantIssued) {
|
|
t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
|
|
assertNoLegacyForecastDescriptionKeys(t, run.Periods[0])
|
|
}
|
|
|
|
func TestBuildHourlyForecastPreservesUpdatedAtCentroidAndElevation(t *testing.T) {
|
|
parsed := nwsHourlyForecastResponse{}
|
|
parsed.Properties.GeneratedAt = "2026-03-16T18:00:00Z"
|
|
parsed.Properties.UpdateTime = "2026-03-16T18:30:00Z"
|
|
elevation := 123.4
|
|
parsed.Properties.Elevation.Value = &elevation
|
|
parsed.Geometry.Coordinates = [][][]float64{
|
|
{
|
|
{-90.0, 38.0},
|
|
{-89.0, 38.0},
|
|
{-89.0, 39.0},
|
|
{-90.0, 39.0},
|
|
},
|
|
}
|
|
parsed.Properties.Periods = []nwsHourlyForecastPeriod{
|
|
{
|
|
StartTime: "2026-03-16T19:00:00Z",
|
|
EndTime: "2026-03-16T20:00:00Z",
|
|
ShortForecast: "Cloudy",
|
|
},
|
|
}
|
|
|
|
run, effectiveAt, err := buildHourlyForecast(parsed)
|
|
if err != nil {
|
|
t.Fatalf("buildHourlyForecast() error = %v", err)
|
|
}
|
|
|
|
wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC)
|
|
wantUpdated := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
|
if !run.IssuedAt.Equal(wantIssued) {
|
|
t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
if run.UpdatedAt == nil || !run.UpdatedAt.Equal(wantUpdated) {
|
|
t.Fatalf("UpdatedAt = %v, want %s", run.UpdatedAt, wantUpdated.Format(time.RFC3339))
|
|
}
|
|
if run.Latitude == nil || math.Abs(*run.Latitude-38.5) > 0.0001 {
|
|
t.Fatalf("Latitude = %v, want 38.5", run.Latitude)
|
|
}
|
|
if run.Longitude == nil || math.Abs(*run.Longitude+89.5) > 0.0001 {
|
|
t.Fatalf("Longitude = %v, want -89.5", run.Longitude)
|
|
}
|
|
if run.ElevationMeters == nil || math.Abs(*run.ElevationMeters-elevation) > 0.0001 {
|
|
t.Fatalf("ElevationMeters = %v, want %.1f", run.ElevationMeters, elevation)
|
|
}
|
|
if !effectiveAt.Equal(wantIssued) {
|
|
t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func TestNormalizeForecastEventBySchemaRejectsUnsupportedSchema(t *testing.T) {
|
|
_, err := normalizeForecastEventBySchema(event.Event{
|
|
Schema: "raw.nws.daily.forecast.v1",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("normalizeForecastEventBySchema() expected unsupported schema error")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported nws forecast schema") {
|
|
t.Fatalf("error = %q, want unsupported schema context", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeForecastEventBySchemaRoutesHourly(t *testing.T) {
|
|
_, err := normalizeForecastEventBySchema(event.Event{
|
|
Schema: standards.SchemaRawNWSHourlyForecastV1,
|
|
Payload: map[string]any{"properties": map[string]any{}},
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("normalizeForecastEventBySchema() expected build error for missing generatedAt")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing properties.generatedAt") {
|
|
t.Fatalf("error = %q, want missing properties.generatedAt", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeForecastEventBySchemaRoutesNarrative(t *testing.T) {
|
|
_, err := normalizeForecastEventBySchema(event.Event{
|
|
Schema: standards.SchemaRawNWSNarrativeForecastV1,
|
|
Payload: map[string]any{"properties": map[string]any{}},
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("normalizeForecastEventBySchema() expected build error for missing generatedAt")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing properties.generatedAt") {
|
|
t.Fatalf("error = %q, want missing properties.generatedAt", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeForecastEventBySchemaProducesCanonicalWeatherForecastSchema(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
schema string
|
|
payload map[string]any
|
|
}{
|
|
{
|
|
name: "hourly",
|
|
schema: standards.SchemaRawNWSHourlyForecastV1,
|
|
payload: map[string]any{
|
|
"properties": map[string]any{
|
|
"generatedAt": "2026-03-16T18:00:00Z",
|
|
"periods": []map[string]any{
|
|
{
|
|
"startTime": "2026-03-16T19:00:00Z",
|
|
"endTime": "2026-03-16T20:00:00Z",
|
|
"shortForecast": "Cloudy",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "narrative",
|
|
schema: standards.SchemaRawNWSNarrativeForecastV1,
|
|
payload: map[string]any{
|
|
"properties": map[string]any{
|
|
"generatedAt": "2026-03-16T18:00:00Z",
|
|
"periods": []map[string]any{
|
|
{
|
|
"startTime": "2026-03-16T19:00:00Z",
|
|
"endTime": "2026-03-16T20:00:00Z",
|
|
"shortForecast": "Cloudy",
|
|
"detailedForecast": "Cloudy",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
out, err := normalizeForecastEventBySchema(event.Event{
|
|
ID: "evt-1",
|
|
Kind: event.Kind("forecast"),
|
|
Source: "nws-test",
|
|
EmittedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
|
Schema: tt.schema,
|
|
Payload: tt.payload,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("normalizeForecastEventBySchema() error = %v", err)
|
|
}
|
|
if out == nil {
|
|
t.Fatalf("normalizeForecastEventBySchema() returned nil output")
|
|
}
|
|
if out.Schema != standards.SchemaWeatherForecastV1 {
|
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastV1)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildNarrativeForecastMapsExpectedFields(t *testing.T) {
|
|
parsed := nwsNarrativeForecastResponse{}
|
|
parsed.Properties.GeneratedAt = "2026-03-27T15:17:01Z"
|
|
isDay := true
|
|
tempF := 53.0
|
|
pop := 20.0
|
|
|
|
parsed.Properties.Periods = []nwsNarrativeForecastPeriod{
|
|
{
|
|
Name: "Today",
|
|
StartTime: "2026-03-27T10:00:00-05:00",
|
|
EndTime: "2026-03-27T18:00:00-05:00",
|
|
IsDaytime: &isDay,
|
|
Temperature: &tempF,
|
|
TemperatureUnit: "F",
|
|
WindSpeed: "10 to 14 mph",
|
|
WindDirection: "SW",
|
|
ShortForecast: "Partly Sunny",
|
|
DetailedForecast: " Partly sunny, with a high near 53. ",
|
|
ProbabilityOfPrecipitation: struct {
|
|
UnitCode string `json:"unitCode"`
|
|
Value *float64 `json:"value"`
|
|
}{
|
|
UnitCode: "wmoUnit:percent",
|
|
Value: &pop,
|
|
},
|
|
Icon: "https://api.weather.gov/icons/land/day/bkn?size=medium",
|
|
},
|
|
}
|
|
|
|
run, effectiveAt, err := buildNarrativeForecast(parsed)
|
|
if err != nil {
|
|
t.Fatalf("buildNarrativeForecast() error = %v", err)
|
|
}
|
|
if got, want := run.Product, model.ForecastProductNarrative; got != want {
|
|
t.Fatalf("Product = %q, want %q", got, want)
|
|
}
|
|
if len(run.Periods) != 1 {
|
|
t.Fatalf("periods len = %d, want 1", len(run.Periods))
|
|
}
|
|
|
|
p := run.Periods[0]
|
|
if got, want := p.TextDescription, "Partly sunny, with a high near 53."; got != want {
|
|
t.Fatalf("TextDescription = %q, want %q", got, want)
|
|
}
|
|
if p.TemperatureC == nil {
|
|
t.Fatalf("TemperatureC is nil, want converted value")
|
|
}
|
|
if math.Abs(*p.TemperatureC-11.6666666667) > 0.0001 {
|
|
t.Fatalf("TemperatureC = %.6f, want ~11.6667", *p.TemperatureC)
|
|
}
|
|
if p.IsDay == nil || !*p.IsDay {
|
|
t.Fatalf("IsDay = %v, want true", p.IsDay)
|
|
}
|
|
if p.WindDirectionDegrees == nil || *p.WindDirectionDegrees != 225 {
|
|
t.Fatalf("WindDirectionDegrees = %v, want 225", p.WindDirectionDegrees)
|
|
}
|
|
if p.WindSpeedKmh == nil || math.Abs(*p.WindSpeedKmh-19.3128) > 0.001 {
|
|
t.Fatalf("WindSpeedKmh = %.6f, want ~19.3128", derefOrZero(p.WindSpeedKmh))
|
|
}
|
|
if p.ProbabilityOfPrecipitationPercent == nil || *p.ProbabilityOfPrecipitationPercent != 20 {
|
|
t.Fatalf("ProbabilityOfPrecipitationPercent = %v, want 20", p.ProbabilityOfPrecipitationPercent)
|
|
}
|
|
|
|
wantIssued := time.Date(2026, 3, 27, 15, 17, 1, 0, time.UTC)
|
|
if !run.IssuedAt.Equal(wantIssued) {
|
|
t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
if !effectiveAt.Equal(wantIssued) {
|
|
t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
|
}
|
|
|
|
assertNoLegacyForecastDescriptionKeys(t, p)
|
|
}
|
|
|
|
func TestBuildNarrativeForecastFallsBackToShortForecastDescription(t *testing.T) {
|
|
parsed := nwsNarrativeForecastResponse{}
|
|
parsed.Properties.GeneratedAt = "2026-03-27T15:17:01Z"
|
|
parsed.Properties.Periods = []nwsNarrativeForecastPeriod{
|
|
{
|
|
StartTime: "2026-03-27T18:00:00-05:00",
|
|
EndTime: "2026-03-28T06:00:00-05:00",
|
|
ShortForecast: " Mostly Clear ",
|
|
DetailedForecast: " ",
|
|
},
|
|
}
|
|
|
|
run, _, err := buildNarrativeForecast(parsed)
|
|
if err != nil {
|
|
t.Fatalf("buildNarrativeForecast() error = %v", err)
|
|
}
|
|
if len(run.Periods) != 1 {
|
|
t.Fatalf("periods len = %d, want 1", len(run.Periods))
|
|
}
|
|
if got, want := run.Periods[0].TextDescription, "Mostly Clear"; got != want {
|
|
t.Fatalf("TextDescription = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) {
|
|
t.Helper()
|
|
|
|
b, err := json.Marshal(period)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(period) error = %v", err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatalf("json.Unmarshal(period) error = %v", err)
|
|
}
|
|
|
|
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
|
|
if _, ok := got[key]; ok {
|
|
t.Fatalf("unexpected legacy key %q in marshaled period: %#v", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func derefOrZero(v *float64) float64 {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return *v
|
|
}
|