646 lines
23 KiB
Go
646 lines
23 KiB
Go
// payload_test.go validates presenter conversion and payload shaping behavior.
|
|
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
|
|
package presenter
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
)
|
|
|
|
func TestObservationPayloadUS(t *testing.T) {
|
|
obs := &model.WeatherObservation{
|
|
StationID: "KSTL",
|
|
Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
|
|
ConditionCode: 2,
|
|
TemperatureC: float64Ptr(20),
|
|
DewpointC: float64Ptr(10),
|
|
WindSpeedKmh: float64Ptr(100),
|
|
WindGustKmh: float64Ptr(80),
|
|
BarometricPressurePa: float64Ptr(101325),
|
|
VisibilityMeters: float64Ptr(1609.344),
|
|
ApparentTemperatureC: float64Ptr(25),
|
|
RelativeHumidityPercent: float64Ptr(50),
|
|
}
|
|
|
|
payload := ObservationPayload(obs, UnitsUS, 2)
|
|
converted, ok := payload.(WeatherObservationUS)
|
|
if !ok {
|
|
t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
|
|
}
|
|
|
|
assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
|
|
assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
|
|
assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
|
|
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
|
|
|
|
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
|
|
t.Fatalf("expected converted Fahrenheit fields to be populated")
|
|
}
|
|
}
|
|
|
|
func TestForecastPayloadUS(t *testing.T) {
|
|
issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
|
|
updatedAt := issuedAt.Add(1 * time.Hour)
|
|
run := &model.WeatherForecastRun{
|
|
LocationID: "stl",
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: &updatedAt,
|
|
Product: model.ForecastProductHourly,
|
|
ElevationMeters: float64Ptr(1000),
|
|
Periods: []model.WeatherForecastPeriod{{
|
|
StartTime: issuedAt,
|
|
EndTime: issuedAt.Add(1 * time.Hour),
|
|
ConditionCode: wmoCodePtr(63),
|
|
TemperatureC: float64Ptr(0),
|
|
TemperatureCMin: float64Ptr(-5),
|
|
TemperatureCMax: float64Ptr(5),
|
|
WindSpeedKmh: float64Ptr(64.37376),
|
|
PrecipitationAmountMm: float64Ptr(25.4),
|
|
SnowfallDepthMM: float64Ptr(50.8),
|
|
}},
|
|
}
|
|
|
|
payload := ForecastPayload(run, UnitsUS, 2, nil)
|
|
converted, ok := payload.(WeatherForecastRunUS)
|
|
if !ok {
|
|
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
|
|
}
|
|
|
|
assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
|
|
if len(converted.Periods) != 1 {
|
|
t.Fatalf("expected 1 period, got %d", len(converted.Periods))
|
|
}
|
|
period := converted.Periods[0]
|
|
assertApprox(t, period.TemperatureF, 32.0, 0.0001)
|
|
assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
|
|
assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
|
|
assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
|
|
assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
|
|
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
|
|
}
|
|
|
|
func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
|
|
run := &model.WeatherForecastRun{
|
|
Product: model.ForecastProductHourly,
|
|
IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
|
|
Periods: []model.WeatherForecastPeriod{{
|
|
StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
|
|
EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC),
|
|
ConditionCode: wmoCodePtr(model.WMOUnknown),
|
|
TextDescription: "Cloudy",
|
|
}},
|
|
}
|
|
|
|
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil))
|
|
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil))
|
|
}
|
|
|
|
func TestForecastPayloadTimezoneConversionMetricAndUS(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)
|
|
run := &model.WeatherForecastRun{
|
|
Product: model.ForecastProductHourly,
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: &updatedAt,
|
|
Periods: []model.WeatherForecastPeriod{{
|
|
StartTime: issuedAt.Add(1 * time.Hour),
|
|
EndTime: issuedAt.Add(2 * time.Hour),
|
|
ConditionCode: wmoCodePtr(model.WMOUnknown),
|
|
}},
|
|
}
|
|
|
|
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
|
|
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
|
if !ok {
|
|
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
|
}
|
|
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
|
|
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
|
|
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
|
|
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
|
|
if !metric.IssuedAt.UTC().Equal(issuedAt) {
|
|
t.Fatalf("expected metric issuedAt to preserve instant")
|
|
}
|
|
|
|
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
|
|
us, ok := usPayload.(WeatherForecastRunUS)
|
|
if !ok {
|
|
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
|
|
}
|
|
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
|
|
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
|
|
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
|
|
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
|
|
|
|
// Source model remains untouched.
|
|
assertOffsetSeconds(t, run.IssuedAt, 0)
|
|
assertOffsetSeconds(t, *run.UpdatedAt, 0)
|
|
}
|
|
|
|
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
|
|
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
|
updatedAt := issuedAt.Add(time.Hour)
|
|
run := &model.WeatherForecastRun{
|
|
Product: model.ForecastProductHourly,
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: &updatedAt,
|
|
Periods: []model.WeatherForecastPeriod{{
|
|
StartTime: issuedAt,
|
|
EndTime: issuedAt.Add(time.Hour),
|
|
ConditionCode: wmoCodePtr(model.WMOUnknown),
|
|
}},
|
|
}
|
|
|
|
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
|
|
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
|
if !ok {
|
|
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
|
}
|
|
if metric == run {
|
|
t.Fatalf("expected metric payload to be copied")
|
|
}
|
|
if metric.UpdatedAt == run.UpdatedAt {
|
|
t.Fatalf("expected updatedAt pointer to be copied")
|
|
}
|
|
if !metric.IssuedAt.Equal(run.IssuedAt) {
|
|
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
|
|
}
|
|
assertOffsetSeconds(t, metric.IssuedAt, 0)
|
|
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
|
|
}
|
|
|
|
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(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)
|
|
run := &model.WeatherForecastDiscussion{
|
|
OfficeID: "LSX",
|
|
Product: model.ForecastDiscussionProductAFD,
|
|
IssuedAt: issuedAt,
|
|
UpdatedAt: &updatedAt,
|
|
KeyMessages: []string{"msg one"},
|
|
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
|
|
}
|
|
|
|
payload := DiscussionPayload(run, UnitsMetric, loc)
|
|
discussion, ok := payload.(*model.WeatherForecastDiscussion)
|
|
if !ok {
|
|
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
|
|
}
|
|
if discussion == run {
|
|
t.Fatalf("expected discussion payload to be copied")
|
|
}
|
|
if discussion.ShortTerm == run.ShortTerm {
|
|
t.Fatalf("expected shortTerm pointer to be copied")
|
|
}
|
|
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
|
|
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
|
|
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
|
|
if discussion.KeyMessages[0] != "msg one" {
|
|
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
|
|
}
|
|
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),
|
|
}
|
|
metric := ObservationPayload(obs, UnitsMetric, 0)
|
|
metricObs, ok := metric.(*model.WeatherObservation)
|
|
if !ok {
|
|
t.Fatalf("expected metric payload to remain model type, got %T", metric)
|
|
}
|
|
if metricObs == obs {
|
|
t.Fatalf("expected metric payload to be copied")
|
|
}
|
|
assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
|
|
assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
|
|
if metricObs.TemperatureC == obs.TemperatureC {
|
|
t.Fatalf("expected temperature pointer copy, got same pointer")
|
|
}
|
|
|
|
if ObservationPayload(nil, UnitsUS, 0) != nil {
|
|
t.Fatalf("expected nil observation input to return nil payload")
|
|
}
|
|
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
|
|
t.Fatalf("expected nil forecast input to return nil payload")
|
|
}
|
|
if AlertsPayload(nil, UnitsUS) != nil {
|
|
t.Fatalf("expected nil alerts input to return nil payload")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestAlertsPayloadPassThrough(t *testing.T) {
|
|
run := &model.WeatherAlertRun{
|
|
LocationID: "stl",
|
|
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
|
Alerts: []model.WeatherAlert{{
|
|
ID: "alert-1",
|
|
Headline: "Storm warning",
|
|
}},
|
|
}
|
|
|
|
payload := AlertsPayload(run, UnitsUS)
|
|
if payload != run {
|
|
t.Fatalf("expected alerts payload to pass through input run")
|
|
}
|
|
}
|
|
|
|
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|
conditions := &app.CurrentConditions{
|
|
TemperatureC: float64Ptr(20),
|
|
ApparentTemperatureC: float64Ptr(18),
|
|
DewpointC: float64Ptr(10),
|
|
RelativeHumidityPercent: float64Ptr(55),
|
|
WindSpeedKmh: float64Ptr(100),
|
|
WindDirectionDegrees: float64Ptr(225),
|
|
ConditionCode: 0,
|
|
IsDay: boolPtr(true),
|
|
}
|
|
|
|
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
|
|
metric, ok := metricPayload.(CurrentConditionsResponse)
|
|
if !ok {
|
|
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
|
|
}
|
|
assertApprox(t, metric.TemperatureC, 20, 0.0001)
|
|
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
|
|
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
|
|
t.Fatalf("expected US fields omitted for metric payload")
|
|
}
|
|
if metric.ConditionText != "Sunny" {
|
|
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
|
}
|
|
if metric.ConditionCode != 0 {
|
|
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
|
|
}
|
|
|
|
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
|
|
us, ok := usPayload.(CurrentConditionsResponse)
|
|
if !ok {
|
|
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
|
|
}
|
|
assertApprox(t, us.TemperatureF, 68, 0.0001)
|
|
assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
|
|
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
|
t.Fatalf("expected metric fields omitted for US payload")
|
|
}
|
|
if us.ConditionCode != 0 {
|
|
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
|
|
}
|
|
}
|
|
|
|
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
|
night := false
|
|
payload := CurrentConditionsPayload(&app.CurrentConditions{
|
|
ConditionCode: 0,
|
|
IsDay: &night,
|
|
}, UnitsMetric, 0)
|
|
|
|
metric, ok := payload.(CurrentConditionsResponse)
|
|
if !ok {
|
|
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
|
|
}
|
|
if metric.ConditionText != "Clear" {
|
|
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
|
|
}
|
|
}
|
|
|
|
func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
|
|
payload := CurrentConditionsPayload(&app.CurrentConditions{
|
|
TemperatureC: float64Ptr(-1.5),
|
|
}, UnitsMetric, 0)
|
|
|
|
metric, ok := payload.(CurrentConditionsResponse)
|
|
if !ok {
|
|
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
|
|
}
|
|
assertApprox(t, metric.TemperatureC, -2, 0.0001)
|
|
}
|
|
|
|
func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
|
|
run := &model.WeatherForecastRun{
|
|
Latitude: float64Ptr(38.627123),
|
|
Longitude: float64Ptr(-90.199456),
|
|
ElevationMeters: float64Ptr(10.499),
|
|
Product: model.ForecastProductHourly,
|
|
IssuedAt: time.Now().UTC(),
|
|
}
|
|
|
|
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
|
|
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
|
if !ok {
|
|
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
|
}
|
|
assertApprox(t, metric.Latitude, 38.627123, 0.000001)
|
|
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
|
|
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
|
|
|
|
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
|
|
us, ok := usPayload.(WeatherForecastRunUS)
|
|
if !ok {
|
|
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
|
|
}
|
|
assertApprox(t, us.Latitude, 38.627123, 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)
|
|
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
|
|
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",
|
|
SourceURL: "https://example.test/source",
|
|
ImageURL: "https://example.test/image.png",
|
|
ContainsLocation: true,
|
|
Geometry: geometry,
|
|
}},
|
|
Discussions: []model.WeatherOutlookDiscussion{{
|
|
Day: 1,
|
|
Headline: "Severe storms possible",
|
|
Summary: "Scattered severe storms are possible.",
|
|
Discussion: "Discussion text.",
|
|
UpdatedAt: &discussionUpdatedAt,
|
|
}},
|
|
}
|
|
|
|
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 len(out.Discussions) != 1 {
|
|
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
|
|
}
|
|
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
|
t.Fatalf("expected severity rank pointer to be copied")
|
|
}
|
|
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
|
|
t.Fatalf("expected discussion updatedAt 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)
|
|
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -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)
|
|
}
|
|
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
|
|
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
|
|
out.Discussions[0].Discussion != "Discussion text." {
|
|
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
|
|
}
|
|
|
|
*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.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
out.Outlooks[0].Geometry[0] = '['
|
|
out.Discussions[0].Headline = "changed"
|
|
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)
|
|
}
|
|
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
|
|
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
|
|
}
|
|
if run.Discussions[0].Headline != "Severe storms possible" {
|
|
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
|
|
}
|
|
assertOffsetSeconds(t, run.AsOf, 0)
|
|
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
|
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
|
|
}
|
|
|
|
func float64Ptr(v float64) *float64 {
|
|
return &v
|
|
}
|
|
|
|
func boolPtr(v bool) *bool {
|
|
return &v
|
|
}
|
|
|
|
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
|
|
out := v
|
|
return &out
|
|
}
|
|
|
|
func assertApprox(t *testing.T, got *float64, want, eps float64) {
|
|
t.Helper()
|
|
if got == nil {
|
|
t.Fatalf("expected value near %f, got nil", want)
|
|
}
|
|
if math.Abs(*got-want) > eps {
|
|
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
|
|
}
|
|
}
|
|
|
|
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
|
|
t.Helper()
|
|
_, got := ts.Zone()
|
|
if got != want {
|
|
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) {
|
|
t.Helper()
|
|
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
|
}
|
|
|
|
var root map[string]any
|
|
if err := json.Unmarshal(b, &root); err != nil {
|
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
|
}
|
|
periodsRaw, ok := root["periods"].([]any)
|
|
if !ok || len(periodsRaw) == 0 {
|
|
t.Fatalf("expected non-empty periods in payload: %#v", root["periods"])
|
|
}
|
|
period, ok := periodsRaw[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected first period map, got %#v", periodsRaw[0])
|
|
}
|
|
|
|
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
|
|
if _, exists := period[key]; exists {
|
|
t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period)
|
|
}
|
|
}
|
|
if period["textDescription"] != "Cloudy" {
|
|
t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
|
|
}
|
|
}
|