aboutsummaryrefslogtreecommitdiff
path: root/internal/openmeteo/openmeteo_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/openmeteo/openmeteo_test.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/openmeteo/openmeteo_test.go')
-rw-r--r--internal/openmeteo/openmeteo_test.go364
1 files changed, 364 insertions, 0 deletions
diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go
new file mode 100644
index 0000000..758920c
--- /dev/null
+++ b/internal/openmeteo/openmeteo_test.go
@@ -0,0 +1,364 @@
+package openmeteo
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// serve returns a server replying with a recorded fixture, and records the
+// query it was asked for so tests can assert on the request as well as the
+// parsing.
+func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", fixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if gotQuery != nil {
+ *gotQuery = r.URL.RawQuery
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// fixtureStart is the first hour in the recorded forecast, so tests can pin the
+// clock relative to real recorded data.
+func fixtureStart(t *testing.T) time.Time {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Hourly struct {
+ Time []string `json:"time"`
+ } `json:"hourly"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return when
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at.UTC() }
+ t.Cleanup(func() { now = old })
+}
+
+// The hourly array begins at 00:00 local, so slicing from the front reports the
+// small hours of this morning rather than the hours ahead -- the bug that made
+// the Python version report the wrong pollen peak.
+//
+// It also pins the timezone contract: the clock is real UTC, and the offset in
+// the response converts it to the *location's* local time. That is what makes
+// "prognosis -l krakow" start at the right hour when run from another zone.
+func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) {
+ offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast
+ if offset == 0 {
+ t.Skip("fixture has no UTC offset; the conversion cannot be exercised")
+ }
+ // 11:00 UTC is 13:00 in a +02:00 location.
+ utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second)
+ pinClock(t, utcNoon)
+
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := d.Rows[0].When.Hour(); got != 13 {
+ t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)",
+ got, utcNoon.Format("15:04"), offset)
+ }
+ if len(d.Rows) != 6 {
+ t.Fatalf("got %d rows, want 6", len(d.Rows))
+ }
+ // Rows must be consecutive hours from there.
+ for i, r := range d.Rows {
+ if want := 13 + i; r.When.Hour() != want {
+ t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want)
+ }
+ }
+}
+
+// fixtureOffset is the recorded response's utc_offset_seconds.
+func fixtureOffset(t *testing.T) int {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Offset int `json:"utc_offset_seconds"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ return r.Offset
+}
+
+func TestForecastRequestsOnlySelectedFields(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+
+ if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil {
+ t.Fatal(err)
+ }
+ hourly := queryValue(t, query, "hourly")
+ for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} {
+ if !strings.Contains(hourly, want) {
+ t.Errorf("hourly=%q is missing %q", hourly, want)
+ }
+ }
+ // A field nobody asked for costs response size for nothing.
+ for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} {
+ if strings.Contains(hourly, unwanted) {
+ t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted)
+ }
+ }
+}
+
+func TestForecastUnitsArePassedToTheProvider(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ cases := map[string]map[string]string{
+ "metric": {"temperature_unit": "", "wind_speed_unit": ""},
+ "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"},
+ "si": {"wind_speed_unit": "ms"},
+ }
+ for units, want := range cases {
+ t.Run(units, func(t *testing.T) {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range want {
+ if got := queryValue(t, query, k); got != v {
+ t.Errorf("%s=%q, want %q", k, got, v)
+ }
+ }
+ })
+ }
+}
+
+func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != wantDays {
+ t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays)
+ }
+ }
+}
+
+func TestForecastParsesDailyAndSun(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(d.Sun) == 0 {
+ t.Error("no sunrise/sunset parsed")
+ }
+ for _, day := range d.Sun {
+ if len(day[0]) != 5 || len(day[1]) != 5 {
+ t.Errorf("sun times must be HH:MM, got %v", day)
+ }
+ }
+ for _, f := range DailyFields {
+ if _, ok := d.Daily[f]; !ok {
+ t.Errorf("daily field %q missing", f)
+ }
+ }
+}
+
+func TestForecastErrors(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ t.Run("http error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "nope", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on HTTP 500")
+ }
+ })
+ t.Run("bad json", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("{not json"))
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on malformed JSON")
+ }
+ })
+}
+
+// Pollen peaks around midday, so a 3-hour request must still look 12 hours
+// ahead or it understates the day for someone with an allergy.
+func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) {
+ start := fixtureStart(t)
+ pinClock(t, start.Add(6*time.Hour))
+ srv := serve(t, "pollen.json", nil)
+ airURL = srv.URL
+
+ short, err := Pollen(50, 21, 3, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ long, err := Pollen(50, 21, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if short["grass"] != long["grass"] {
+ t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+
+ "still cover 12 hours", short["grass"], long["grass"])
+ }
+}
+
+// Outside Europe the API returns nulls. Treating those as 0.0 reports a
+// confident "grass 0.0 none" where the truth is "no data".
+func TestPollenNullsAreAbsentNotZero(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "pollen_nulls.json", nil)
+ airURL = srv.URL
+ peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := peaks["grass"]; ok {
+ t.Fatalf("grass reported as %v, but the fixture has no readings there", v)
+ }
+}
+
+func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "pollen.json", &query)
+ airURL = srv.URL
+ if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != "7" {
+ t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got)
+ }
+}
+
+func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ }))
+ defer srv.Close()
+ airURL = srv.URL
+ peaks, err := Pollen(50, 21, 12, nil)
+ if err != nil || len(peaks) != 0 {
+ t.Fatalf("got %v, %v", peaks, err)
+ }
+ if called {
+ t.Error("requested pollen despite no species being selected")
+ }
+}
+
+func TestGeocodeReportsAlternatives(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cands) < 2 {
+ t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands))
+ }
+ g := cands[0].Geo
+ if g.Country == "" || g.Label == "" {
+ t.Errorf("incomplete geo: %+v", g)
+ }
+ if !strings.Contains(g.Label, g.Country) {
+ t.Errorf("label %q should carry the country %q", g.Label, g.Country)
+ }
+}
+
+// Selecting a place other than the first one is impossible unless its
+// coordinates survive the call, which is exactly what the old API discarded.
+func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, c := range cands {
+ if c.Geo.Lat == 0 || c.Geo.Lon == 0 {
+ t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label)
+ }
+ if c.Admin1 == "" {
+ t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label)
+ }
+ }
+}
+
+func TestGeocodeNoResultsIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`{"generationtime_ms":0.1}`))
+ }))
+ defer srv.Close()
+ geoURL = srv.URL
+ if _, err := Geocode("zzzznowhere"); err == nil {
+ t.Fatal("expected an error when nothing matched")
+ }
+}
+
+func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) {
+ pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
+ if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 {
+ t.Fatalf("got %d, want 0", got)
+ }
+}
+
+func queryValue(t *testing.T, rawQuery, key string) string {
+ t.Helper()
+ for _, pair := range strings.Split(rawQuery, "&") {
+ k, v, _ := strings.Cut(pair, "=")
+ if k == key {
+ unescaped, err := urlUnescape(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return unescaped
+ }
+ }
+ return ""
+}
+
+func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) }