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 }