From 0b5eaf46f4b2d9e9961e44dd1442d1f7ef25d8aa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 2 Aug 2026 23:01:11 +0000 Subject: [PATCH] Add cross-office NWS forecast discussion coverage --- .../nws/forecast_discussion_test.go | 90 +++++++++++++++++++ .../providers/nws/forecast_discussion_test.go | 65 ++++++++++++++ .../forecast_discussion_bou_sample.html | 48 ++++++++++ 3 files changed, 203 insertions(+) create mode 100644 internal/providers/nws/testdata/forecast_discussion_bou_sample.html diff --git a/internal/normalizers/nws/forecast_discussion_test.go b/internal/normalizers/nws/forecast_discussion_test.go index b812040..f60c997 100644 --- a/internal/normalizers/nws/forecast_discussion_test.go +++ b/internal/normalizers/nws/forecast_discussion_test.go @@ -135,6 +135,85 @@ func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) { } } +func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) { + in := event.Event{ + ID: "evt-discussion-bou", + Kind: event.Kind(standards.KindForecastDiscussion), + Source: "nws-discussion-bou-test", + EmittedAt: time.Date(2026, 4, 7, 19, 1, 0, 0, time.UTC), + Schema: standards.SchemaRawNWSForecastDiscussionV1, + Payload: loadForecastDiscussionBOUSampleHTML(t), + } + + 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) + } + wantEffectiveAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC) + if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) { + t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339)) + } + + payload, ok := out.Payload.(model.WeatherForecastDiscussion) + if !ok { + t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) + } + if payload.OfficeID != "BOU" || payload.OfficeName != "National Weather Service Denver CO" { + t.Fatalf("OfficeID=%q OfficeName=%q", payload.OfficeID, payload.OfficeName) + } + wantMessages := []string{ + "Strong winds are expected along the Front Range this evening.", + "Cooler temperatures arrive on Wednesday.", + } + if len(payload.KeyMessages) != len(wantMessages) { + t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, wantMessages) + } + for i := range wantMessages { + if payload.KeyMessages[i] != wantMessages[i] { + t.Fatalf("KeyMessages[%d] = %q, want %q", i, payload.KeyMessages[i], wantMessages[i]) + } + } + if payload.ShortTerm == nil || payload.LongTerm == nil { + t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm) + } + if payload.ShortTerm.Qualifier != "(Tonight through Wednesday)" || payload.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." { + t.Fatalf("ShortTerm = %#v", payload.ShortTerm) + } + if payload.LongTerm.Qualifier != "(Thursday through Saturday)" || payload.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." { + t.Fatalf("LongTerm = %#v", payload.LongTerm) + } + if payload.ShortTerm.IssuedAt == nil || payload.LongTerm.IssuedAt == nil || + !payload.ShortTerm.IssuedAt.Equal(wantEffectiveAt) || !payload.LongTerm.IssuedAt.Equal(wantEffectiveAt) { + t.Fatalf("section issue times = short %v long %v, want %s", payload.ShortTerm.IssuedAt, payload.LongTerm.IssuedAt, wantEffectiveAt.Format(time.RFC3339)) + } + + b, err := json.Marshal(out.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{"aviation", "discussion", "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", @@ -193,6 +272,17 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string { return string(b) } +func loadForecastDiscussionBOUSampleHTML(t *testing.T) string { + t.Helper() + + path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_bou_sample.html") + 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 16b48e6..2d5d322 100644 --- a/internal/providers/nws/forecast_discussion_test.go +++ b/internal/providers/nws/forecast_discussion_test.go @@ -553,6 +553,60 @@ func TestParseForecastDiscussionTextSectionKeepsArbitraryDashedProse(t *testing. } } +func TestParseForecastDiscussionHTMLParsesCrossOfficeLayout(t *testing.T) { + got, err := ParseForecastDiscussionHTML(loadForecastDiscussionBOUSampleHTML(t)) + if err != nil { + t.Fatalf("ParseForecastDiscussionHTML() error = %v", err) + } + if got.OfficeID != "BOU" || got.OfficeName != "National Weather Service Denver CO" { + t.Fatalf("OfficeID=%q OfficeName=%q", got.OfficeID, got.OfficeName) + } + wantIssuedAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC) + if !got.IssuedAt.Equal(wantIssuedAt) { + t.Fatalf("IssuedAt = %s, want %s", got.IssuedAt.Format(time.RFC3339), wantIssuedAt.Format(time.RFC3339)) + } + wantMessages := []string{ + "Strong winds are expected along the Front Range this evening.", + "Cooler temperatures arrive on Wednesday.", + } + if !reflect.DeepEqual(got.KeyMessages, wantMessages) { + t.Fatalf("KeyMessages = %#v, want %#v", got.KeyMessages, wantMessages) + } + + if got.ShortTerm == nil { + t.Fatalf("ShortTerm is nil") + } + if got.ShortTerm.Qualifier != "(Tonight through Wednesday)" { + t.Fatalf("ShortTerm.Qualifier = %q", got.ShortTerm.Qualifier) + } + if got.ShortTerm.IssuedAt == nil || !got.ShortTerm.IssuedAt.Equal(wantIssuedAt) { + t.Fatalf("ShortTerm.IssuedAt = %v, want %s", got.ShortTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339)) + } + if got.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." { + t.Fatalf("ShortTerm.Text = %q", got.ShortTerm.Text) + } + + if got.LongTerm == nil { + t.Fatalf("LongTerm is nil") + } + if got.LongTerm.Qualifier != "(Thursday through Saturday)" { + t.Fatalf("LongTerm.Qualifier = %q", got.LongTerm.Qualifier) + } + if got.LongTerm.IssuedAt == nil || !got.LongTerm.IssuedAt.Equal(wantIssuedAt) { + t.Fatalf("LongTerm.IssuedAt = %v, want %s", got.LongTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339)) + } + if got.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." { + t.Fatalf("LongTerm.Text = %q", got.LongTerm.Text) + } + + allText := strings.Join(append(append([]string(nil), got.KeyMessages...), got.ShortTerm.Text, got.LongTerm.Text), "\n") + for _, unwanted := range []string{"Changed Discussion", "Updated at", "Issued at", "ISSUED AT", "AVIATION", "VFR conditions"} { + if strings.Contains(allText, unwanted) { + t.Fatalf("parsed content includes %q: %q", unwanted, allText) + } + } +} + func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) { raw := loadForecastDiscussionSampleHTML(t) @@ -707,6 +761,17 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string { return string(b) } +func loadForecastDiscussionBOUSampleHTML(t *testing.T) string { + t.Helper() + + path := filepath.Join("testdata", "forecast_discussion_bou_sample.html") + 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_bou_sample.html b/internal/providers/nws/testdata/forecast_discussion_bou_sample.html new file mode 100644 index 0000000..5186aa6 --- /dev/null +++ b/internal/providers/nws/testdata/forecast_discussion_bou_sample.html @@ -0,0 +1,48 @@ + + + +
+FXUS65 KBOU 071900
+AFDBOU
+
+Area Forecast Discussion
+National Weather Service Denver CO
+100 PM MDT Tue Apr 7 2026
+
+.KEY MESSAGES...
+-- Changed Discussion --
+Updated at 100 PM MDT Tue Apr 7 2026
+- Strong winds are expected along the Front Range this evening.
+- Cooler temperatures arrive on Wednesday.
+-- End Changed Discussion --
+
+&&
+
+.SHORT TERM...
+(Tonight through Wednesday)
+Issued at 100 PM MDT Tue Apr 7 2026
+
+Gusty west winds will continue through the evening before decreasing overnight.
+
+&&
+
+.LONG TERM...
+(Thursday through Saturday)
+ISSUED AT 100 PM MDT Tue Apr 7 2026
+
+Warmer and drier conditions return Thursday, followed by a chance of showers Friday.
+
+&&
+
+.AVIATION...
+
+VFR conditions are expected at Denver-area terminals through Wednesday morning.
+
+&&
+
+$$
+
+WFO BOU
+    
+ +