All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
1399 lines
43 KiB
Go
1399 lines
43 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 TestParseForecastDiscussionSectionBlocksRecognizesEmbeddedHeadingsInPreviousDiscussion(t *testing.T) {
|
|
got := parseForecastDiscussionSectionBlocks([]string{
|
|
".PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/",
|
|
"KEY MESSAGES...",
|
|
"* First embedded message.",
|
|
"DISCUSSION...",
|
|
"Discussion prose.",
|
|
"&&",
|
|
})
|
|
want := []forecastDiscussionSectionBlock{
|
|
{
|
|
heading: forecastDiscussionSectionHeading{
|
|
section: "PREV DISCUSSION",
|
|
qualifier: "/Issued 319 PM PDT Sun Aug 2 2026/",
|
|
},
|
|
},
|
|
{
|
|
heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"},
|
|
body: []string{"* First embedded message."},
|
|
},
|
|
{
|
|
heading: forecastDiscussionSectionHeading{section: "DISCUSSION"},
|
|
body: []string{"Discussion prose."},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("blocks = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseForecastDiscussionSectionBlocksKeepsUndottedHeadingLikeLinesOutsideWrappers(t *testing.T) {
|
|
got := parseForecastDiscussionSectionBlocks([]string{
|
|
".SHORT TERM...",
|
|
"Short-term prose.",
|
|
"KEY MESSAGES...",
|
|
"Still short-term prose.",
|
|
})
|
|
want := []forecastDiscussionSectionBlock{
|
|
{
|
|
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
|
|
body: []string{
|
|
"Short-term prose.",
|
|
"KEY MESSAGES...",
|
|
"Still short-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 TestIsForecastDiscussionKeyMessageMetadataLine(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "issued at",
|
|
line: "Issued at 300 PM CDT Sat Mar 28 2026",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "updated at ASCII case insensitive",
|
|
line: "uPdAtEd At 300 PM CDT Sat Mar 28 2026",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "as of weekday preamble",
|
|
line: "As of 1235 PM Sunday...",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "as of ASCII case insensitive",
|
|
line: "aS oF 1235 pm SuNdAy...",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "label without whitespace boundary",
|
|
line: "Updated at300 PM CDT Sat Mar 28 2026",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid timestamp",
|
|
line: "Updated at not a timestamp",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "as of invalid clock",
|
|
line: "As of 2535 PM Sunday...",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "as of invalid weekday",
|
|
line: "As of 1235 PM Someday...",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "as of without terminal ellipsis",
|
|
line: "As of 1235 PM Sunday",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "as of prefix collision",
|
|
line: "As often happens, storms weaken overnight.",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "nonmetadata prose",
|
|
line: "Updated atmospheric conditions remain unsettled.",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isForecastDiscussionKeyMessageMetadataLine(tt.line); got != tt.want {
|
|
t.Fatalf("isForecastDiscussionKeyMessageMetadataLine(%q) = %t, want %t", tt.line, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripForecastDiscussionKeyMessageMarker(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{name: "hyphen", line: "- First message.", want: "First message.", ok: true},
|
|
{name: "hyphen without whitespace", line: "-First message.", want: "First message.", ok: true},
|
|
{name: "asterisk", line: "* First message.", want: "First message.", ok: true},
|
|
{name: "numeric parenthesis", line: "1) First message.", want: "First message.", ok: true},
|
|
{name: "numeric dot", line: "2. Second message.", want: "Second message.", ok: true},
|
|
{name: "parenthesized numeric", line: "(1) First message.", want: "First message.", ok: true},
|
|
{name: "composite hyphen", line: "- 1. First message.", want: "First message.", ok: true},
|
|
{name: "composite asterisk", line: "* 1) First message.", want: "First message.", ok: true},
|
|
{name: "composite parenthesized numeric", line: "- (1) First message.", want: "First message.", ok: true},
|
|
{name: "composite asterisk parenthesized numeric", line: "* (1) First message.", want: "First message.", ok: true},
|
|
{name: "multi digit numeric", line: "12. Twelfth message.", want: "Twelfth message.", ok: true},
|
|
{name: "hyphen only", line: "-", want: "", ok: true},
|
|
{name: "numeric marker only", line: "1)", want: "", ok: true},
|
|
{name: "zero numeric marker", line: "0) Not a marker.", want: "", ok: false},
|
|
{name: "zero parenthesized numeric marker", line: "(0) Not a marker.", want: "", ok: false},
|
|
{name: "overflow numeric marker", line: "999999999999999999999999999999. Too big.", want: "", ok: false},
|
|
{name: "parenthesized numeric marker without boundary", line: "(1)Not a marker.", want: "", ok: false},
|
|
{name: "alphanumeric numeric prefix", line: "1x Not a marker.", want: "", ok: false},
|
|
{name: "numeric marker without boundary", line: "1)Not a marker.", want: "", ok: false},
|
|
{name: "numeric marker with unsupported punctuation", line: "1, Not a marker.", want: "", ok: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := stripForecastDiscussionKeyMessageMarker(tt.line)
|
|
if ok != tt.ok || got != tt.want {
|
|
t.Fatalf("stripForecastDiscussionKeyMessageMarker(%q) = (%q, %t), want (%q, %t)", tt.line, got, ok, tt.want, tt.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseForecastDiscussionKeyMessagesClassifiesListsAndMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "numbered list with continuation",
|
|
body: []string{
|
|
"1) First numbered message.",
|
|
"Wrapped continuation.",
|
|
"2. Second numbered message.",
|
|
},
|
|
want: []string{"First numbered message. Wrapped continuation.", "Second numbered message."},
|
|
},
|
|
{
|
|
name: "asterisk list",
|
|
body: []string{
|
|
"* First asterisk message.",
|
|
"*Second asterisk message.",
|
|
},
|
|
want: []string{"First asterisk message.", "Second asterisk message."},
|
|
},
|
|
{
|
|
name: "unmarked paragraphs",
|
|
body: []string{
|
|
"First paragraph.",
|
|
"Wrapped continuation.",
|
|
"",
|
|
"",
|
|
"Second paragraph.",
|
|
},
|
|
want: []string{"First paragraph. Wrapped continuation.", "Second paragraph."},
|
|
},
|
|
{
|
|
name: "introductory prose before marked items",
|
|
body: []string{
|
|
"Introductory prose.",
|
|
"",
|
|
"- First marked message.",
|
|
"Wrapped continuation.",
|
|
"- Second marked message.",
|
|
},
|
|
want: []string{"Introductory prose.", "First marked message. Wrapped continuation.", "Second marked message."},
|
|
},
|
|
{
|
|
name: "leading issued metadata",
|
|
body: []string{
|
|
"iSsUeD At 300 PM CDT Sat Mar 28 2026",
|
|
"- First message.",
|
|
},
|
|
want: []string{"First message."},
|
|
},
|
|
{
|
|
name: "leading updated metadata",
|
|
body: []string{
|
|
"Updated at 300 PM CDT Sat Mar 28 2026",
|
|
"- First message.",
|
|
},
|
|
want: []string{"First message."},
|
|
},
|
|
{
|
|
name: "leading as of metadata",
|
|
body: []string{
|
|
"As of 1235 PM Sunday...",
|
|
"- First message.",
|
|
},
|
|
want: []string{"First message."},
|
|
},
|
|
{
|
|
name: "updated atmospheric prose remains content",
|
|
body: []string{
|
|
"Updated atmospheric conditions remain unsettled.",
|
|
"Additional detail.",
|
|
},
|
|
want: []string{"Updated atmospheric conditions remain unsettled. Additional detail."},
|
|
},
|
|
{
|
|
name: "invalid updated metadata remains content",
|
|
body: []string{
|
|
"Updated at not a timestamp",
|
|
"Additional detail.",
|
|
},
|
|
want: []string{"Updated at not a timestamp Additional detail."},
|
|
},
|
|
{
|
|
name: "later valid metadata remains content",
|
|
body: []string{
|
|
"- First message.",
|
|
"Updated at 300 PM CDT Sat Mar 28 2026",
|
|
},
|
|
want: []string{"First message. Updated at 300 PM CDT Sat Mar 28 2026"},
|
|
},
|
|
{
|
|
name: "later as of metadata remains content",
|
|
body: []string{
|
|
"- First message.",
|
|
"As of 1235 PM Sunday...",
|
|
},
|
|
want: []string{"First message. As of 1235 PM Sunday..."},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := parseForecastDiscussionKeyMessages(tt.body); !reflect.DeepEqual(got, tt.want) {
|
|
t.Fatalf("KeyMessages = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestParseForecastDiscussionHTMLParsesCrossOfficeKeyMessageFixtures(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.",
|
|
},
|
|
},
|
|
{
|
|
name: "as of preamble",
|
|
filename: "forecast_discussion_rah_as_of_sample.html",
|
|
officeID: "RAH",
|
|
officeName: "National Weather Service Raleigh NC",
|
|
issuedAt: time.Date(2026, 8, 2, 16, 35, 0, 0, time.UTC),
|
|
messages: []string{
|
|
"Scattered storms may produce locally heavy rain this afternoon.",
|
|
"Drier weather arrives Monday.",
|
|
},
|
|
unwanted: []string{
|
|
"As of", "1)", "2)", "DISCUSSION", "Discussion details remain boundary-only content.",
|
|
},
|
|
},
|
|
{
|
|
name: "parenthesized numeric markers",
|
|
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
|
|
officeID: "LWX",
|
|
officeName: "National Weather Service Baltimore MD/Washington DC",
|
|
issuedAt: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
|
|
messages: []string{
|
|
"Thunderstorms remain possible near the Blue Ridge this evening.",
|
|
"Seasonably warm conditions continue Monday.",
|
|
},
|
|
unwanted: []string{
|
|
"(1)", "(2)", "AVIATION", "Aviation details remain boundary-only content.",
|
|
},
|
|
},
|
|
{
|
|
name: "embedded key messages in previous discussion",
|
|
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
|
|
officeID: "MFR",
|
|
officeName: "National Weather Service Medford OR",
|
|
issuedAt: time.Date(2026, 8, 2, 22, 19, 0, 0, time.UTC),
|
|
messages: []string{
|
|
"Heat returns to inland valleys Monday.",
|
|
"Gusty afternoon winds develop east of the Cascades.",
|
|
},
|
|
unwanted: []string{
|
|
"PREV DISCUSSION", "Issued 319 PM", "*", "DISCUSSION", "Discussion details must not be included with key messages.",
|
|
},
|
|
},
|
|
}
|
|
|
|
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) {
|
|
_, 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 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 {
|
|
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
|
|
}
|