Files
weatherfeeder/internal/providers/nws/forecast_discussion_test.go

480 lines
15 KiB
Go

package nws
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestParseForecastDiscussionSectionHeading(t *testing.T) {
tests := []struct {
name string
line string
wantSection string
wantQualifier string
wantOK bool
}{
{
name: "ellipsis-first key messages",
line: ".KEY MESSAGES... (Through Tonight)",
wantSection: "KEY MESSAGES",
wantQualifier: "(Through Tonight)",
wantOK: true,
},
{
name: "ellipsis-first short term",
line: ".SHORT TERM... (Sunday)",
wantSection: "SHORT TERM",
wantQualifier: "(Sunday)",
wantOK: true,
},
{
name: "ellipsis-first long term",
line: ".LONG TERM... (Monday Through Friday)",
wantSection: "LONG TERM",
wantQualifier: "(Monday Through Friday)",
wantOK: true,
},
{
name: "ellipsis-first aviation without qualifier",
line: ".AVIATION...",
wantSection: "AVIATION",
wantOK: true,
},
{
name: "slash-qualified key messages",
line: ".KEY MESSAGES /Tonight/...",
wantSection: "KEY MESSAGES",
wantQualifier: "Tonight",
wantOK: true,
},
{
name: "slash-qualified short term with outer whitespace",
line: " .SHORT TERM /Through Late Sunday Night/... ",
wantSection: "SHORT TERM",
wantQualifier: "Through Late Sunday Night",
wantOK: true,
},
{
name: "slash-qualified long term",
line: ".LONG TERM /Monday through Next Saturday/...",
wantSection: "LONG TERM",
wantQualifier: "Monday through Next Saturday",
wantOK: true,
},
{
name: "slash-qualified aviation",
line: ".AVIATION /18Z TAFS/...",
wantSection: "AVIATION",
wantQualifier: "18Z TAFS",
wantOK: true,
},
{
name: "generic synopsis",
line: ".SYNOPSIS... Overview",
wantSection: "SYNOPSIS",
wantQualifier: "Overview",
wantOK: true,
},
{
name: "generic update",
line: ".UPDATE...",
wantSection: "UPDATE",
wantOK: true,
},
{
name: "generic marine",
line: ".MARINE...",
wantSection: "MARINE",
wantOK: true,
},
{
name: "generic hydrology",
line: ".HYDROLOGY...",
wantSection: "HYDROLOGY",
wantOK: true,
},
{
name: "office watch advisory heading",
line: ".LSX WATCHES/WARNINGS/ADVISORIES...",
wantSection: "LSX WATCHES/WARNINGS/ADVISORIES",
wantOK: true,
},
{
name: "preliminary point temperatures heading",
line: ".PRELIMINARY POINT TEMPS/POPS ...",
wantSection: "PRELIMINARY POINT TEMPS/POPS",
wantOK: true,
},
{
name: "generic identity punctuation and digits",
line: ".DAY 1 FIRE-WEATHER & HYDROLOGY'S/OUTLOOK...",
wantSection: "DAY 1 FIRE-WEATHER & HYDROLOGY'S/OUTLOOK",
wantOK: true,
},
{
name: "slash qualifier contains slash",
line: ".LONG TERM /Tonight/Sunday/...",
wantSection: "LONG TERM",
wantQualifier: "Tonight/Sunday",
wantOK: true,
},
{
name: "normalized identity whitespace",
line: " .SHORT\t\tTERM \t... (Tonight) ",
wantSection: "SHORT TERM",
wantQualifier: "(Tonight)",
wantOK: true,
},
{
name: "lowercase prose",
line: ".This is ordinary prose...",
wantOK: false,
},
{
name: "empty identity",
line: "....",
wantOK: false,
},
{
name: "punctuation only identity",
line: ".-/&'...",
wantOK: false,
},
{
name: "slash qualifier missing opening separator",
line: ".SHORT TERM Through Tonight/...",
wantOK: false,
},
{
name: "slash qualifier missing required whitespace",
line: ".SHORT TERM/Through Tonight/...",
wantOK: false,
},
{
name: "slash qualifier missing closing terminal",
line: ".SHORT TERM /Through Tonight...",
wantOK: false,
},
{
name: "slash qualifier whitespace only",
line: ".SHORT TERM / /...",
wantOK: false,
},
{
name: "slash qualifier missing final ellipsis",
line: ".SHORT TERM /Through Tonight/..",
wantOK: false,
},
{
name: "slash qualifier has trailing text",
line: ".SHORT TERM /Through Tonight/... more",
wantOK: false,
},
{
name: "missing ellipsis",
line: ".SHORT TERM",
wantOK: false,
},
{
name: "unsupported identity punctuation",
line: ".SHORT TERM:...",
wantOK: false,
},
{
name: "unicode ellipsis",
line: ".SHORT TERM…",
wantOK: false,
},
{
name: "mixed case identity",
line: ".Short Term... (Tonight)",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseForecastDiscussionSectionHeading(tt.line)
if ok != tt.wantOK {
t.Fatalf("parseForecastDiscussionSectionHeading(%q) ok = %t, want %t", tt.line, ok, tt.wantOK)
}
if !ok {
return
}
if got.section != tt.wantSection {
t.Fatalf("section = %q, want %q", got.section, tt.wantSection)
}
if got.qualifier != tt.wantQualifier {
t.Fatalf("qualifier = %q, want %q", got.qualifier, tt.wantQualifier)
}
})
}
}
func TestForecastDiscussionSectionRoleHeadingsParse(t *testing.T) {
if len(forecastDiscussionSectionRoles) != 3 {
t.Fatalf("role registry has %d entries, want 3", len(forecastDiscussionSectionRoles))
}
if _, ok := forecastDiscussionSectionRoles["AVIATION"]; ok {
t.Fatalf("AVIATION must remain boundary-only")
}
for section := range forecastDiscussionSectionRoles {
t.Run(section, func(t *testing.T) {
got, ok := parseForecastDiscussionSectionHeading("." + section + "...")
if !ok {
t.Fatalf("parseForecastDiscussionSectionHeading() did not recognize %q", section)
}
if got.section != section {
t.Fatalf("section = %q, want %q", got.section, section)
}
})
}
}
func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".LONG TERM /Monday through Next Saturday/...",
"Long-term prose.",
".AVIATION /18Z TAFS/...",
"Aviation prose.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm))
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.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
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 TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".SHORT TERM... (Tonight)",
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
}
wantBody := []string{
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
}
if !reflect.DeepEqual(block.body, wantBody) {
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
}
}
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
raw := loadForecastDiscussionSampleHTML(t)
got, err := ParseForecastDiscussionHTML(raw)
if err != nil {
t.Fatalf("ParseForecastDiscussionHTML() error = %v", err)
}
if got.OfficeID != "LSX" {
t.Fatalf("OfficeID = %q, want LSX", got.OfficeID)
}
if got.OfficeName != "National Weather Service Saint Louis MO" {
t.Fatalf("OfficeName = %q", got.OfficeName)
}
if got.Product != "afd" {
t.Fatalf("Product = %q, want afd", got.Product)
}
wantIssuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
if !got.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("IssuedAt = %s, want %s", got.IssuedAt.Format(time.RFC3339), wantIssuedAt.Format(time.RFC3339))
}
wantUpdatedAt := time.Date(2026, 3, 28, 20, 29, 47, 0, time.UTC)
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdatedAt) {
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdatedAt.Format(time.RFC3339))
}
wantMessages := []string{
"Elevated fire danger conditions are expected across a broad area tomorrow afternoon due to breezy southwest winds and low humidity.",
"Very warm temperatures are expected once again Monday and Tuesday, with highs well into the 80s.",
"A cold front late Tuesday or early Wednesday brings our next chance of thunderstorms, followed by a cooldown and possibly more chances for rain later in the week.",
}
if len(got.KeyMessages) != len(wantMessages) {
t.Fatalf("KeyMessages len = %d, want %d", len(got.KeyMessages), len(wantMessages))
}
for i := range wantMessages {
if got.KeyMessages[i] != wantMessages[i] {
t.Fatalf("KeyMessages[%d] = %q, want %q", i, got.KeyMessages[i], wantMessages[i])
}
}
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)
}
if got.ShortTerm.IssuedAt == nil || !got.ShortTerm.IssuedAt.Equal(time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)) {
t.Fatalf("ShortTerm.IssuedAt = %v", got.ShortTerm.IssuedAt)
}
if !strings.Contains(got.ShortTerm.Text, "After a chilly morning") {
t.Fatalf("ShortTerm.Text missing expected prose: %q", got.ShortTerm.Text)
}
if strings.Contains(got.ShortTerm.Text, "BRC") {
t.Fatalf("ShortTerm.Text should not include signature: %q", got.ShortTerm.Text)
}
if strings.Contains(got.ShortTerm.Text, "\n\n\n") {
t.Fatalf("ShortTerm.Text contains unexpected paragraph breaks: %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(time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)) {
t.Fatalf("LongTerm.IssuedAt = %v", got.LongTerm.IssuedAt)
}
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, "WATCHES/WARNINGS/ADVISORIES") {
t.Fatalf("LongTerm.Text includes content from other sections: %q", got.LongTerm.Text)
}
}
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) {
_, err := ParseForecastDiscussionHTML("<html><body><div>no pre block</div></body></html>")
if err == nil {
t.Fatalf("ParseForecastDiscussionHTML() error = nil, want error")
}
if !strings.Contains(err.Error(), "glossaryProduct") {
t.Fatalf("error = %q, want glossaryProduct context", err)
}
}
func TestParseForecastDiscussionTextMissingIssueTime(t *testing.T) {
_, err := ParseForecastDiscussionText("National Weather Service Saint Louis MO\n\n.KEY MESSAGES...\n- Test")
if err == nil {
t.Fatalf("ParseForecastDiscussionText() error = nil, want error")
}
if !strings.Contains(err.Error(), "issue time") {
t.Fatalf("error = %q, want issue time context", err)
}
}
func loadForecastDiscussionSampleHTML(t *testing.T) string {
t.Helper()
path := filepath.Join("testdata", "forecast_discussion_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()
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
}