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

1002 lines
31 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: "parenthesized terminal discussion",
line: ".DISCUSSION (Today through Thursday)...",
wantSection: "DISCUSSION",
wantQualifier: "(Today through Thursday)",
wantOK: true,
},
{
name: "parenthesized terminal mapped section",
line: ".SHORT TERM (Tonight)...",
wantSection: "SHORT TERM",
wantQualifier: "(Tonight)",
wantOK: true,
},
{
name: "parenthesized terminal normalized identity whitespace",
line: " .SHORT\tTERM (Tonight)... ",
wantSection: "SHORT TERM",
wantQualifier: "(Tonight)",
wantOK: true,
},
{
name: "parenthesized terminal qualifier punctuation and nesting",
line: ".DISCUSSION (Today (and Thursday), 70% chance!)...",
wantSection: "DISCUSSION",
wantQualifier: "(Today (and Thursday), 70% chance!)",
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,
},
{
name: "parenthesized terminal missing separator whitespace",
line: ".DISCUSSION(Today)...",
wantOK: false,
},
{
name: "parenthesized terminal empty qualifier",
line: ".DISCUSSION ( )...",
wantOK: false,
},
{
name: "parenthesized terminal missing final parenthesis",
line: ".DISCUSSION (Today...",
wantOK: false,
},
{
name: "parenthesized terminal misplaced parentheses",
line: ".DISCUSSION Today)...",
wantOK: false,
},
{
name: "parenthesized terminal trailing text",
line: ".DISCUSSION (Today)... extra",
wantOK: false,
},
{
name: "parenthesized terminal unsupported identity punctuation",
line: ".DISCUSSION: (Today)...",
wantOK: false,
},
{
name: "parenthesized terminal lowercase identity",
line: ".discussion (Today)...",
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) != 4 {
t.Fatalf("role registry has %d entries, want 4", len(forecastDiscussionSectionRoles))
}
for _, tt := range []struct {
section string
role forecastDiscussionSectionRole
}{
{section: "KEY MESSAGES", role: forecastDiscussionSectionRoleKeyMessages},
{section: "KEY POINTS", role: forecastDiscussionSectionRoleKeyMessages},
{section: "SHORT TERM", role: forecastDiscussionSectionRoleShortTerm},
{section: "LONG TERM", role: forecastDiscussionSectionRoleLongTerm},
} {
role, ok := forecastDiscussionSectionRoles[tt.section]
if !ok || role != tt.role {
t.Fatalf("role for %q = %d, present = %t; want %d", tt.section, role, ok, tt.role)
}
}
for _, section := range []string{"NEAR TERM", "DISCUSSION", "AVIATION"} {
if _, ok := forecastDiscussionSectionRoles[section]; ok {
t.Fatalf("%s must remain boundary-only", section)
}
}
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 TestParseForecastDiscussionSectionBlocksRetainsOrderAndEmptyBodies(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM... (Tonight)",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
})
want := []forecastDiscussionSectionBlock{
{heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"}},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
body: []string{"Long-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksStopsAtTerminators(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
"Preamble is ignored.",
".KEY MESSAGES...",
"- First message.",
"&&",
".SHORT TERM...",
"Short-term prose.",
".LSX WATCHES/WARNINGS/ADVISORIES...",
"Watch text is ignored.",
"$$",
"WFO LSX",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"},
body: []string{"- First message."},
},
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
body: []string{"Short-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksUsesGenericHeadingsAsBoundaries(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM...",
"Short-term prose.",
".SYNOPSIS...",
"Synopsis prose.",
".LONG TERM /Tomorrow/...",
"Long-term prose.",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
body: []string{"Short-term prose."},
},
{
heading: forecastDiscussionSectionHeading{section: "SYNOPSIS"},
body: []string{"Synopsis prose."},
},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "Tomorrow"},
body: []string{"Long-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksUsesParenthesizedTerminalHeadingAsBoundary(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM... (Tonight)",
"Short-term prose.",
".DISCUSSION (Today through Thursday)...",
"Discussion prose.",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"},
body: []string{"Short-term prose."},
},
{
heading: forecastDiscussionSectionHeading{section: "DISCUSSION", qualifier: "(Today through Thursday)"},
body: []string{"Discussion prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksKeepsMalformedHeadingLikeLines(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]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.",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"},
body: []string{
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
},
},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
body: []string{"Long-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionTextKeepsFirstMappedSections(t *testing.T) {
text := strings.Join([]string{
"AFDLSX",
"National Weather Service Saint Louis MO",
"224 PM CDT Sat Mar 28 2026",
"",
".KEY MESSAGES...",
"&&",
".KEY MESSAGES...",
"- Second key message.",
"&&",
".SHORT TERM... (First short term)",
"First short-term prose.",
"&&",
".SHORT TERM... (Second short term)",
"Second short-term prose.",
"&&",
".LONG TERM... (First long term)",
"First long-term prose.",
"&&",
".LONG TERM... (Second long term)",
"Second long-term prose.",
}, "\n")
got, err := ParseForecastDiscussionText(text)
if err != nil {
t.Fatalf("ParseForecastDiscussionText() error = %v", err)
}
if len(got.KeyMessages) != 0 {
t.Fatalf("KeyMessages = %#v, want empty first block", got.KeyMessages)
}
if got.ShortTerm == nil || got.ShortTerm.Qualifier != "(First short term)" || got.ShortTerm.Text != "First short-term prose." {
t.Fatalf("ShortTerm = %#v, want first section", got.ShortTerm)
}
if got.LongTerm == nil || got.LongTerm.Qualifier != "(First long term)" || got.LongTerm.Text != "First long-term prose." {
t.Fatalf("LongTerm = %#v, want first section", got.LongTerm)
}
}
func TestParseForecastDiscussionTextKeepsFirstKeyMessageRole(t *testing.T) {
tests := []struct {
name string
lines []string
want []string
}{
{
name: "key points only",
lines: []string{
".KEY POINTS...",
"- First key point.",
},
want: []string{"First key point."},
},
{
name: "key points before key messages",
lines: []string{
".KEY POINTS...",
"- First key point.",
"&&",
".KEY MESSAGES...",
"- Later key message.",
},
want: []string{"First key point."},
},
{
name: "key messages before key points",
lines: []string{
".KEY MESSAGES...",
"- First key message.",
"&&",
".KEY POINTS...",
"- Later key point.",
},
want: []string{"First key message."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
text := strings.Join(append([]string{
"AFDLSX",
"National Weather Service Saint Louis MO",
"224 PM CDT Sat Mar 28 2026",
"",
}, tt.lines...), "\n")
got, err := ParseForecastDiscussionText(text)
if err != nil {
t.Fatalf("ParseForecastDiscussionText() error = %v", err)
}
if !reflect.DeepEqual(got.KeyMessages, tt.want) {
t.Fatalf("KeyMessages = %#v, want %#v", got.KeyMessages, tt.want)
}
})
}
}
func TestParseForecastDiscussionTextKeepsUnmappedSectionsBoundaryOnly(t *testing.T) {
text := strings.Join([]string{
"AFDLSX",
"National Weather Service Saint Louis MO",
"224 PM CDT Sat Mar 28 2026",
"",
".SHORT TERM... (Tonight)",
"Short-term prose.",
".NEAR TERM... (This Evening)",
"Near-term prose.",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, "\n")
got, err := ParseForecastDiscussionText(text)
if err != nil {
t.Fatalf("ParseForecastDiscussionText() error = %v", err)
}
if got.ShortTerm == nil || got.ShortTerm.Text != "Short-term prose." {
t.Fatalf("ShortTerm = %#v, want only short-term prose", got.ShortTerm)
}
if got.LongTerm == nil || got.LongTerm.Text != "Long-term prose." {
t.Fatalf("LongTerm = %#v, want only long-term prose", got.LongTerm)
}
}
func TestParseForecastDiscussionTextSectionKeepsHeadingQualifiers(t *testing.T) {
tests := []struct {
name string
heading string
wantQualifier string
}{
{
name: "legacy heading",
heading: ".SHORT TERM... (Tonight)",
wantQualifier: "(Tonight)",
},
{
name: "slash-qualified heading",
heading: ".SHORT TERM /Tonight/...",
wantQualifier: "Tonight",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, tt.heading),
body: []string{"Section prose."},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Qualifier != tt.wantQualifier {
t.Fatalf("Qualifier = %q, want %q", section.Qualifier, tt.wantQualifier)
}
if section.Text != "Section prose." {
t.Fatalf("Text = %q", section.Text)
}
})
}
}
func TestParseForecastDiscussionTextSectionReadsNextLineQualifierAndIssuedAt(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
body: []string{
"(Tonight)",
"Issued at 219 PM CDT Sat Mar 28 2026",
"Section prose.",
},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Qualifier != "(Tonight)" {
t.Fatalf("Qualifier = %q", section.Qualifier)
}
wantIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)
if section.IssuedAt == nil || !section.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("IssuedAt = %v, want %s", section.IssuedAt, wantIssuedAt.Format(time.RFC3339))
}
if section.Text != "Section prose." {
t.Fatalf("Text = %q", section.Text)
}
}
func TestParseForecastDiscussionTextSectionRecognizesUppercaseIssuedAt(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
body: []string{
"ISSUED AT 219 PM CDT Sat Mar 28 2026",
"Section prose.",
},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
wantIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)
if section.IssuedAt == nil || !section.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("IssuedAt = %v, want %s", section.IssuedAt, wantIssuedAt.Format(time.RFC3339))
}
}
func TestParseForecastDiscussionTextSectionKeepsHeadingQualifierBeforeParentheticalProse(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM... (From Heading)"),
body: []string{"(Parenthetical prose.)"},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Qualifier != "(From Heading)" {
t.Fatalf("Qualifier = %q", section.Qualifier)
}
if section.Text != "(Parenthetical prose.)" {
t.Fatalf("Text = %q", section.Text)
}
}
func TestParseForecastDiscussionTextSectionHandlesEmptyAndInvalidPreambles(t *testing.T) {
t.Run("empty section", func(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Qualifier != "" || section.IssuedAt != nil || section.Text != "" {
t.Fatalf("section = %#v, want empty section", section)
}
})
t.Run("invalid issue time", func(t *testing.T) {
_, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
body: []string{"ISSUED AT not a timestamp"},
})
if err == nil {
t.Fatalf("parseForecastDiscussionTextSection() error = nil")
}
if !strings.Contains(err.Error(), "parse section issuedAt") {
t.Fatalf("error = %q, want section issue-time context", err)
}
})
}
func TestParseForecastDiscussionTextSectionRemovesPresentationMarkers(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
body: []string{
"-- Changed Discussion --",
"(Tonight)",
"Issued at 219 PM CDT Sat Mar 28 2026",
"Section prose.",
"-- END CHANGED DISCUSSION --",
},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Qualifier != "(Tonight)" || section.Text != "Section prose." {
t.Fatalf("section = %#v", section)
}
if strings.Contains(section.Text, "Changed Discussion") {
t.Fatalf("Text contains presentation marker: %q", section.Text)
}
}
func TestParseForecastDiscussionKeyMessagesRemovesMarkersAndLeadingMetadata(t *testing.T) {
got := parseForecastDiscussionKeyMessages([]string{
"-- changed discussion --",
"Updated at 300 PM CDT Sat Mar 28 2026",
"- First message.",
"Updated at after the first message.",
"-- End Changed Discussion --",
"- Second message.",
})
want := []string{
"First message. Updated at after the first message.",
"Second message.",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("KeyMessages = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionTextSectionKeepsArbitraryDashedProse(t *testing.T) {
section, err := parseForecastDiscussionTextSection(forecastDiscussionSectionBlock{
heading: mustParseForecastDiscussionSectionHeading(t, ".SHORT TERM..."),
body: []string{"-- Changed Discussion -- extra", "Section prose."},
})
if err != nil {
t.Fatalf("parseForecastDiscussionTextSection() error = %v", err)
}
if section.Text != "-- Changed Discussion -- extra Section prose." {
t.Fatalf("Text = %q", section.Text)
}
}
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)
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 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()
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
}
func mustParseForecastDiscussionSectionHeading(t *testing.T, line string) forecastDiscussionSectionHeading {
t.Helper()
heading, ok := parseForecastDiscussionSectionHeading(line)
if !ok {
t.Fatalf("parseForecastDiscussionSectionHeading(%q) did not recognize heading", line)
}
return heading
}