Add NWS forecast discussion fixture coverage
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
||||||
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
ID: "evt-discussion-bad",
|
ID: "evt-discussion-bad",
|
||||||
@@ -283,6 +376,17 @@ func loadForecastDiscussionBOUSampleHTML(t *testing.T) string {
|
|||||||
return string(b)
|
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 {
|
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestParseForecastDiscussionHTMLMissingPreBlock(t *testing.T) {
|
||||||
_, err := ParseForecastDiscussionHTML("<html><body><div>no pre block</div></body></html>")
|
_, err := ParseForecastDiscussionHTML("<html><body><div>no pre block</div></body></html>")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1131,6 +1200,17 @@ func loadForecastDiscussionBOUSampleHTML(t *testing.T) string {
|
|||||||
return string(b)
|
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 {
|
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal file
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal 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>
|
||||||
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal file
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal 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>
|
||||||
Reference in New Issue
Block a user