From 26a52f8c448e4a8ea253a41ca536cf6890e58466 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 19 Mar 2026 22:36:15 -0500 Subject: [PATCH] Add US unit support for weather observations and forecasts --- .../adapters/inbound/httpapi/endpoints.go | 57 +++-- .../inbound/httpapi/endpoints_test.go | 87 +++++++- internal/core/constants.go | 19 ++ internal/core/payload.go | 196 ++++++++++++++++++ internal/core/payload_test.go | 119 +++++++++++ 5 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 internal/core/constants.go create mode 100644 internal/core/payload.go create mode 100644 internal/core/payload_test.go diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 45e7505..dca484e 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -3,11 +3,13 @@ package httpapi import ( "context" "net/http" + "strings" "gitea.maximumdirect.net/ejr/feedapi/bind" "gitea.maximumdirect.net/ejr/feedapi/endpoint" "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/response" + "gitea.maximumdirect.net/ejr/weatherapi/internal/core" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -18,45 +20,47 @@ type Service interface { LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) } -type emptyRequest struct{} +type queryRequest struct { + Units core.Units +} func Definitions(svc Service) []endpoint.Definition { return []endpoint.Definition{ endpoint.GET( "/observations", - bindFormatOnly, - func(ctx context.Context, _ emptyRequest) (any, error) { + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { obs, err := svc.LatestObservation(ctx) if err != nil { return nil, err } - return response.Envelope{Data: obs}, nil + return response.Envelope{Data: core.ObservationPayload(obs, req.Units)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("observations.txt.tmpl"), ), endpoint.GET( "/forecast/hourly", - bindFormatOnly, - func(ctx context.Context, _ emptyRequest) (any, error) { + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { run, err := svc.LatestHourlyForecast(ctx) if err != nil { return nil, err } - return response.Envelope{Data: run}, nil + return response.Envelope{Data: core.ForecastPayload(run, req.Units)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("forecast_hourly.txt.tmpl"), ), endpoint.GET( "/alerts/active", - bindFormatOnly, - func(ctx context.Context, _ emptyRequest) (any, error) { + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { run, err := svc.LatestActiveAlerts(ctx) if err != nil { return nil, err } - return response.Envelope{Data: run}, nil + return response.Envelope{Data: core.AlertsPayload(run, req.Units)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("alerts_active.txt.tmpl"), @@ -64,13 +68,38 @@ func Definitions(svc Service) []endpoint.Definition { } } -func bindFormatOnly(r *http.Request) (emptyRequest, error) { - _, err := bind.CommonQueryParams(r, bind.QueryPolicy{ +func bindQuery(r *http.Request) (queryRequest, error) { + normalizeCommonQueryValue(r, "units") + normalizeCommonQueryValue(r, "format") + + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowUnits: true, AllowFormat: true, + DefaultUnits: string(core.UnitsMetric), RejectUnknown: true, }) if err != nil { - return emptyRequest{}, err + return queryRequest{}, err } - return emptyRequest{}, nil + + units := core.Units(strings.ToLower(strings.TrimSpace(common.Units))) + if units == "" { + units = core.UnitsMetric + } + return queryRequest{Units: units}, nil +} + +func normalizeCommonQueryValue(r *http.Request, key string) { + q := r.URL.Query() + values, ok := q[key] + if !ok || len(values) == 0 { + return + } + + normalized := strings.ToLower(strings.TrimSpace(values[0])) + if normalized == values[0] { + return + } + q.Set(key, normalized) + r.URL.RawQuery = q.Encode() } diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index e71c45e..12ff037 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "encoding/json" + "math" "net/http" "net/http/httptest" "strings" @@ -110,12 +111,11 @@ func TestObservationsPopulatedJSONEnvelope(t *testing.T) { } } -func TestFormatNegotiationXMLAndText(t *testing.T) { +func TestFormatNegotiationCaseInsensitive(t *testing.T) { hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active") w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil) - req.Header.Set("Accept", "application/xml") + req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=XML", nil) hXML.ServeHTTP(w, req) if w.Code != http.StatusOK { @@ -127,7 +127,7 @@ func TestFormatNegotiationXMLAndText(t *testing.T) { hText := newHandler(t, &fakeService{forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly}}, "/forecast/hourly") w = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=text", nil) + req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=TEXT", nil) hText.ServeHTTP(w, req) if w.Code != http.StatusOK { @@ -141,6 +141,81 @@ func TestFormatNegotiationXMLAndText(t *testing.T) { } } +func TestObservationUSUnitsChangesFieldNames(t *testing.T) { + h := newHandler(t, &fakeService{ + observation: &model.WeatherObservation{ + StationID: "KSTL", + Timestamp: time.Now().UTC(), + ConditionCode: 1, + TemperatureC: float64Ptr(20), + }, + }, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations?units=US", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + + if _, ok := payload.Data["temperatureC"]; ok { + t.Fatalf("expected temperatureC to be omitted in US payload") + } + v, ok := payload.Data["temperatureF"].(float64) + if !ok { + t.Fatalf("expected temperatureF in US payload, got %#v", payload.Data["temperatureF"]) + } + if math.Abs(v-68.0) > 0.0001 { + t.Fatalf("expected temperatureF ~= 68, got %f", v) + } +} + +func TestAlertsUSUnitsKeepSchema(t *testing.T) { + h := newHandler(t, &fakeService{ + alerts: &model.WeatherAlertRun{ + AsOf: time.Now().UTC(), + Alerts: []model.WeatherAlert{{ + ID: "abc", + Headline: "A headline", + }}, + }, + }, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active?units=us", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + alerts, ok := payload.Data["alerts"].([]any) + if !ok || len(alerts) != 1 { + t.Fatalf("expected one alert in response, got %#v", payload.Data["alerts"]) + } + first, ok := alerts[0].(map[string]any) + if !ok { + t.Fatalf("expected first alert object, got %#v", alerts[0]) + } + if first["id"] != "abc" { + t.Fatalf("expected alert id abc, got %#v", first["id"]) + } +} + func newHandler(t *testing.T, svc Service, path string) http.Handler { t.Helper() @@ -193,3 +268,7 @@ func testRenderers(t *testing.T) *render.Registry { return reg } + +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/internal/core/constants.go b/internal/core/constants.go new file mode 100644 index 0000000..9055eea --- /dev/null +++ b/internal/core/constants.go @@ -0,0 +1,19 @@ +package core + +// Units controls response-unit output formatting. +type Units string + +const ( + UnitsMetric Units = "metric" + UnitsUS Units = "us" +) + +const ( + celsiusToFahrenheitScale = 9.0 / 5.0 + celsiusToFahrenheitOffset = 32.0 + kmhToMphFactor = 0.621371192237334 + metersToMilesFactor = 0.000621371192237334 + metersToFeetFactor = 3.280839895013123 + paToInHgFactor = 0.000295299830714045 + mmToInchesFactor = 0.03937007874015748 +) diff --git a/internal/core/payload.go b/internal/core/payload.go new file mode 100644 index 0000000..0694fc4 --- /dev/null +++ b/internal/core/payload.go @@ -0,0 +1,196 @@ +package core + +import ( + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +// WeatherObservationUS is the US-customary response shape for observations. +type WeatherObservationUS struct { + StationID string `json:"stationId,omitempty" xml:"stationId,omitempty"` + StationName string `json:"stationName,omitempty" xml:"stationName,omitempty"` + Timestamp time.Time `json:"timestamp" xml:"timestamp"` + ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` + IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` + TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` + PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"` +} + +// WeatherForecastRunUS is the US-customary response shape for hourly forecasts. +type WeatherForecastRunUS struct { + LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"` + IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"` + Product model.ForecastProduct `json:"product" xml:"product"` + Latitude *float64 `json:"latitude,omitempty" xml:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty" xml:"longitude,omitempty"` + ElevationFeet *float64 `json:"elevationFeet,omitempty" xml:"elevationFeet,omitempty"` + Periods []WeatherForecastPeriodUS `json:"periods" xml:"periods"` +} + +// WeatherForecastPeriodUS is the US-customary response shape for forecast periods. +type WeatherForecastPeriodUS struct { + StartTime time.Time `json:"startTime" xml:"startTime"` + EndTime time.Time `json:"endTime" xml:"endTime"` + Name string `json:"name,omitempty" xml:"name,omitempty"` + IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` + ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` + ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"` + ProviderRawDescription string `json:"providerRawDescription,omitempty" xml:"providerRawDescription,omitempty"` + TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` + DetailedText string `json:"detailedText,omitempty" xml:"detailedText,omitempty"` + IconURL string `json:"iconUrl,omitempty" xml:"iconUrl,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` + TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"` + TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` + CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty" xml:"cloudCoverPercent,omitempty"` + ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty" xml:"probabilityOfPrecipitationPercent,omitempty"` + PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty" xml:"precipitationAmountIn,omitempty"` + SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty" xml:"snowfallDepthIn,omitempty"` + UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` +} + +func ObservationPayload(obs *model.WeatherObservation, units Units) any { + if obs == nil { + return nil + } + if units == UnitsUS { + converted := WeatherObservationUS{ + StationID: obs.StationID, + StationName: obs.StationName, + Timestamp: obs.Timestamp, + ConditionCode: obs.ConditionCode, + IsDay: copyBoolPtr(obs.IsDay), + TextDescription: obs.TextDescription, + TemperatureF: celsiusToFahrenheitPtr(obs.TemperatureC), + DewpointF: celsiusToFahrenheitPtr(obs.DewpointC), + WindDirectionDegrees: copyFloat64Ptr(obs.WindDirectionDegrees), + WindSpeedMph: scalePtr(obs.WindSpeedKmh, kmhToMphFactor), + WindGustMph: scalePtr(obs.WindGustKmh, kmhToMphFactor), + BarometricPressureInHg: scalePtr(obs.BarometricPressurePa, paToInHgFactor), + VisibilityMiles: scalePtr(obs.VisibilityMeters, metersToMilesFactor), + RelativeHumidityPercent: copyFloat64Ptr(obs.RelativeHumidityPercent), + ApparentTemperatureF: celsiusToFahrenheitPtr(obs.ApparentTemperatureC), + PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), + } + return converted + } + return obs +} + +func ForecastPayload(run *model.WeatherForecastRun, units Units) any { + if run == nil { + return nil + } + if units == UnitsUS { + out := WeatherForecastRunUS{ + LocationID: run.LocationID, + LocationName: run.LocationName, + IssuedAt: run.IssuedAt, + UpdatedAt: copyTimePtr(run.UpdatedAt), + Product: run.Product, + Latitude: copyFloat64Ptr(run.Latitude), + Longitude: copyFloat64Ptr(run.Longitude), + ElevationFeet: scalePtr(run.ElevationMeters, metersToFeetFactor), + Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)), + } + for _, p := range run.Periods { + out.Periods = append(out.Periods, WeatherForecastPeriodUS{ + StartTime: p.StartTime, + EndTime: p.EndTime, + Name: p.Name, + IsDay: copyBoolPtr(p.IsDay), + ConditionCode: p.ConditionCode, + ConditionText: p.ConditionText, + ProviderRawDescription: p.ProviderRawDescription, + TextDescription: p.TextDescription, + DetailedText: p.DetailedText, + IconURL: p.IconURL, + TemperatureF: celsiusToFahrenheitPtr(p.TemperatureC), + TemperatureFMin: celsiusToFahrenheitPtr(p.TemperatureCMin), + TemperatureFMax: celsiusToFahrenheitPtr(p.TemperatureCMax), + DewpointF: celsiusToFahrenheitPtr(p.DewpointC), + RelativeHumidityPercent: copyFloat64Ptr(p.RelativeHumidityPercent), + WindDirectionDegrees: copyFloat64Ptr(p.WindDirectionDegrees), + WindSpeedMph: scalePtr(p.WindSpeedKmh, kmhToMphFactor), + WindGustMph: scalePtr(p.WindGustKmh, kmhToMphFactor), + BarometricPressureInHg: scalePtr(p.BarometricPressurePa, paToInHgFactor), + VisibilityMiles: scalePtr(p.VisibilityMeters, metersToMilesFactor), + ApparentTemperatureF: celsiusToFahrenheitPtr(p.ApparentTemperatureC), + CloudCoverPercent: copyFloat64Ptr(p.CloudCoverPercent), + ProbabilityOfPrecipitationPercent: copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), + PrecipitationAmountIn: scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), + SnowfallDepthIn: scalePtr(p.SnowfallDepthMM, mmToInchesFactor), + UVIndex: copyFloat64Ptr(p.UVIndex), + }) + } + return out + } + return run +} + +func AlertsPayload(run *model.WeatherAlertRun, _ Units) any { + if run == nil { + return nil + } + return run +} + +func celsiusToFahrenheitPtr(v *float64) *float64 { + if v == nil { + return nil + } + out := (*v * celsiusToFahrenheitScale) + celsiusToFahrenheitOffset + return &out +} + +func scalePtr(v *float64, factor float64) *float64 { + if v == nil { + return nil + } + out := *v * factor + return &out +} + +func copyFloat64Ptr(v *float64) *float64 { + if v == nil { + return nil + } + out := *v + return &out +} + +func copyBoolPtr(v *bool) *bool { + if v == nil { + return nil + } + out := *v + return &out +} + +func copyTimePtr(v *time.Time) *time.Time { + if v == nil { + return nil + } + out := *v + return &out +} diff --git a/internal/core/payload_test.go b/internal/core/payload_test.go new file mode 100644 index 0000000..fb3478e --- /dev/null +++ b/internal/core/payload_test.go @@ -0,0 +1,119 @@ +package core + +import ( + "math" + "testing" + "time" + + "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) + 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.1371192237, 0.0001) + assertApprox(t, converted.BarometricPressureInHg, 29.9212524019, 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: 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) + converted, ok := payload.(WeatherForecastRunUS) + if !ok { + t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload) + } + + assertApprox(t, converted.ElevationFeet, 3280.839895, 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 TestMetricPassthroughAndNilHandling(t *testing.T) { + obs := &model.WeatherObservation{} + metric := ObservationPayload(obs, UnitsMetric) + 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 original pointer") + } + + if ObservationPayload(nil, UnitsUS) != nil { + t.Fatalf("expected nil observation input to return nil payload") + } + if ForecastPayload(nil, UnitsUS) != 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") + } +} + +func float64Ptr(v float64) *float64 { + return &v +} + +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) + } +}