Add NWS forecast discussion fixture coverage

This commit is contained in:
2026-08-03 00:22:51 +00:00
parent 6943a5ebc9
commit 3740c779eb
4 changed files with 235 additions and 0 deletions

View File

@@ -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()

View File

@@ -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("<html><body><div>no pre block</div></body></html>")
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()

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<!-- Representative BGM/CTP-style layout; prose is concise edited test data, not an archived product. -->
<html>
<body>
<pre class="glossaryProduct">
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
</pre>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<!-- Representative MFR-style layout; prose is concise edited test data, not an archived product. -->
<html>
<body>
<pre class="glossaryProduct">
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
</pre>
</body>
</html>