All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
456 lines
16 KiB
Go
456 lines
16 KiB
Go
package nws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
|
)
|
|
|
|
func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|
rawHTML := loadForecastDiscussionSampleHTML(t)
|
|
|
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
|
ID: "evt-discussion-1",
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: "nws-discussion-test",
|
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: rawHTML,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Normalize() error = %v", err)
|
|
}
|
|
if out == nil {
|
|
t.Fatalf("Normalize() returned nil output")
|
|
}
|
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
|
}
|
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
|
}
|
|
|
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
|
if !ok {
|
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
|
}
|
|
if payload.OfficeID != "LSX" {
|
|
t.Fatalf("OfficeID = %q, want LSX", payload.OfficeID)
|
|
}
|
|
if payload.Product != model.ForecastDiscussionProductAFD {
|
|
t.Fatalf("Product = %q, want %q", payload.Product, model.ForecastDiscussionProductAFD)
|
|
}
|
|
if len(payload.KeyMessages) != 3 {
|
|
t.Fatalf("KeyMessages len = %d, want 3", len(payload.KeyMessages))
|
|
}
|
|
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
|
}
|
|
if payload.ShortTerm.Qualifier != "(Through Late Sunday Night)" {
|
|
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
|
}
|
|
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
|
t.Fatalf("ShortTerm.Text = %q, want normalized prose", payload.ShortTerm.Text)
|
|
}
|
|
if strings.Contains(payload.ShortTerm.Text, "BRC") {
|
|
t.Fatalf("ShortTerm.Text contains signature: %q", payload.ShortTerm.Text)
|
|
}
|
|
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "WATCHES/WARNINGS/ADVISORIES") {
|
|
t.Fatalf("LongTerm.Text includes downstream sections: %q", payload.LongTerm.Text)
|
|
}
|
|
wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
|
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) {
|
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
|
ID: "evt-discussion-mixed-format",
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: "nws-discussion-test",
|
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: loadMixedFormatForecastDiscussionSampleHTML(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Normalize() error = %v", err)
|
|
}
|
|
if out == nil {
|
|
t.Fatalf("Normalize() returned nil output")
|
|
}
|
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
|
}
|
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
|
}
|
|
wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
|
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
|
}
|
|
|
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
|
if !ok {
|
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
|
}
|
|
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
|
}
|
|
if payload.ShortTerm.Qualifier != "Through Late Sunday Night" {
|
|
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
|
}
|
|
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
|
t.Fatalf("ShortTerm.Text missing expected prose: %q", payload.ShortTerm.Text)
|
|
}
|
|
if payload.LongTerm.Qualifier != "Monday through Next Saturday" {
|
|
t.Fatalf("LongTerm.Qualifier = %q", payload.LongTerm.Qualifier)
|
|
}
|
|
if !strings.Contains(payload.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
|
|
t.Fatalf("LongTerm.Text missing expected prose: %q", payload.LongTerm.Text)
|
|
}
|
|
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "VFR conditions are expected") {
|
|
t.Fatalf("LongTerm.Text includes aviation content: %q", payload.LongTerm.Text)
|
|
}
|
|
|
|
b, err := json.Marshal(out.Payload)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(b, &fields); err != nil {
|
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
|
}
|
|
for _, key := range []string{"aviation", "sections"} {
|
|
if _, ok := fields[key]; ok {
|
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) {
|
|
in := event.Event{
|
|
ID: "evt-discussion-bou",
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: "nws-discussion-bou-test",
|
|
EmittedAt: time.Date(2026, 4, 7, 19, 1, 0, 0, time.UTC),
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: loadForecastDiscussionBOUSampleHTML(t),
|
|
}
|
|
|
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
|
if err != nil {
|
|
t.Fatalf("Normalize() error = %v", err)
|
|
}
|
|
if out == nil {
|
|
t.Fatalf("Normalize() returned nil output")
|
|
}
|
|
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
|
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
|
}
|
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
|
}
|
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
|
}
|
|
wantEffectiveAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC)
|
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
|
}
|
|
|
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
|
if !ok {
|
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
|
}
|
|
if payload.OfficeID != "BOU" || payload.OfficeName != "National Weather Service Denver CO" {
|
|
t.Fatalf("OfficeID=%q OfficeName=%q", payload.OfficeID, payload.OfficeName)
|
|
}
|
|
wantMessages := []string{
|
|
"Strong winds are expected along the Front Range this evening.",
|
|
"Cooler temperatures arrive on Wednesday.",
|
|
}
|
|
if len(payload.KeyMessages) != len(wantMessages) {
|
|
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, wantMessages)
|
|
}
|
|
for i := range wantMessages {
|
|
if payload.KeyMessages[i] != wantMessages[i] {
|
|
t.Fatalf("KeyMessages[%d] = %q, want %q", i, payload.KeyMessages[i], wantMessages[i])
|
|
}
|
|
}
|
|
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
|
}
|
|
if payload.ShortTerm.Qualifier != "(Tonight through Wednesday)" || payload.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." {
|
|
t.Fatalf("ShortTerm = %#v", payload.ShortTerm)
|
|
}
|
|
if payload.LongTerm.Qualifier != "(Thursday through Saturday)" || payload.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." {
|
|
t.Fatalf("LongTerm = %#v", payload.LongTerm)
|
|
}
|
|
if payload.ShortTerm.IssuedAt == nil || payload.LongTerm.IssuedAt == nil ||
|
|
!payload.ShortTerm.IssuedAt.Equal(wantEffectiveAt) || !payload.LongTerm.IssuedAt.Equal(wantEffectiveAt) {
|
|
t.Fatalf("section issue times = short %v long %v, want %s", payload.ShortTerm.IssuedAt, payload.LongTerm.IssuedAt, wantEffectiveAt.Format(time.RFC3339))
|
|
}
|
|
|
|
b, err := json.Marshal(out.Payload)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(b, &fields); err != nil {
|
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
|
}
|
|
for _, key := range []string{"aviation", "discussion", "sections"} {
|
|
if _, ok := fields[key]; ok {
|
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForecastDiscussionNormalizerSupportsCrossOfficeKeyMessageFixtures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
id string
|
|
source string
|
|
emittedAt time.Time
|
|
effectiveAt time.Time
|
|
messages []string
|
|
}{
|
|
{
|
|
name: "numbered key messages",
|
|
filename: "forecast_discussion_bgm_numbered_sample.html",
|
|
id: "evt-discussion-bgm",
|
|
source: "nws-discussion-bgm-test",
|
|
emittedAt: time.Date(2026, 4, 10, 17, 31, 0, 0, time.UTC),
|
|
effectiveAt: 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.",
|
|
},
|
|
},
|
|
{
|
|
name: "key points alias",
|
|
filename: "forecast_discussion_mfr_key_points_sample.html",
|
|
id: "evt-discussion-mfr",
|
|
source: "nws-discussion-mfr-test",
|
|
emittedAt: time.Date(2026, 4, 10, 19, 46, 0, 0, time.UTC),
|
|
effectiveAt: 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.",
|
|
},
|
|
},
|
|
{
|
|
name: "as of preamble",
|
|
filename: "forecast_discussion_rah_as_of_sample.html",
|
|
id: "evt-discussion-rah",
|
|
source: "nws-discussion-rah-test",
|
|
emittedAt: time.Date(2026, 8, 2, 16, 36, 0, 0, time.UTC),
|
|
effectiveAt: 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.",
|
|
},
|
|
},
|
|
{
|
|
name: "parenthesized numeric markers",
|
|
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
|
|
id: "evt-discussion-lwx",
|
|
source: "nws-discussion-lwx-test",
|
|
emittedAt: time.Date(2026, 8, 2, 18, 1, 0, 0, time.UTC),
|
|
effectiveAt: 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.",
|
|
},
|
|
},
|
|
{
|
|
name: "embedded key messages in previous discussion",
|
|
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
|
|
id: "evt-discussion-mfr-previous",
|
|
source: "nws-discussion-mfr-previous-test",
|
|
emittedAt: time.Date(2026, 8, 2, 22, 20, 0, 0, time.UTC),
|
|
effectiveAt: 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.",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
in := event.Event{
|
|
ID: tt.id,
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: tt.source,
|
|
EmittedAt: tt.emittedAt,
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: loadForecastDiscussionFixtureHTML(t, tt.filename),
|
|
}
|
|
|
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
|
if err != nil {
|
|
t.Fatalf("Normalize() error = %v", err)
|
|
}
|
|
if out == nil {
|
|
t.Fatalf("Normalize() returned nil output")
|
|
}
|
|
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
|
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
|
}
|
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
|
}
|
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
|
}
|
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(tt.effectiveAt) {
|
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, tt.effectiveAt.Format(time.RFC3339))
|
|
}
|
|
|
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
|
if !ok {
|
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
|
}
|
|
if !reflect.DeepEqual(payload.KeyMessages, tt.messages) {
|
|
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, tt.messages)
|
|
}
|
|
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(b, &fields); err != nil {
|
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
|
}
|
|
for _, key := range []string{"nearTerm", "discussion", "aviation", "sections"} {
|
|
if _, ok := fields[key]; ok {
|
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
|
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
|
ID: "evt-discussion-bad",
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: "nws-discussion-test",
|
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: "<html><body><pre class=\"glossaryProduct\">National Weather Service Saint Louis MO</pre></body></html>",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("Normalize() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), "issue time") {
|
|
t.Fatalf("error = %q, want issue time context", err)
|
|
}
|
|
}
|
|
|
|
func TestForecastDiscussionNormalizerWireShapeHasNoUnexpectedKeys(t *testing.T) {
|
|
rawHTML := loadForecastDiscussionSampleHTML(t)
|
|
|
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
|
ID: "evt-discussion-2",
|
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
|
Source: "nws-discussion-test",
|
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
|
Payload: rawHTML,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Normalize() error = %v", err)
|
|
}
|
|
|
|
b, err := json.Marshal(out.Payload)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
|
}
|
|
for _, key := range []string{"sections", "aviation"} {
|
|
if _, ok := got[key]; ok {
|
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join("..", "..", "providers", "nws", "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("..", "..", "providers", "nws", "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("..", "..", "providers", "nws", "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
|
|
}
|