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

382 lines
12 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: forecastDiscussionSectionKeyMessages,
wantQualifier: "(Through Tonight)",
wantOK: true,
},
{
name: "ellipsis-first short term",
line: ".SHORT TERM... (Sunday)",
wantSection: forecastDiscussionSectionShortTerm,
wantQualifier: "(Sunday)",
wantOK: true,
},
{
name: "ellipsis-first long term",
line: ".LONG TERM... (Monday Through Friday)",
wantSection: forecastDiscussionSectionLongTerm,
wantQualifier: "(Monday Through Friday)",
wantOK: true,
},
{
name: "ellipsis-first aviation without qualifier",
line: ".AVIATION...",
wantSection: forecastDiscussionSectionAviation,
wantOK: true,
},
{
name: "slash-qualified key messages",
line: ".KEY MESSAGES /Tonight/...",
wantSection: forecastDiscussionSectionKeyMessages,
wantQualifier: "Tonight",
wantOK: true,
},
{
name: "slash-qualified short term with outer whitespace",
line: " .SHORT TERM /Through Late Sunday Night/... ",
wantSection: forecastDiscussionSectionShortTerm,
wantQualifier: "Through Late Sunday Night",
wantOK: true,
},
{
name: "slash-qualified long term",
line: ".LONG TERM /Monday through Next Saturday/...",
wantSection: forecastDiscussionSectionLongTerm,
wantQualifier: "Monday through Next Saturday",
wantOK: true,
},
{
name: "slash-qualified aviation",
line: ".AVIATION /18Z TAFS/...",
wantSection: forecastDiscussionSectionAviation,
wantQualifier: "18Z TAFS",
wantOK: true,
},
{
name: "unknown identity",
line: ".SYNOPSIS... Overview",
wantOK: false,
},
{
name: "slash qualifier missing opening slash",
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 final ellipsis",
line: ".SHORT TERM /Through Tonight/..",
wantOK: false,
},
{
name: "slash qualifier has trailing text",
line: ".SHORT TERM /Through Tonight/... more",
wantOK: false,
},
{
name: "lowercase 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 TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".LONG TERM /Monday through Next Saturday/...",
"Long-term prose.",
".AVIATION /18Z TAFS/...",
"Aviation prose.",
}, forecastDiscussionSectionLongTerm)
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no LONG TERM block")
}
if block.heading.qualifier != "Monday through Next Saturday" {
t.Fatalf("qualifier = %q, want normalized slash qualifier", block.heading.qualifier)
}
wantBody := []string{"Long-term prose."}
if !reflect.DeepEqual(block.body, wantBody) {
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
}
}
func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".SHORT TERM... (Tonight)",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionShortTerm)
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
}
if len(block.body) != 0 {
t.Fatalf("body = %#v, want empty body", block.body)
}
}
func TestExtractForecastDiscussionSectionKeepsUnrecognizedHeadingLikeLines(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".SHORT TERM... (Tonight)",
".SYNOPSIS... This unknown section stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/Next Week/...",
"Expected short-term prose.",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionShortTerm)
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
}
wantBody := []string{
".SYNOPSIS... This unknown section stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/Next Week/...",
"Expected short-term prose.",
}
if !reflect.DeepEqual(block.body, wantBody) {
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
}
}
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
raw := loadForecastDiscussionSampleHTML(t)
got, err := ParseForecastDiscussionHTML(raw)
if err != nil {
t.Fatalf("ParseForecastDiscussionHTML() error = %v", err)
}
if got.OfficeID != "LSX" {
t.Fatalf("OfficeID = %q, want LSX", got.OfficeID)
}
if got.OfficeName != "National Weather Service Saint Louis MO" {
t.Fatalf("OfficeName = %q", got.OfficeName)
}
if got.Product != "afd" {
t.Fatalf("Product = %q, want afd", got.Product)
}
wantIssuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
if !got.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("IssuedAt = %s, want %s", got.IssuedAt.Format(time.RFC3339), wantIssuedAt.Format(time.RFC3339))
}
wantUpdatedAt := time.Date(2026, 3, 28, 20, 29, 47, 0, time.UTC)
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdatedAt) {
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdatedAt.Format(time.RFC3339))
}
wantMessages := []string{
"Elevated fire danger conditions are expected across a broad area tomorrow afternoon due to breezy southwest winds and low humidity.",
"Very warm temperatures are expected once again Monday and Tuesday, with highs well into the 80s.",
"A cold front late Tuesday or early Wednesday brings our next chance of thunderstorms, followed by a cooldown and possibly more chances for rain later in the week.",
}
if len(got.KeyMessages) != len(wantMessages) {
t.Fatalf("KeyMessages len = %d, want %d", len(got.KeyMessages), len(wantMessages))
}
for i := range wantMessages {
if got.KeyMessages[i] != wantMessages[i] {
t.Fatalf("KeyMessages[%d] = %q, want %q", i, got.KeyMessages[i], wantMessages[i])
}
}
if got.ShortTerm == nil {
t.Fatalf("ShortTerm is nil")
}
if got.ShortTerm.Qualifier != "(Through Late Sunday Night)" {
t.Fatalf("ShortTerm.Qualifier = %q", got.ShortTerm.Qualifier)
}
if got.ShortTerm.IssuedAt == nil || !got.ShortTerm.IssuedAt.Equal(time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)) {
t.Fatalf("ShortTerm.IssuedAt = %v", got.ShortTerm.IssuedAt)
}
if !strings.Contains(got.ShortTerm.Text, "After a chilly morning") {
t.Fatalf("ShortTerm.Text missing expected prose: %q", got.ShortTerm.Text)
}
if strings.Contains(got.ShortTerm.Text, "BRC") {
t.Fatalf("ShortTerm.Text should not include signature: %q", got.ShortTerm.Text)
}
if strings.Contains(got.ShortTerm.Text, "\n\n\n") {
t.Fatalf("ShortTerm.Text contains unexpected paragraph breaks: %q", got.ShortTerm.Text)
}
if got.LongTerm == nil {
t.Fatalf("LongTerm is nil")
}
if got.LongTerm.Qualifier != "(Monday through Next Saturday)" {
t.Fatalf("LongTerm.Qualifier = %q", got.LongTerm.Qualifier)
}
if got.LongTerm.IssuedAt == nil || !got.LongTerm.IssuedAt.Equal(time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)) {
t.Fatalf("LongTerm.IssuedAt = %v", got.LongTerm.IssuedAt)
}
if !strings.Contains(got.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
t.Fatalf("LongTerm.Text missing expected prose: %q", got.LongTerm.Text)
}
if strings.Contains(got.LongTerm.Text, "AVIATION") || strings.Contains(got.LongTerm.Text, "WATCHES/WARNINGS/ADVISORIES") {
t.Fatalf("LongTerm.Text includes content from other sections: %q", got.LongTerm.Text)
}
}
func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) {
legacy, err := ParseForecastDiscussionHTML(loadForecastDiscussionSampleHTML(t))
if err != nil {
t.Fatalf("ParseForecastDiscussionHTML() legacy error = %v", err)
}
got, err := ParseForecastDiscussionHTML(loadMixedFormatForecastDiscussionSampleHTML(t))
if err != nil {
t.Fatalf("ParseForecastDiscussionHTML() mixed-format error = %v", err)
}
if !reflect.DeepEqual(got.KeyMessages, legacy.KeyMessages) {
t.Fatalf("KeyMessages = %#v, want unchanged %#v", got.KeyMessages, legacy.KeyMessages)
}
if got.ShortTerm == nil {
t.Fatalf("ShortTerm is nil")
}
if got.ShortTerm.Qualifier != "Through Late Sunday Night" {
t.Fatalf("ShortTerm.Qualifier = %q", got.ShortTerm.Qualifier)
}
wantIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)
if got.ShortTerm.IssuedAt == nil || !got.ShortTerm.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("ShortTerm.IssuedAt = %v, want %s", got.ShortTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339))
}
if !strings.Contains(got.ShortTerm.Text, "After a chilly morning") {
t.Fatalf("ShortTerm.Text missing expected prose: %q", got.ShortTerm.Text)
}
if got.LongTerm == nil {
t.Fatalf("LongTerm is nil")
}
if got.LongTerm.Qualifier != "Monday through Next Saturday" {
t.Fatalf("LongTerm.Qualifier = %q", got.LongTerm.Qualifier)
}
if got.LongTerm.IssuedAt == nil || !got.LongTerm.IssuedAt.Equal(wantIssuedAt) {
t.Fatalf("LongTerm.IssuedAt = %v, want %s", got.LongTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339))
}
if !strings.Contains(got.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
t.Fatalf("LongTerm.Text missing expected prose: %q", got.LongTerm.Text)
}
if strings.Contains(got.LongTerm.Text, "AVIATION") || strings.Contains(got.LongTerm.Text, "VFR conditions are expected") {
t.Fatalf("LongTerm.Text includes aviation content: %q", got.LongTerm.Text)
}
}
func TestParseForecastDiscussionHTMLMissingPreBlock(t *testing.T) {
_, err := ParseForecastDiscussionHTML("<html><body><div>no pre block</div></body></html>")
if err == nil {
t.Fatalf("ParseForecastDiscussionHTML() error = nil, want error")
}
if !strings.Contains(err.Error(), "glossaryProduct") {
t.Fatalf("error = %q, want glossaryProduct context", err)
}
}
func TestParseForecastDiscussionTextMissingIssueTime(t *testing.T) {
_, err := ParseForecastDiscussionText("National Weather Service Saint Louis MO\n\n.KEY MESSAGES...\n- Test")
if err == nil {
t.Fatalf("ParseForecastDiscussionText() error = nil, want error")
}
if !strings.Contains(err.Error(), "issue time") {
t.Fatalf("error = %q, want issue time context", err)
}
}
func loadForecastDiscussionSampleHTML(t *testing.T) string {
t.Helper()
path := filepath.Join("testdata", "forecast_discussion_sample.html")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
}
return string(b)
}
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
t.Helper()
raw := loadForecastDiscussionSampleHTML(t)
replacements := []struct {
original string
replacement string
}{
{
original: ".SHORT TERM... (Through Late Sunday Night)",
replacement: ".SHORT TERM /Through Late Sunday Night/...",
},
{
original: ".LONG TERM... (Monday through Next Saturday)",
replacement: ".LONG TERM /Monday through Next Saturday/...",
},
{
original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)",
replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...",
},
}
for _, replacement := range replacements {
if !strings.Contains(raw, replacement.original) {
t.Fatalf("fixture missing heading %q", replacement.original)
}
raw = strings.Replace(raw, replacement.original, replacement.replacement, 1)
}
return raw
}