Add convective outlook presentation
This commit is contained in:
@@ -1355,6 +1355,43 @@ func TestOutlookNoDataReturnsNullEnvelopeData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutlookTextResponseUsesTemplate(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, 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"))
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
|
||||||
|
t.Fatalf("expected outlook text template body, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutlookXMLResponseRenders(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=xml", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
|
||||||
|
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
||||||
|
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||||
run := testOutlookRun()
|
run := testOutlookRun()
|
||||||
run.Outlooks = []model.WeatherOutlook{}
|
run.Outlooks = []model.WeatherOutlook{}
|
||||||
@@ -2174,7 +2211,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||||
"outlooks_convective.txt.tmpl": "Convective Outlook",
|
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
|
||||||
"weatherstories.txt.tmpl": "Weather Stories",
|
"weatherstories.txt.tmpl": "Weather Stories",
|
||||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "Alerts text",
|
||||||
|
|||||||
@@ -3,14 +3,64 @@
|
|||||||
package presenter
|
package presenter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, _ *time.Location) any {
|
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location) any {
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return run
|
|
||||||
|
out := model.WeatherOutlookRun{
|
||||||
|
LocationID: run.LocationID,
|
||||||
|
LocationName: run.LocationName,
|
||||||
|
Latitude: copyFloat64Ptr(run.Latitude),
|
||||||
|
Longitude: copyFloat64Ptr(run.Longitude),
|
||||||
|
AsOf: inLocationTime(run.AsOf, tz),
|
||||||
|
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
||||||
|
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
||||||
|
}
|
||||||
|
for _, outlook := range run.Outlooks {
|
||||||
|
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherOutlook {
|
||||||
|
out := model.WeatherOutlook{
|
||||||
|
ID: outlook.ID,
|
||||||
|
Provider: outlook.Provider,
|
||||||
|
Product: outlook.Product,
|
||||||
|
Day: outlook.Day,
|
||||||
|
OutlookType: outlook.OutlookType,
|
||||||
|
Label: outlook.Label,
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
SeverityRank: copyIntPtr(outlook.SeverityRank),
|
||||||
|
ValidFrom: inLocationTime(outlook.ValidFrom, tz),
|
||||||
|
ValidTo: inLocationTime(outlook.ValidTo, tz),
|
||||||
|
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
||||||
|
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
||||||
|
Forecaster: outlook.Forecaster,
|
||||||
|
Headline: outlook.Headline,
|
||||||
|
Summary: outlook.Summary,
|
||||||
|
Discussion: outlook.Discussion,
|
||||||
|
SourceURL: outlook.SourceURL,
|
||||||
|
ImageURL: outlook.ImageURL,
|
||||||
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
}
|
||||||
|
if outlook.Geometry != nil {
|
||||||
|
out.Geometry = json.RawMessage(append([]byte(nil), outlook.Geometry...))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIntPtr(v *int) *int {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := *v
|
||||||
|
return &out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,110 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
|
|||||||
assertApprox(t, us.Longitude, -90.199456, 0.000001)
|
assertApprox(t, us.Longitude, -90.199456, 0.000001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutlookRunPayloadNilInputReturnsNil(t *testing.T) {
|
||||||
|
if OutlookRunPayload(nil, UnitsUS, time.UTC) != nil {
|
||||||
|
t.Fatalf("expected nil outlook run input to return nil payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||||
|
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||||
|
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||||
|
issuedAt := asOf.Add(-1 * time.Hour)
|
||||||
|
severityRank := 5
|
||||||
|
latitude := 38.627123
|
||||||
|
longitude := -90.199456
|
||||||
|
geometry := json.RawMessage(`{"type":"Point","coordinates":[-90.2,38.6]}`)
|
||||||
|
run := &model.WeatherOutlookRun{
|
||||||
|
LocationID: "stl",
|
||||||
|
LocationName: "St. Louis",
|
||||||
|
Latitude: &latitude,
|
||||||
|
Longitude: &longitude,
|
||||||
|
AsOf: asOf,
|
||||||
|
IssuedAt: &issuedAt,
|
||||||
|
Outlooks: []model.WeatherOutlook{{
|
||||||
|
ID: "cat-1",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
SeverityRank: &severityRank,
|
||||||
|
ValidFrom: asOf,
|
||||||
|
ValidTo: asOf.Add(6 * time.Hour),
|
||||||
|
IssuedAt: issuedAt,
|
||||||
|
ExpiresAt: asOf.Add(6 * time.Hour),
|
||||||
|
Forecaster: "DIAL",
|
||||||
|
Headline: "Severe storms possible",
|
||||||
|
Summary: "Scattered severe storms are possible.",
|
||||||
|
Discussion: "Discussion text.",
|
||||||
|
SourceURL: "https://example.test/source",
|
||||||
|
ImageURL: "https://example.test/image.png",
|
||||||
|
ContainsLocation: true,
|
||||||
|
Geometry: geometry,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := OutlookRunPayload(run, UnitsUS, loc)
|
||||||
|
out, ok := payload.(*model.WeatherOutlookRun)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *model.WeatherOutlookRun payload, got %T", payload)
|
||||||
|
}
|
||||||
|
if out == run {
|
||||||
|
t.Fatalf("expected outlook run payload to be copied")
|
||||||
|
}
|
||||||
|
if out.Latitude == run.Latitude || out.Longitude == run.Longitude || out.IssuedAt == run.IssuedAt {
|
||||||
|
t.Fatalf("expected run pointers to be copied")
|
||||||
|
}
|
||||||
|
if len(out.Outlooks) != 1 {
|
||||||
|
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
||||||
|
}
|
||||||
|
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
||||||
|
t.Fatalf("expected severity rank pointer to be copied")
|
||||||
|
}
|
||||||
|
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
||||||
|
t.Fatalf("expected geometry bytes to be copied")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOffsetSeconds(t, out.AsOf, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, *out.IssuedAt, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, out.Outlooks[0].ValidFrom, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
||||||
|
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
||||||
|
t.Fatalf("expected timezone conversion to preserve instants")
|
||||||
|
}
|
||||||
|
|
||||||
|
if *out.Latitude != latitude || *out.Longitude != longitude {
|
||||||
|
t.Fatalf("expected latitude/longitude preserved, got %v %v", out.Latitude, out.Longitude)
|
||||||
|
}
|
||||||
|
if out.Outlooks[0].SeverityRank == nil || *out.Outlooks[0].SeverityRank != severityRank {
|
||||||
|
t.Fatalf("expected severity rank preserved, got %v", out.Outlooks[0].SeverityRank)
|
||||||
|
}
|
||||||
|
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
||||||
|
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
*out.Latitude = 99
|
||||||
|
*out.Longitude = -99
|
||||||
|
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
*out.Outlooks[0].SeverityRank = 99
|
||||||
|
out.Outlooks[0].Geometry[0] = '['
|
||||||
|
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
||||||
|
t.Fatalf("expected source run pointers not to mutate")
|
||||||
|
}
|
||||||
|
if *run.Outlooks[0].SeverityRank != severityRank {
|
||||||
|
t.Fatalf("expected source severity rank not to mutate")
|
||||||
|
}
|
||||||
|
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
||||||
|
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
||||||
|
}
|
||||||
|
assertOffsetSeconds(t, run.AsOf, 0)
|
||||||
|
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func float64Ptr(v float64) *float64 {
|
func float64Ptr(v float64) *float64 {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|||||||
30
templates/outlooks_convective.txt.tmpl
Normal file
30
templates/outlooks_convective.txt.tmpl
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{{- if .Data -}}
|
||||||
|
Convective Outlook
|
||||||
|
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
|
||||||
|
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
|
||||||
|
As Of: {{.Data.AsOf}}
|
||||||
|
{{- if .Data.IssuedAt}}
|
||||||
|
Issued At: {{.Data.IssuedAt}}
|
||||||
|
{{- end}}
|
||||||
|
Outlooks: {{len .Data.Outlooks}}
|
||||||
|
{{- range $i, $outlook := .Data.Outlooks}}
|
||||||
|
|
||||||
|
[{{$i}}] Day {{$outlook.Day}} {{$outlook.OutlookType}} {{$outlook.Label}}
|
||||||
|
Valid: {{$outlook.ValidFrom}} -> {{$outlook.ValidTo}}
|
||||||
|
Contains Location: {{$outlook.ContainsLocation}}
|
||||||
|
{{- if $outlook.LabelText}}
|
||||||
|
Label Text: {{$outlook.LabelText}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $outlook.Headline}}
|
||||||
|
Headline: {{$outlook.Headline}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $outlook.Summary}}
|
||||||
|
Summary: {{$outlook.Summary}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $outlook.SourceURL}}
|
||||||
|
Source URL: {{$outlook.SourceURL}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- else -}}
|
||||||
|
No convective outlook data available.
|
||||||
|
{{- end}}
|
||||||
Reference in New Issue
Block a user