Generalize NWS forecast discussion heading parsing

This commit is contained in:
2026-08-02 22:53:00 +00:00
parent 29c65971eb
commit 2b19a121fa
4 changed files with 618 additions and 331 deletions

View File

@@ -20,88 +20,150 @@ func TestParseForecastDiscussionSectionHeading(t *testing.T) {
{
name: "ellipsis-first key messages",
line: ".KEY MESSAGES... (Through Tonight)",
wantSection: forecastDiscussionSectionKeyMessages,
wantSection: "KEY MESSAGES",
wantQualifier: "(Through Tonight)",
wantOK: true,
},
{
name: "ellipsis-first short term",
line: ".SHORT TERM... (Sunday)",
wantSection: forecastDiscussionSectionShortTerm,
wantSection: "SHORT TERM",
wantQualifier: "(Sunday)",
wantOK: true,
},
{
name: "ellipsis-first long term",
line: ".LONG TERM... (Monday Through Friday)",
wantSection: forecastDiscussionSectionLongTerm,
wantSection: "LONG TERM",
wantQualifier: "(Monday Through Friday)",
wantOK: true,
},
{
name: "ellipsis-first aviation without qualifier",
line: ".AVIATION...",
wantSection: forecastDiscussionSectionAviation,
wantSection: "AVIATION",
wantOK: true,
},
{
name: "slash-qualified key messages",
line: ".KEY MESSAGES /Tonight/...",
wantSection: forecastDiscussionSectionKeyMessages,
wantSection: "KEY MESSAGES",
wantQualifier: "Tonight",
wantOK: true,
},
{
name: "slash-qualified short term with outer whitespace",
line: " .SHORT TERM /Through Late Sunday Night/... ",
wantSection: forecastDiscussionSectionShortTerm,
wantSection: "SHORT TERM",
wantQualifier: "Through Late Sunday Night",
wantOK: true,
},
{
name: "slash-qualified long term",
line: ".LONG TERM /Monday through Next Saturday/...",
wantSection: forecastDiscussionSectionLongTerm,
wantSection: "LONG TERM",
wantQualifier: "Monday through Next Saturday",
wantOK: true,
},
{
name: "slash-qualified aviation",
line: ".AVIATION /18Z TAFS/...",
wantSection: forecastDiscussionSectionAviation,
wantSection: "AVIATION",
wantQualifier: "18Z TAFS",
wantOK: true,
},
{
name: "unknown identity",
line: ".SYNOPSIS... Overview",
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: "slash qualifier missing opening slash",
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 closing slash",
line: ".SHORT TERM /Through Tonight...",
wantOK: false,
},
{
name: "slash qualifier contains embedded slash",
line: ".SHORT TERM /Tonight/Sunday/...",
wantOK: false,
},
{
name: "slash qualifier whitespace only",
line: ".SHORT TERM / /...",
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/..",
@@ -113,8 +175,23 @@ func TestParseForecastDiscussionSectionHeading(t *testing.T) {
wantOK: false,
},
{
name: "lowercase identity",
line: ".short term... (Tonight)",
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,
},
}
@@ -138,13 +215,34 @@ func TestParseForecastDiscussionSectionHeading(t *testing.T) {
}
}
func TestForecastDiscussionSectionRoleHeadingsParse(t *testing.T) {
if len(forecastDiscussionSectionRoles) != 3 {
t.Fatalf("role registry has %d entries, want 3", len(forecastDiscussionSectionRoles))
}
if _, ok := forecastDiscussionSectionRoles["AVIATION"]; ok {
t.Fatalf("AVIATION must remain boundary-only")
}
for section := range forecastDiscussionSectionRoles {
t.Run(section, func(t *testing.T) {
got, ok := parseForecastDiscussionSectionHeading("." + section + "...")
if !ok {
t.Fatalf("parseForecastDiscussionSectionHeading() did not recognize %q", section)
}
if got.section != section {
t.Fatalf("section = %q, want %q", got.section, section)
}
})
}
}
func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".LONG TERM /Monday through Next Saturday/...",
"Long-term prose.",
".AVIATION /18Z TAFS/...",
"Aviation prose.",
}, forecastDiscussionSectionLongTerm)
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no LONG TERM block")
}
@@ -162,7 +260,7 @@ func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing
".SHORT TERM... (Tonight)",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionShortTerm)
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
}
@@ -171,23 +269,23 @@ func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing
}
}
func TestExtractForecastDiscussionSectionKeepsUnrecognizedHeadingLikeLines(t *testing.T) {
func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".SHORT TERM... (Tonight)",
".SYNOPSIS... This unknown section stays in the body.",
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/Next Week/...",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionShortTerm)
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
}
wantBody := []string{
".SYNOPSIS... This unknown section stays in the body.",
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/Next Week/...",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
}
if !reflect.DeepEqual(block.body, wantBody) {