Feature addition to support narrative forecast updates from the NWS
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -2,11 +2,13 @@ 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"
|
||||
)
|
||||
|
||||
@@ -70,6 +72,118 @@ func TestNormalizeForecastEventBySchemaRoutesHourly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
|
||||
@@ -88,3 +202,10 @@ func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func derefOrZero(v *float64) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user