diff --git a/internal/normalizers/nws/forecast_discussion_test.go b/internal/normalizers/nws/forecast_discussion_test.go index f60c997..5a3604a 100644 --- a/internal/normalizers/nws/forecast_discussion_test.go +++ b/internal/normalizers/nws/forecast_discussion_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -214,6 +215,98 @@ func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) { } } +func TestForecastDiscussionNormalizerSupportsNumberedAndKeyPointsFixtures(t *testing.T) { + tests := []struct { + name string + filename string + id string + source string + emittedAt time.Time + effectiveAt time.Time + messages []string + }{ + { + name: "numbered key messages", + filename: "forecast_discussion_bgm_numbered_sample.html", + id: "evt-discussion-bgm", + source: "nws-discussion-bgm-test", + emittedAt: time.Date(2026, 4, 10, 17, 31, 0, 0, time.UTC), + effectiveAt: time.Date(2026, 4, 10, 17, 30, 0, 0, time.UTC), + messages: []string{ + "Periods of rain are expected through Saturday, with locally heavier amounts possible.", + "Cooler temperatures return late this weekend.", + }, + }, + { + name: "key points alias", + filename: "forecast_discussion_mfr_key_points_sample.html", + id: "evt-discussion-mfr", + source: "nws-discussion-mfr-test", + emittedAt: time.Date(2026, 4, 10, 19, 46, 0, 0, time.UTC), + effectiveAt: time.Date(2026, 4, 10, 19, 45, 0, 0, time.UTC), + messages: []string{ + "Gusty winds will develop over exposed ridges, especially during the afternoon.", + "Inland valleys remain dry through Saturday.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := event.Event{ + ID: tt.id, + Kind: event.Kind(standards.KindForecastDiscussion), + Source: tt.source, + EmittedAt: tt.emittedAt, + Schema: standards.SchemaRawNWSForecastDiscussionV1, + Payload: loadForecastDiscussionFixtureHTML(t, tt.filename), + } + + out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if out == nil { + t.Fatalf("Normalize() returned nil output") + } + if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) { + t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out) + } + if out.Kind != event.Kind(standards.KindForecastDiscussion) { + t.Fatalf("Kind = %q, want forecast_discussion", out.Kind) + } + if out.Schema != standards.SchemaWeatherForecastDiscussionV1 { + t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1) + } + if out.EffectiveAt == nil || !out.EffectiveAt.Equal(tt.effectiveAt) { + t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, tt.effectiveAt.Format(time.RFC3339)) + } + + payload, ok := out.Payload.(model.WeatherForecastDiscussion) + if !ok { + t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) + } + if !reflect.DeepEqual(payload.KeyMessages, tt.messages) { + t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, tt.messages) + } + + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal(payload) error = %v", err) + } + var fields map[string]any + if err := json.Unmarshal(b, &fields); err != nil { + t.Fatalf("json.Unmarshal(payload) error = %v", err) + } + for _, key := range []string{"nearTerm", "discussion", "aviation", "sections"} { + if _, ok := fields[key]; ok { + t.Fatalf("unexpected key %q in canonical payload", key) + } + } + }) + } +} + func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) { _, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-bad", @@ -283,6 +376,17 @@ func loadForecastDiscussionBOUSampleHTML(t *testing.T) string { return string(b) } +func loadForecastDiscussionFixtureHTML(t *testing.T, filename string) string { + t.Helper() + + path := filepath.Join("..", "..", "providers", "nws", "testdata", filename) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", path, err) + } + return string(b) +} + func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string { t.Helper() diff --git a/internal/providers/nws/forecast_discussion_test.go b/internal/providers/nws/forecast_discussion_test.go index 41942d9..71b11f5 100644 --- a/internal/providers/nws/forecast_discussion_test.go +++ b/internal/providers/nws/forecast_discussion_test.go @@ -1089,6 +1089,75 @@ func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) { } } +func TestParseForecastDiscussionHTMLParsesNumberedAndKeyPointsFixtures(t *testing.T) { + tests := []struct { + name string + filename string + officeID string + officeName string + issuedAt time.Time + messages []string + unwanted []string + }{ + { + name: "numbered key messages", + filename: "forecast_discussion_bgm_numbered_sample.html", + officeID: "BGM", + officeName: "National Weather Service Binghamton NY", + issuedAt: time.Date(2026, 4, 10, 17, 30, 0, 0, time.UTC), + messages: []string{ + "Periods of rain are expected through Saturday, with locally heavier amounts possible.", + "Cooler temperatures return late this weekend.", + }, + unwanted: []string{ + "1)", "2.", "DISCUSSION", "Discussion details remain boundary-only content.", + }, + }, + { + name: "key points alias", + filename: "forecast_discussion_mfr_key_points_sample.html", + officeID: "MFR", + officeName: "National Weather Service Medford OR", + issuedAt: time.Date(2026, 4, 10, 19, 45, 0, 0, time.UTC), + messages: []string{ + "Gusty winds will develop over exposed ridges, especially during the afternoon.", + "Inland valleys remain dry through Saturday.", + }, + unwanted: []string{ + "*", "DISCUSSION", "(Today through Thursday)", "Discussion details must not be included with key points.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseForecastDiscussionHTML(loadForecastDiscussionFixtureHTML(t, tt.filename)) + if err != nil { + t.Fatalf("ParseForecastDiscussionHTML() error = %v", err) + } + if got.OfficeID != tt.officeID || got.OfficeName != tt.officeName { + t.Fatalf("OfficeID=%q OfficeName=%q, want %q %q", got.OfficeID, got.OfficeName, tt.officeID, tt.officeName) + } + if !got.IssuedAt.Equal(tt.issuedAt) { + t.Fatalf("IssuedAt = %s, want %s", got.IssuedAt.Format(time.RFC3339), tt.issuedAt.Format(time.RFC3339)) + } + if !reflect.DeepEqual(got.KeyMessages, tt.messages) { + t.Fatalf("KeyMessages = %#v, want %#v", got.KeyMessages, tt.messages) + } + if got.ShortTerm != nil || got.LongTerm != nil { + t.Fatalf("ShortTerm=%#v LongTerm=%#v, want both nil", got.ShortTerm, got.LongTerm) + } + + allText := strings.Join(got.KeyMessages, "\n") + for _, unwanted := range tt.unwanted { + if strings.Contains(allText, unwanted) { + t.Fatalf("KeyMessages contain %q: %q", unwanted, allText) + } + } + }) + } +} + func TestParseForecastDiscussionHTMLMissingPreBlock(t *testing.T) { _, err := ParseForecastDiscussionHTML("
no pre block
") if err == nil { @@ -1131,6 +1200,17 @@ func loadForecastDiscussionBOUSampleHTML(t *testing.T) string { return string(b) } +func loadForecastDiscussionFixtureHTML(t *testing.T, filename string) string { + t.Helper() + + path := filepath.Join("testdata", filename) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", path, err) + } + return string(b) +} + func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string { t.Helper() diff --git a/internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html b/internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html new file mode 100644 index 0000000..832294e --- /dev/null +++ b/internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html @@ -0,0 +1,26 @@ + + + + +
+FXUS61 KBGM 101730
+AFDBGM
+
+Area Forecast Discussion
+National Weather Service Binghamton NY
+130 PM EDT Fri Apr 10 2026
+
+.KEY MESSAGES...
+1) Periods of rain are expected through Saturday,
+with locally heavier amounts possible.
+2. Cooler temperatures return late this weekend.
+
+.DISCUSSION...
+Discussion details remain boundary-only content.
+
+$$
+
+WFO BGM
+    
+ + diff --git a/internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html b/internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html new file mode 100644 index 0000000..fced686 --- /dev/null +++ b/internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html @@ -0,0 +1,25 @@ + + + + +
+FXUS66 KMFR 101945
+AFDMFR
+
+Area Forecast Discussion
+National Weather Service Medford OR
+1245 PM PDT Fri Apr 10 2026
+
+.KEY POINTS...
+* Gusty winds will develop over exposed ridges,
+especially during the afternoon.
+* Inland valleys remain dry through Saturday.
+.DISCUSSION (Today through Thursday)...
+Discussion details must not be included with key points.
+
+$$
+
+WFO MFR
+    
+ +