package spc import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/ejr/feedkit/event" spcprovider "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/spc" "gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) func TestConvectiveOutlookNormalizerMatch(t *testing.T) { n := ConvectiveOutlookNormalizer{} if !n.Match(event.Event{Schema: standards.SchemaRawSPCConvectiveOutlookV1}) { t.Fatalf("Match(raw SPC outlook) = false, want true") } if n.Match(event.Event{Schema: standards.SchemaRawNWSAlertsV1}) { t.Fatalf("Match(raw NWS alerts) = true, want false") } } func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5))) if err != nil { t.Fatalf("Normalize() error = %v", err) } if out.Schema != standards.SchemaWeatherOutlookV1 { t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV1) } if out.Kind != event.Kind(standards.KindOutlook) { t.Fatalf("Kind = %q, want outlook", out.Kind) } run, ok := out.Payload.(model.WeatherOutlookRun) if !ok { t.Fatalf("Payload type = %T, want model.WeatherOutlookRun", out.Payload) } wantAsOf := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC) if !run.AsOf.Equal(wantAsOf) { t.Fatalf("AsOf = %s, want %s", run.AsOf, wantAsOf) } if run.IssuedAt == nil || !run.IssuedAt.Equal(wantAsOf) { t.Fatalf("IssuedAt = %v, want %s", run.IssuedAt, wantAsOf) } if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantAsOf) { t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantAsOf) } if run.LocationID != "stl" || run.LocationName != "St. Louis, MO" { t.Fatalf("location metadata = %q/%q", run.LocationID, run.LocationName) } if run.Latitude == nil || *run.Latitude != 38.5 || run.Longitude == nil || *run.Longitude != -90.5 { t.Fatalf("coordinates = %v,%v", run.Latitude, run.Longitude) } if len(run.Outlooks) != 9 { t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks)) } got := run.Outlooks[0] if got.Provider != "spc" || got.Product != "convective" { t.Fatalf("provider/product = %q/%q", got.Provider, got.Product) } if got.Day != 1 || got.OutlookType != spcprovider.OutlookTypeCategorical { t.Fatalf("day/type = %d/%q", got.Day, got.OutlookType) } if got.Label != "SLGT" || got.LabelText != "Slight Risk" { t.Fatalf("label fields = %q/%q", got.Label, got.LabelText) } if got.SeverityRank == nil || *got.SeverityRank != 3 { t.Fatalf("SeverityRank = %v, want 3", got.SeverityRank) } assertTime(t, "ValidFrom", got.ValidFrom, 2026, 6, 11, 13, 0, 0) assertTime(t, "ValidTo", got.ValidTo, 2026, 6, 12, 12, 0, 0) assertTime(t, "IssuedAt", got.IssuedAt, 2026, 6, 11, 12, 34, 56) assertTime(t, "ExpiresAt", got.ExpiresAt, 2026, 6, 12, 12, 0, 0) if got.Forecaster != "SMITH" { t.Fatalf("Forecaster = %q, want SMITH", got.Forecaster) } if got.SourceURL != "https://example.invalid/day1_categorical.geojson" { t.Fatalf("SourceURL = %q", got.SourceURL) } wantGeometry := `{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}` if string(got.Geometry) != wantGeometry { t.Fatalf("Geometry = %s, want %s", got.Geometry, wantGeometry) } if !got.ContainsLocation { t.Fatalf("ContainsLocation = false, want true") } if got.Headline != "Day 1 Convective Outlook" { t.Fatalf("Headline = %q", got.Headline) } if !strings.Contains(got.Summary, "central Plains") { t.Fatalf("Summary = %q", got.Summary) } if !strings.Contains(got.Discussion, "...DISCUSSION...") { t.Fatalf("Discussion missing product text: %q", got.Discussion) } if !strings.HasPrefix(got.Discussion, "SPC AC 111234") { t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion) } if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" { t.Fatalf("ID = %q", got.ID) } } func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) { bundle := spcBundle(t, 0, 0) for i, j := 0, len(bundle.Products)-1; i < j; i, j = i+1, j-1 { bundle.Products[i], bundle.Products[j] = bundle.Products[j], bundle.Products[i] } out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err != nil { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) got := []string{ run.Outlooks[0].OutlookType, run.Outlooks[1].OutlookType, run.Outlooks[2].OutlookType, run.Outlooks[3].OutlookType, } want := []string{ spcprovider.OutlookTypeCategorical, spcprovider.OutlookTypeTornado, spcprovider.OutlookTypeHail, spcprovider.OutlookTypeWind, } for i := range want { if got[i] != want[i] || run.Outlooks[i].Day != 1 { t.Fatalf("outlook[%d] = day %d type %q, want day 1 type %q", i, run.Outlooks[i].Day, got[i], want[i]) } } } func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) if err != nil { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) for _, outlookType := range []string{ spcprovider.OutlookTypeTornado, spcprovider.OutlookTypeHail, spcprovider.OutlookTypeWind, } { if findOutlook(run.Outlooks, 1, outlookType) == nil { t.Fatalf("missing day 1 outlook type %q", outlookType) } } } func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) if err != nil { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) if run.Outlooks[0].ContainsLocation { t.Fatalf("ContainsLocation = true, want false") } } func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) if err != nil { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) got := findOutlook(run.Outlooks, 2, spcprovider.OutlookTypeTornado) if got == nil { t.Fatalf("missing day 2 tornado outlook") } if !strings.Contains(got.Headline, "CORR 1") { t.Fatalf("Headline = %q, want correction marker", got.Headline) } if !strings.Contains(got.Discussion, "CORR 1") { t.Fatalf("Discussion = %q, want correction marker", got.Discussion) } } func TestConvectiveOutlookNormalizerMissingRSSNormalizes(t *testing.T) { bundle := spcBundle(t, 0, 0) bundle.RSS = nil if _, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)); err != nil { t.Fatalf("Normalize() error = %v", err) } } func TestConvectiveOutlookNormalizerInvalidRequiredTimestampFailsWithContext(t *testing.T) { bundle := spcBundle(t, 0, 0) bundle.Products[0].Body = json.RawMessage(strings.Replace( string(bundle.Products[0].Body), `"ISSUE_ISO": "2026-06-11T12:34:56Z"`, `"ISSUE_ISO": "bad"`, 1, )) _, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err == nil { t.Fatalf("Normalize() error = nil, want error") } if !strings.Contains(err.Error(), "product day1_categorical feature 0.ISSUE_ISO") { t.Fatalf("error = %q, want product and feature context", err) } } func TestConvectiveOutlookNormalizerInvalidGeometryFailsWithContext(t *testing.T) { bundle := spcBundle(t, 0, 0) bundle.Products[0].Body = json.RawMessage(strings.Replace( string(bundle.Products[0].Body), `"geometry": {`, `"geometry": {"type":"LineString","coordinates":[[-91,38],[-90,39]]}, "oldGeometry": {`, 1, )) _, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err == nil { t.Fatalf("Normalize() error = nil, want error") } if !strings.Contains(err.Error(), "product day1_categorical feature 0.geometry") { t.Fatalf("error = %q, want product and feature context", err) } } func TestConvectiveOutlookNormalizerRejectsMissingLabel(t *testing.T) { bundle := spcBundle(t, 0, 0) bundle.Products[0].Body = json.RawMessage(strings.Replace( string(bundle.Products[0].Body), `"LABEL": "SLGT"`, `"LABEL": ""`, 1, )) _, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err == nil { t.Fatalf("Normalize() error = nil, want error") } if !strings.Contains(err.Error(), "product day1_categorical feature 0.LABEL") { t.Fatalf("error = %q, want label context", err) } } func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5))) if err != nil { t.Fatalf("Normalize() error = %v", err) } raw, err := json.Marshal(out.Payload) if err != nil { t.Fatalf("Marshal(payload) error = %v", err) } got := string(raw) for _, want := range []string{`"asOf"`, `"outlooks"`, `"containsLocation"`, `"geometry"`} { if !strings.Contains(got, want) { t.Fatalf("payload JSON missing %s: %s", want, got) } } for _, unwanted := range []string{`"products"`, `"discussions"`, `"fetchedAt"`, `"body"`} { if strings.Contains(got, unwanted) { t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got) } } } func spcRawEvent(t *testing.T, bundle spcprovider.RawConvectiveOutlookBundle) event.Event { t.Helper() raw, err := json.Marshal(bundle) if err != nil { t.Fatalf("Marshal(bundle) error = %v", err) } effectiveAt := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC) return event.Event{ ID: "evt-spc-outlook-1", Kind: event.Kind(standards.KindOutlook), Source: "spc-test", EmittedAt: time.Date(2026, 6, 11, 20, 5, 0, 0, time.UTC), EffectiveAt: &effectiveAt, Schema: standards.SchemaRawSPCConvectiveOutlookV1, Payload: json.RawMessage(raw), } } func spcBundle(t *testing.T, latitude, longitude float64) spcprovider.RawConvectiveOutlookBundle { t.Helper() fetchedAt := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC) products := make([]spcprovider.RawOutlookProduct, 0, len(spcprovider.GeoJSONProducts())) for _, product := range spcprovider.GeoJSONProducts() { products = append(products, spcprovider.RawOutlookProduct{ Key: product.Key, Day: product.Day, OutlookType: product.OutlookType, URL: "https://example.invalid/" + product.Key + ".geojson", FetchedAt: fetchedAt, Body: json.RawMessage(geoJSONFixtureForProduct(t, product.Key)), }) } return spcprovider.RawConvectiveOutlookBundle{ LocationID: "stl", LocationName: "St. Louis, MO", Latitude: latitude, Longitude: longitude, FetchedAt: fetchedAt, Products: products, Discussions: []spcprovider.RawDiscussionPage{ {Key: "day1", Day: 1, URL: "https://example.invalid/day1.html", FetchedAt: fetchedAt, Body: string(readSPCTestFixture(t, "day1_prt.html"))}, {Key: "day2", Day: 2, URL: "https://example.invalid/day2.html", FetchedAt: fetchedAt, Body: string(readSPCTestFixture(t, "day2_prt_corr.html"))}, {Key: "day3", Day: 3, URL: "https://example.invalid/day3.html", FetchedAt: fetchedAt, Body: string(readSPCTestFixture(t, "day3_prt.html"))}, }, } } func geoJSONFixtureForProduct(t *testing.T, key string) []byte { t.Helper() switch { case strings.HasPrefix(key, "day1_"): return readSPCTestFixture(t, "day1_cat.geojson") case strings.HasPrefix(key, "day2_"): return readSPCTestFixture(t, "day2_torn.geojson") case strings.HasPrefix(key, "day3_"): return readSPCTestFixture(t, "day3_cat.geojson") default: t.Fatalf("unknown product key %q", key) return nil } } func readSPCTestFixture(t *testing.T, name string) []byte { t.Helper() path := filepath.Join("..", "..", "providers", "spc", "testdata", name) raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read fixture %s: %v", path, err) } return raw } func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) *model.WeatherOutlook { for i := range outlooks { if outlooks[i].Day == day && outlooks[i].OutlookType == outlookType { return &outlooks[i] } } return nil } func assertTime(t *testing.T, name string, got time.Time, year int, month time.Month, day int, hour int, minute int, second int) { t.Helper() want := time.Date(year, month, day, hour, minute, second, 0, time.UTC) if !got.Equal(want) { t.Fatalf("%s = %s, want %s", name, got, want) } }