750 lines
23 KiB
Go
750 lines
23 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 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 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 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 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
|
|
}
|
|
|
|
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
|
|
}
|