Added new, focused forecast discussion endpoints for key messages, short term, and long term discussions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-29 08:05:16 -05:00
parent 0bccfc50d9
commit 6ea27cb2c5
9 changed files with 607 additions and 11 deletions

View File

@@ -8,6 +8,9 @@ A small HTTP API that serves a variety of weather-related endpoints.
- `GET /conditions/current`
- `GET /alerts/active`
- `GET /discussion`
- `GET /discussion/key-messages`
- `GET /discussion/short-term`
- `GET /discussion/long-term`
- `GET /forecast/hourly`
- `GET /forecast/hourly/today`
- `GET /forecast/hourly/tomorrow`

View File

@@ -4,25 +4,69 @@ package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func discussionDefinition(svc Service) endpoint.Definition {
func discussionDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
discussionDefinition(
"/discussion",
"discussion.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/key-messages",
"discussion_key_messages.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionKeyMessagesOnlyPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/short-term",
"discussion_short_term.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionShortTermOnlyPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/long-term",
"discussion_long_term.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionLongTermOnlyPayload(run, units, tz)
},
svc,
),
}
}
func discussionDefinition(
path string,
templateName string,
present func(*model.WeatherForecastDiscussion, presenter.Units, *time.Location) any,
svc Service,
) endpoint.Definition {
return endpoint.GET(
"/discussion",
path,
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
run, err := svc.LatestForecastDiscussion(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil
return response.Envelope{Data: present(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("discussion.txt.tmpl"),
endpoint.WithTemplate(templateName),
)
}

View File

@@ -8,9 +8,9 @@ func Definitions(svc Service) []endpoint.Definition {
defs := []endpoint.Definition{
observationDefinition(svc),
alertsDefinition(svc),
discussionDefinition(svc),
conditionsDefinition(svc),
}
defs = append(defs, discussionDefinitions(svc)...)
defs = append(defs, forecastDefinitions(svc)...)
return defs
}

View File

@@ -1440,6 +1440,307 @@ func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) {
func TestDefinitionsIncludeDiscussion(t *testing.T) {
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion")
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/key-messages")
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/short-term")
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term")
}
func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) {
for _, path := range []string{
"/discussion/key-messages",
"/discussion/short-term",
"/discussion/long-term",
} {
t.Run(path, func(t *testing.T) {
h := newHandler(t, &fakeService{}, path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data *json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data != nil {
t.Fatalf("expected data null, got %s", string(*payload.Data))
}
})
}
}
func TestDiscussionSubresourcesJSONEnvelopeFocusedFields(t *testing.T) {
issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC)
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
longIssuedAt := issuedAt.Add(10 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
OfficeName: "National Weather Service Saint Louis MO",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
KeyMessages: []string{"msg one", "msg two"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"},
}
t.Run("key messages", func(t *testing.T) {
h := newHandler(t, &fakeService{discussion: run}, "/discussion/key-messages")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion/key-messages", nil)
h.ServeHTTP(w, req)
var payload struct {
Data struct {
OfficeID string `json:"officeId"`
KeyMessages []string `json:"keyMessages"`
ShortTerm any `json:"shortTerm"`
LongTerm any `json:"longTerm"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.OfficeID != "LSX" {
t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID)
}
if len(payload.Data.KeyMessages) != 2 {
t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages))
}
if payload.Data.ShortTerm != nil || payload.Data.LongTerm != nil {
t.Fatalf("unexpected extra fields in key messages payload")
}
})
t.Run("short term", func(t *testing.T) {
h := newHandler(t, &fakeService{discussion: run}, "/discussion/short-term")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion/short-term", nil)
h.ServeHTTP(w, req)
var payload struct {
Data struct {
ShortTerm *struct {
Text string `json:"text"`
} `json:"shortTerm"`
KeyMessages any `json:"keyMessages"`
LongTerm any `json:"longTerm"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" {
t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm)
}
if payload.Data.KeyMessages != nil || payload.Data.LongTerm != nil {
t.Fatalf("unexpected extra fields in short term payload")
}
})
t.Run("long term", func(t *testing.T) {
h := newHandler(t, &fakeService{discussion: run}, "/discussion/long-term")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion/long-term", nil)
h.ServeHTTP(w, req)
var payload struct {
Data struct {
LongTerm *struct {
Text string `json:"text"`
} `json:"longTerm"`
KeyMessages any `json:"keyMessages"`
ShortTerm any `json:"shortTerm"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.LongTerm == nil || payload.Data.LongTerm.Text != "Long term text" {
t.Fatalf("unexpected longTerm payload: %+v", payload.Data.LongTerm)
}
if payload.Data.KeyMessages != nil || payload.Data.ShortTerm != nil {
t.Fatalf("unexpected extra fields in long term payload")
}
})
}
func TestDiscussionSubresourcesSupportTextAndXMLFormats(t *testing.T) {
tests := []struct {
path string
textContains string
}{
{path: "/discussion/key-messages", textContains: "Forecast Discussion Key Messages"},
{path: "/discussion/short-term", textContains: "Forecast Discussion Short Term"},
{path: "/discussion/long-term", textContains: "Forecast Discussion Long Term"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
hText := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, tt.path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tt.path+"?format=TEXT", nil)
hText.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for text request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), tt.textContains) {
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
}
hXML := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, tt.path)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, tt.path+"?format=XML", nil)
hXML.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for xml request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
}
})
}
}
func TestDiscussionSubresourcesTimezoneQuery(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
longIssuedAt := issuedAt.Add(15 * time.Minute)
tests := []struct {
path string
query string
want int
check func(*testing.T, discussionFocusedTimePayload, int)
}{
{
path: "/discussion/key-messages",
query: "/discussion/key-messages?tz=CDT",
want: -5 * 60 * 60,
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
},
},
{
path: "/discussion/short-term",
query: "/discussion/short-term?tz=Chicago",
want: -5 * 60 * 60,
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, want)
},
},
{
path: "/discussion/long-term",
query: "/discussion/long-term?TZ=-5",
want: -5 * 60 * 60,
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, want)
},
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
h := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt},
LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt},
},
}, tt.path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tt.query, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload discussionFocusedTimePayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode discussion payload: %v", err)
}
tt.check(t, payload, tt.want)
})
}
}
func TestDiscussionSubresourcesRejectInvalidQueryParameters(t *testing.T) {
tests := []struct {
path string
urls []string
}{
{
path: "/discussion/key-messages",
urls: []string{
"/discussion/key-messages?bogus=1",
"/discussion/key-messages?precision=1",
"/discussion/key-messages?tz=not-a-timezone",
"/discussion/key-messages?tz=CDT&TZ=EST",
},
},
{
path: "/discussion/short-term",
urls: []string{
"/discussion/short-term?bogus=1",
"/discussion/short-term?precision=1",
"/discussion/short-term?tz=not-a-timezone",
"/discussion/short-term?tz=CDT&TZ=EST",
},
},
{
path: "/discussion/long-term",
urls: []string{
"/discussion/long-term?bogus=1",
"/discussion/long-term?precision=1",
"/discussion/long-term?tz=not-a-timezone",
"/discussion/long-term?tz=CDT&TZ=EST",
},
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
for _, rawURL := range tt.urls {
h := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, tt.path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
}
}
})
}
}
func newHandler(t *testing.T, svc Service, path string) http.Handler {
@@ -1476,12 +1777,15 @@ func testRenderers(t *testing.T) *render.Registry {
tmplReg := templates.NewRegistry()
for name, body := range map[string]string{
"observations.txt.tmpl": "Observation text",
"discussion.txt.tmpl": "Forecast Discussion",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"alerts_active.txt.tmpl": "Alerts text",
"conditions_current.txt.tmpl": "Conditions text",
"observations.txt.tmpl": "Observation text",
"discussion.txt.tmpl": "Forecast Discussion",
"discussion_key_messages.txt.tmpl": "Forecast Discussion Key Messages",
"discussion_short_term.txt.tmpl": "Forecast Discussion Short Term",
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"alerts_active.txt.tmpl": "Alerts text",
"conditions_current.txt.tmpl": "Conditions text",
} {
tmpl, err := template.New(name).Parse(body)
if err != nil {
@@ -1526,6 +1830,19 @@ type discussionTimePayload struct {
} `json:"data"`
}
type discussionFocusedTimePayload struct {
Data struct {
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt"`
ShortTerm *struct {
IssuedAt *time.Time `json:"issuedAt"`
} `json:"shortTerm"`
LongTerm *struct {
IssuedAt *time.Time `json:"issuedAt"`
} `json:"longTerm"`
} `json:"data"`
}
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
t.Helper()

View File

@@ -8,6 +8,33 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type DiscussionKeyMessagesPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
KeyMessages []string `json:"keyMessages" xml:"keyMessages"`
}
type DiscussionShortTermPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
ShortTerm *model.WeatherForecastDiscussionSection `json:"shortTerm,omitempty" xml:"shortTerm,omitempty"`
}
type DiscussionLongTermPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
LongTerm *model.WeatherForecastDiscussionSection `json:"longTerm,omitempty" xml:"longTerm,omitempty"`
}
func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
@@ -26,6 +53,51 @@ func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.L
return &out
}
func DiscussionKeyMessagesOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionKeyMessagesPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
KeyMessages: append([]string{}, run.KeyMessages...),
}
}
func DiscussionShortTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionShortTermPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
}
}
func DiscussionLongTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionLongTermPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
LongTerm: copyDiscussionSection(run.LongTerm, tz),
}
}
func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection {
if in == nil {
return nil

View File

@@ -209,6 +209,90 @@ func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
assertOffsetSeconds(t, run.IssuedAt, 0)
}
func TestDiscussionFocusedPayloads(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
longIssuedAt := issuedAt.Add(15 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
OfficeName: "National Weather Service Saint Louis MO",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: nil,
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"},
}
keyMessagesPayload := DiscussionKeyMessagesOnlyPayload(run, UnitsMetric, loc)
keyMessages, ok := keyMessagesPayload.(DiscussionKeyMessagesPayload)
if !ok {
t.Fatalf("expected DiscussionKeyMessagesPayload, got %T", keyMessagesPayload)
}
if keyMessages.KeyMessages == nil {
t.Fatalf("expected keyMessages slice to be non-nil")
}
if len(keyMessages.KeyMessages) != 0 {
t.Fatalf("expected empty keyMessages slice, got %#v", keyMessages.KeyMessages)
}
assertOffsetSeconds(t, keyMessages.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *keyMessages.UpdatedAt, -5*60*60)
shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, loc)
shortTerm, ok := shortPayload.(DiscussionShortTermPayload)
if !ok {
t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload)
}
if shortTerm.ShortTerm == nil {
t.Fatalf("expected shortTerm section")
}
if shortTerm.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm section copy")
}
assertOffsetSeconds(t, *shortTerm.ShortTerm.IssuedAt, -5*60*60)
longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, loc)
longTerm, ok := longPayload.(DiscussionLongTermPayload)
if !ok {
t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload)
}
if longTerm.LongTerm == nil {
t.Fatalf("expected longTerm section")
}
if longTerm.LongTerm == run.LongTerm {
t.Fatalf("expected longTerm section copy")
}
assertOffsetSeconds(t, *longTerm.LongTerm.IssuedAt, -5*60*60)
}
func TestDiscussionFocusedPayloadsPreserveNilSections(t *testing.T) {
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
}
shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, nil)
shortTerm, ok := shortPayload.(DiscussionShortTermPayload)
if !ok {
t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload)
}
if shortTerm.ShortTerm != nil {
t.Fatalf("expected nil shortTerm, got %+v", shortTerm.ShortTerm)
}
longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, nil)
longTerm, ok := longPayload.(DiscussionLongTermPayload)
if !ok {
t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload)
}
if longTerm.LongTerm != nil {
t.Fatalf("expected nil longTerm, got %+v", longTerm.LongTerm)
}
}
func TestMetricCopyAndNilHandling(t *testing.T) {
obs := &model.WeatherObservation{
TemperatureC: float64Ptr(20.6),
@@ -239,6 +323,15 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
if DiscussionPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil discussion input to return nil payload")
}
if DiscussionKeyMessagesOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil key messages discussion input to return nil payload")
}
if DiscussionShortTermOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil short term discussion input to return nil payload")
}
if DiscussionLongTermOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil long term discussion input to return nil payload")
}
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil current conditions input to return nil payload")
}

View File

@@ -0,0 +1,17 @@
{{- if .Data -}}
Forecast Discussion Key Messages
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
Product: {{.Data.Product}}
Issued At: {{.Data.IssuedAt}}
{{- if .Data.UpdatedAt}}
Updated At: {{.Data.UpdatedAt}}
{{- end}}
Key Messages: {{len .Data.KeyMessages}}
{{- range $i, $message := .Data.KeyMessages}}
[{{$i}}] {{$message}}
{{- end}}
{{- else -}}
No key messages discussion data available.
{{- end}}

View File

@@ -0,0 +1,25 @@
{{- if .Data -}}
Forecast Discussion Long Term
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
Product: {{.Data.Product}}
Issued At: {{.Data.IssuedAt}}
{{- if .Data.UpdatedAt}}
Updated At: {{.Data.UpdatedAt}}
{{- end}}
{{- if .Data.LongTerm}}
{{- if .Data.LongTerm.Qualifier}}
Qualifier: {{.Data.LongTerm.Qualifier}}
{{- end}}
{{- if .Data.LongTerm.IssuedAt}}
Section Issued At: {{.Data.LongTerm.IssuedAt}}
{{- end}}
{{- if .Data.LongTerm.Text}}
Text: {{.Data.LongTerm.Text}}
{{- end}}
{{- else}}
No long term discussion data available.
{{- end}}
{{- else -}}
No long term discussion data available.
{{- end}}

View File

@@ -0,0 +1,25 @@
{{- if .Data -}}
Forecast Discussion Short Term
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
Product: {{.Data.Product}}
Issued At: {{.Data.IssuedAt}}
{{- if .Data.UpdatedAt}}
Updated At: {{.Data.UpdatedAt}}
{{- end}}
{{- if .Data.ShortTerm}}
{{- if .Data.ShortTerm.Qualifier}}
Qualifier: {{.Data.ShortTerm.Qualifier}}
{{- end}}
{{- if .Data.ShortTerm.IssuedAt}}
Section Issued At: {{.Data.ShortTerm.IssuedAt}}
{{- end}}
{{- if .Data.ShortTerm.Text}}
Text: {{.Data.ShortTerm.Text}}
{{- end}}
{{- else}}
No short term discussion data available.
{{- end}}
{{- else -}}
No short term discussion data available.
{{- end}}