diff --git a/internal/normalizers/spc/convective_outlook.go b/internal/normalizers/spc/convective_outlook.go index 93eceea..aaa0544 100644 --- a/internal/normalizers/spc/convective_outlook.go +++ b/internal/normalizers/spc/convective_outlook.go @@ -29,10 +29,10 @@ var idTokenRE = regexp.MustCompile(`[^a-z0-9]+`) // ConvectiveOutlookNormalizer converts: // -// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV1 +// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV2 // -// It maps SPC GeoJSON outlook features into canonical outlook polygons and -// enriches each day with the matching required print-page discussion. +// It maps SPC GeoJSON outlook features containing the configured location into +// canonical outlook polygons and adds matching day-level print-page discussions. type ConvectiveOutlookNormalizer struct{} func (ConvectiveOutlookNormalizer) Match(e event.Event) bool { @@ -50,7 +50,7 @@ func (ConvectiveOutlookNormalizer) Normalize(ctx context.Context, in event.Event return normcommon.NormalizeJSON( in, outlookNormalizer, - standards.SchemaWeatherOutlookV1, + standards.SchemaWeatherOutlookV2, func(parsed spcprovider.RawConvectiveOutlookBundle) (model.WeatherOutlookRun, time.Time, error) { return buildConvectiveOutlook(parsed, fallbackAsOf) }, @@ -104,10 +104,18 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb if latestIssue.IsZero() || outlook.IssuedAt.After(latestIssue) { latestIssue = outlook.IssuedAt } + if !outlook.ContainsLocation { + continue + } outlooks = append(outlooks, outlook) } } + runDiscussions, err := buildOutlookDiscussions(outlooks, discussions) + if err != nil { + return model.WeatherOutlookRun{}, time.Time{}, err + } + asOf := latestIssue if asOf.IsZero() { asOf = latestDiscussionUpdated @@ -132,6 +140,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb AsOf: asOf.UTC(), IssuedAt: issuedAt, Outlooks: outlooks, + Discussions: runDiscussions, } return run, run.AsOf, nil } @@ -174,6 +183,43 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc return out, latestUpdated, nil } +func buildOutlookDiscussions(outlooks []model.WeatherOutlook, discussions map[int]parsedDiscussion) ([]model.WeatherOutlookDiscussion, error) { + daysWithOutlooks := map[int]bool{} + for _, outlook := range outlooks { + daysWithOutlooks[outlook.Day] = true + } + + days := make([]int, 0, len(daysWithOutlooks)) + for day := range daysWithOutlooks { + days = append(days, day) + } + sort.Ints(days) + + out := make([]model.WeatherOutlookDiscussion, 0, len(days)) + for _, day := range days { + disc, ok := discussions[day] + if !ok { + return nil, fmt.Errorf("discussion for retained day %d is required", day) + } + out = append(out, model.WeatherOutlookDiscussion{ + Day: day, + Headline: disc.Headline, + Summary: disc.Summary, + Discussion: disc.Discussion, + UpdatedAt: utcTimePtr(disc.UpdatedAt), + }) + } + return out, nil +} + +func utcTimePtr(t *time.Time) *time.Time { + if t == nil { + return nil + } + tt := t.UTC() + return &tt +} + func orderedProducts(products []spcprovider.RawOutlookProduct) []spcprovider.RawOutlookProduct { out := make([]spcprovider.RawOutlookProduct, len(products)) copy(out, products) diff --git a/internal/normalizers/spc/convective_outlook_test.go b/internal/normalizers/spc/convective_outlook_test.go index 676c6a4..552e3e1 100644 --- a/internal/normalizers/spc/convective_outlook_test.go +++ b/internal/normalizers/spc/convective_outlook_test.go @@ -27,8 +27,8 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test 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.Schema != standards.SchemaWeatherOutlookV2 { + t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV2) } if out.Kind != event.Kind(standards.KindOutlook) { t.Fatalf("Kind = %q, want outlook", out.Kind) @@ -54,8 +54,23 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test 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)) + if len(run.Outlooks) != 4 { + t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks)) + } + assertAllOutlooksContainLocation(t, run.Outlooks) + assertDiscussionDays(t, run.Discussions, 1) + day1Discussion := run.Discussions[0] + if day1Discussion.Headline != "Day 1 Convective Outlook" { + t.Fatalf("day 1 Headline = %q", day1Discussion.Headline) + } + if !strings.Contains(day1Discussion.Summary, "central Plains") { + t.Fatalf("day 1 Summary = %q", day1Discussion.Summary) + } + if !strings.Contains(day1Discussion.Discussion, "...DISCUSSION...") { + t.Fatalf("day 1 Discussion missing product text: %q", day1Discussion.Discussion) + } + if !strings.HasPrefix(day1Discussion.Discussion, "SPC AC 111234") { + t.Fatalf("day 1 Discussion = %q, want SPC product code prefix", day1Discussion.Discussion) } got := run.Outlooks[0] @@ -103,13 +118,39 @@ func TestConvectiveOutlookNormalizerAcceptsTypedSourcePayload(t *testing.T) { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) - if len(run.Outlooks) != 9 { - t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks)) + if len(run.Outlooks) != 4 { + t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks)) + } + assertAllOutlooksContainLocation(t, run.Outlooks) + assertDiscussionDays(t, run.Discussions, 1) +} + +func TestConvectiveOutlookNormalizerEmitsEmptyLocalRunOutsidePolygons(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 len(run.Outlooks) != 0 { + t.Fatalf("Outlooks length = %d, want 0", len(run.Outlooks)) + } + if len(run.Discussions) != 0 { + t.Fatalf("Discussions length = %d, want 0", len(run.Discussions)) + } + wantAsOf := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC) + if !run.AsOf.Equal(wantAsOf) { + t.Fatalf("AsOf = %s, want latest product issue time %s", run.AsOf, wantAsOf) + } + if run.IssuedAt == nil || !run.IssuedAt.Equal(wantAsOf) { + t.Fatalf("IssuedAt = %v, want latest product issue time %s", run.IssuedAt, wantAsOf) + } + if out.EffectiveAt == nil || !out.EffectiveAt.Equal(run.AsOf) { + t.Fatalf("EffectiveAt = %v, want run AsOf %s", out.EffectiveAt, run.AsOf) } } func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) { - bundle := spcBundle(t, 0, 0) + bundle := spcBundle(t, 38.5, -90.5) 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] } @@ -139,11 +180,12 @@ func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) { } func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) { - out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) + out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5))) if err != nil { t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) + assertAllOutlooksContainLocation(t, run.Outlooks) for _, outlookType := range []string{ spcprovider.OutlookTypeTornado, spcprovider.OutlookTypeHail, @@ -156,7 +198,7 @@ func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) } func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *testing.T) { - bundle := spcBundle(t, 0, 0) + bundle := spcBundle(t, 36, -99) replaced := false for i := range bundle.Products { if bundle.Products[i].Day == 2 && bundle.Products[i].OutlookType == spcprovider.OutlookTypeTornado { @@ -173,9 +215,11 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t t.Fatalf("Normalize() error = %v", err) } run := out.Payload.(model.WeatherOutlookRun) - if len(run.Outlooks) != 8 { - t.Fatalf("Outlooks length = %d, want 8", len(run.Outlooks)) + if len(run.Outlooks) != 3 { + t.Fatalf("Outlooks length = %d, want 3", len(run.Outlooks)) } + assertAllOutlooksContainLocation(t, run.Outlooks) + assertDiscussionDays(t, run.Discussions, 2) if got := findOutlook(run.Outlooks, 2, spcprovider.OutlookTypeTornado); got != nil { t.Fatalf("day 2 tornado outlook = %+v, want nil placeholder skipped", *got) } @@ -188,19 +232,39 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t } } -func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) { - out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) +func TestConvectiveOutlookNormalizerIncludesOnlyDayWithContainingPolygons(t *testing.T) { + out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 36, -99))) 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") + if len(run.Outlooks) == 0 { + t.Fatalf("Outlooks length = 0, want retained day 2 outlooks") } + assertAllOutlooksContainLocation(t, run.Outlooks) + for i, outlook := range run.Outlooks { + if outlook.Day != 2 { + t.Fatalf("outlook[%d].Day = %d, want 2", i, outlook.Day) + } + } + assertDiscussionDays(t, run.Discussions, 2) +} + +func TestConvectiveOutlookNormalizerUsesOneDiscussionForMultipleSameDayOutlooks(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) + } + run := out.Payload.(model.WeatherOutlookRun) + if got := countOutlooksByDay(run.Outlooks, 1); got < 2 { + t.Fatalf("day 1 outlook count = %d, want multiple", got) + } + assertAllOutlooksContainLocation(t, run.Outlooks) + assertDiscussionDays(t, run.Discussions, 1) } func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) { - bundle := spcBundle(t, 0, 0) + bundle := spcBundle(t, 36, -99) out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err != nil { t.Fatalf("Normalize() error = %v", err) @@ -210,15 +274,12 @@ func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) { if got == nil { t.Fatalf("missing day 2 tornado outlook") } - discussions, _, err := parseDiscussions(bundle.Discussions) - if err != nil { - t.Fatalf("parseDiscussions() error = %v", err) + assertDiscussionDays(t, run.Discussions, 2) + if !strings.Contains(run.Discussions[0].Headline, "CORR 1") { + t.Fatalf("day 2 headline = %q, want correction marker", run.Discussions[0].Headline) } - if !strings.Contains(discussions[2].Headline, "CORR 1") { - t.Fatalf("day 2 headline = %q, want correction marker", discussions[2].Headline) - } - if !strings.Contains(discussions[2].Discussion, "CORR 1") { - t.Fatalf("day 2 discussion = %q, want correction marker", discussions[2].Discussion) + if !strings.Contains(run.Discussions[0].Discussion, "CORR 1") { + t.Fatalf("day 2 discussion = %q, want correction marker", run.Discussions[0].Discussion) } } @@ -284,6 +345,19 @@ func TestConvectiveOutlookNormalizerRejectsMissingLabel(t *testing.T) { } } +func TestConvectiveOutlookNormalizerRejectsMissingDiscussion(t *testing.T) { + bundle := spcBundle(t, 38.5, -90.5) + bundle.Discussions = bundle.Discussions[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: discussion for day 1 is required") { + t.Fatalf("error = %q, want missing discussion context", err) + } +} + func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5))) if err != nil { @@ -294,11 +368,22 @@ func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) { t.Fatalf("Marshal(payload) error = %v", err) } got := string(raw) - for _, want := range []string{`"asOf"`, `"outlooks"`, `"containsLocation"`, `"geometry"`} { + for _, want := range []string{`"asOf"`, `"outlooks"`, `"discussions"`, `"containsLocation"`, `"geometry"`} { if !strings.Contains(got, want) { t.Fatalf("payload JSON missing %s: %s", want, got) } } + outlookStart := strings.Index(got, `"outlooks"`) + discussionStart := strings.Index(got, `"discussions"`) + if outlookStart == -1 || discussionStart == -1 || discussionStart <= outlookStart { + t.Fatalf("payload JSON has unexpected outlook/discussion order: %s", got) + } + outlookJSON := got[outlookStart:discussionStart] + for _, unwanted := range []string{`"headline"`, `"summary"`, `"discussion"`} { + if strings.Contains(outlookJSON, unwanted) { + t.Fatalf("outlook JSON exposed polygon-level prose key %s: %s", unwanted, got) + } + } for _, unwanted := range []string{`"products"`, `"fetchedAt"`, `"body"`} { if strings.Contains(got, unwanted) { t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got) @@ -401,6 +486,37 @@ func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) * return nil } +func countOutlooksByDay(outlooks []model.WeatherOutlook, day int) int { + count := 0 + for _, outlook := range outlooks { + if outlook.Day == day { + count++ + } + } + return count +} + +func assertAllOutlooksContainLocation(t *testing.T, outlooks []model.WeatherOutlook) { + t.Helper() + for i, outlook := range outlooks { + if !outlook.ContainsLocation { + t.Fatalf("outlook[%d].ContainsLocation = false, want true", i) + } + } +} + +func assertDiscussionDays(t *testing.T, discussions []model.WeatherOutlookDiscussion, want ...int) { + t.Helper() + if len(discussions) != len(want) { + t.Fatalf("Discussions length = %d, want %d", len(discussions), len(want)) + } + for i, day := range want { + if discussions[i].Day != day { + t.Fatalf("Discussions[%d].Day = %d, want %d", i, discussions[i].Day, day) + } + } +} + 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)