Add NWS forecast discussion heading regressions
This commit is contained in:
@@ -71,6 +71,70 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) {
|
||||||
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
|
ID: "evt-discussion-mixed-format",
|
||||||
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
|
Source: "nws-discussion-test",
|
||||||
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||||
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
|
Payload: loadMixedFormatForecastDiscussionSampleHTML(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("Normalize() returned nil output")
|
||||||
|
}
|
||||||
|
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, 3, 28, 19, 24, 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.ShortTerm == nil || payload.LongTerm == nil {
|
||||||
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||||
|
}
|
||||||
|
if payload.ShortTerm.Qualifier != "Through Late Sunday Night" {
|
||||||
|
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
||||||
|
}
|
||||||
|
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
||||||
|
t.Fatalf("ShortTerm.Text missing expected prose: %q", payload.ShortTerm.Text)
|
||||||
|
}
|
||||||
|
if payload.LongTerm.Qualifier != "Monday through Next Saturday" {
|
||||||
|
t.Fatalf("LongTerm.Qualifier = %q", payload.LongTerm.Qualifier)
|
||||||
|
}
|
||||||
|
if !strings.Contains(payload.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
|
||||||
|
t.Fatalf("LongTerm.Text missing expected prose: %q", payload.LongTerm.Text)
|
||||||
|
}
|
||||||
|
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "VFR conditions are expected") {
|
||||||
|
t.Fatalf("LongTerm.Text includes aviation content: %q", payload.LongTerm.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "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",
|
||||||
@@ -128,3 +192,34 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw := loadForecastDiscussionSampleHTML(t)
|
||||||
|
replacements := []struct {
|
||||||
|
original string
|
||||||
|
replacement string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
original: ".SHORT TERM... (Through Late Sunday Night)",
|
||||||
|
replacement: ".SHORT TERM /Through Late Sunday Night/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".LONG TERM... (Monday through Next Saturday)",
|
||||||
|
replacement: ".LONG TERM /Monday through Next Saturday/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)",
|
||||||
|
replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, replacement := range replacements {
|
||||||
|
if !strings.Contains(raw, replacement.original) {
|
||||||
|
t.Fatalf("fixture missing heading %q", replacement.original)
|
||||||
|
}
|
||||||
|
raw = strings.Replace(raw, replacement.original, replacement.replacement, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package nws
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -137,6 +138,63 @@ func TestParseForecastDiscussionSectionHeading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
|
||||||
|
block, ok := extractForecastDiscussionSection([]string{
|
||||||
|
".LONG TERM /Monday through Next Saturday/...",
|
||||||
|
"Long-term prose.",
|
||||||
|
".AVIATION /18Z TAFS/...",
|
||||||
|
"Aviation prose.",
|
||||||
|
}, forecastDiscussionSectionLongTerm)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("extractForecastDiscussionSection() found no LONG TERM block")
|
||||||
|
}
|
||||||
|
if block.heading.qualifier != "Monday through Next Saturday" {
|
||||||
|
t.Fatalf("qualifier = %q, want normalized slash qualifier", block.heading.qualifier)
|
||||||
|
}
|
||||||
|
wantBody := []string{"Long-term prose."}
|
||||||
|
if !reflect.DeepEqual(block.body, wantBody) {
|
||||||
|
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing.T) {
|
||||||
|
block, ok := extractForecastDiscussionSection([]string{
|
||||||
|
".SHORT TERM... (Tonight)",
|
||||||
|
".LONG TERM... (Tomorrow)",
|
||||||
|
"Long-term prose.",
|
||||||
|
}, forecastDiscussionSectionShortTerm)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
|
||||||
|
}
|
||||||
|
if len(block.body) != 0 {
|
||||||
|
t.Fatalf("body = %#v, want empty body", block.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractForecastDiscussionSectionKeepsUnrecognizedHeadingLikeLines(t *testing.T) {
|
||||||
|
block, ok := extractForecastDiscussionSection([]string{
|
||||||
|
".SHORT TERM... (Tonight)",
|
||||||
|
".SYNOPSIS... This unknown section stays in the body.",
|
||||||
|
".LONG TERM /Tomorrow/..",
|
||||||
|
".LONG TERM /Tomorrow/Next Week/...",
|
||||||
|
"Expected short-term prose.",
|
||||||
|
".LONG TERM... (Tomorrow)",
|
||||||
|
"Long-term prose.",
|
||||||
|
}, forecastDiscussionSectionShortTerm)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
|
||||||
|
}
|
||||||
|
wantBody := []string{
|
||||||
|
".SYNOPSIS... This unknown section stays in the body.",
|
||||||
|
".LONG TERM /Tomorrow/..",
|
||||||
|
".LONG TERM /Tomorrow/Next Week/...",
|
||||||
|
"Expected short-term prose.",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(block.body, wantBody) {
|
||||||
|
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
|
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
|
||||||
raw := loadForecastDiscussionSampleHTML(t)
|
raw := loadForecastDiscussionSampleHTML(t)
|
||||||
|
|
||||||
@@ -215,6 +273,51 @@ func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) {
|
||||||
|
legacy, err := ParseForecastDiscussionHTML(loadForecastDiscussionSampleHTML(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseForecastDiscussionHTML() legacy error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseForecastDiscussionHTML(loadMixedFormatForecastDiscussionSampleHTML(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseForecastDiscussionHTML() mixed-format error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.KeyMessages, legacy.KeyMessages) {
|
||||||
|
t.Fatalf("KeyMessages = %#v, want unchanged %#v", got.KeyMessages, legacy.KeyMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ShortTerm == nil {
|
||||||
|
t.Fatalf("ShortTerm is nil")
|
||||||
|
}
|
||||||
|
if got.ShortTerm.Qualifier != "Through Late Sunday Night" {
|
||||||
|
t.Fatalf("ShortTerm.Qualifier = %q", got.ShortTerm.Qualifier)
|
||||||
|
}
|
||||||
|
wantIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)
|
||||||
|
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 !strings.Contains(got.ShortTerm.Text, "After a chilly morning") {
|
||||||
|
t.Fatalf("ShortTerm.Text missing expected prose: %q", got.ShortTerm.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.LongTerm == nil {
|
||||||
|
t.Fatalf("LongTerm is nil")
|
||||||
|
}
|
||||||
|
if got.LongTerm.Qualifier != "Monday through Next 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 !strings.Contains(got.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
|
||||||
|
t.Fatalf("LongTerm.Text missing expected prose: %q", got.LongTerm.Text)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.LongTerm.Text, "AVIATION") || strings.Contains(got.LongTerm.Text, "VFR conditions are expected") {
|
||||||
|
t.Fatalf("LongTerm.Text includes aviation content: %q", got.LongTerm.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -245,3 +348,34 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw := loadForecastDiscussionSampleHTML(t)
|
||||||
|
replacements := []struct {
|
||||||
|
original string
|
||||||
|
replacement string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
original: ".SHORT TERM... (Through Late Sunday Night)",
|
||||||
|
replacement: ".SHORT TERM /Through Late Sunday Night/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".LONG TERM... (Monday through Next Saturday)",
|
||||||
|
replacement: ".LONG TERM /Monday through Next Saturday/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)",
|
||||||
|
replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, replacement := range replacements {
|
||||||
|
if !strings.Contains(raw, replacement.original) {
|
||||||
|
t.Fatalf("fixture missing heading %q", replacement.original)
|
||||||
|
}
|
||||||
|
raw = strings.Replace(raw, replacement.original, replacement.replacement, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user