Add convective outlook presentation

This commit is contained in:
2026-06-11 15:41:38 +00:00
parent d6734d5ffc
commit e897ae52df
4 changed files with 224 additions and 3 deletions

View File

@@ -3,14 +3,64 @@
package presenter
import (
"encoding/json"
"time"
"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 {
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
}

View File

@@ -430,6 +430,110 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
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 {
return &v
}