aboutsummaryrefslogtreecommitdiff
path: root/internal/imgw/imgw_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/imgw/imgw_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/imgw/imgw_test.go')
-rw-r--r--internal/imgw/imgw_test.go190
1 files changed, 190 insertions, 0 deletions
diff --git a/internal/imgw/imgw_test.go b/internal/imgw/imgw_test.go
new file mode 100644
index 0000000..ffc678e
--- /dev/null
+++ b/internal/imgw/imgw_test.go
@@ -0,0 +1,190 @@
+package imgw
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+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.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func serveText(t *testing.T, body string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at }
+ t.Cleanup(func() { now = old })
+}
+
+// A point inside Poland resolves to the first four digits of its TERYT code.
+func TestPowiatTruncatesTerytToFourDigits(t *testing.T) {
+ srv := serve(t, "gugik_krakow.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(50.0617, 19.9373)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != StatusOK {
+ t.Fatalf("status = %v, want StatusOK", status)
+ }
+ if code != "1815" {
+ t.Fatalf("code = %q, want 1815 (first four digits of 126101)", code)
+ }
+}
+
+// GUGiK answers a foreign point with HTTP 200 and no results. That is a real
+// answer -- "not in Poland" -- and must never be confused with a failure, which
+// is the difference between "no warnings apply" and "I do not know".
+func TestPowiatOutsidePolandIsAnAnswerNotAnError(t *testing.T) {
+ srv := serve(t, "gugik_abroad.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(52.52, 13.40)
+ if err != nil {
+ t.Fatalf("a valid 'no results' response must not be an error: %v", err)
+ }
+ if status != StatusOutside {
+ t.Fatalf("status = %v, want StatusOutside", status)
+ }
+ if code != "" {
+ t.Fatalf("code = %q, want empty", code)
+ }
+}
+
+func TestPowiatUnreachableIsStatusError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusBadGateway)
+ }))
+ srv.Close() // closed: the request cannot connect at all
+ gugikURL = srv.URL
+ _, status, err := Powiat(50, 21)
+ if status != StatusError {
+ t.Fatalf("status = %v, want StatusError", status)
+ }
+ if err == nil {
+ t.Fatal("expected an error describing the failure")
+ }
+}
+
+// The default 100 m radius finds nothing in the mountains, which is
+// indistinguishable from being abroad and would suppress real warnings.
+func TestPowiatAsksForAWideRadius(t *testing.T) {
+ var query string
+ srv := serve(t, "gugik_krakow.json", &query)
+ gugikURL = srv.URL
+ if _, _, err := Powiat(50, 21); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(query, "radius=10000") {
+ t.Fatalf("query %q must ask for a wide radius", query)
+ }
+}
+
+func TestWarningsKeepOnlyThisPowiat(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) // nothing expired
+ body := `[
+ {"nazwa_zdarzenia":"Upal","stopien":"1","obowiazuje_do":"2030-01-01 00:00:00","teryt":["1815","1234"]},
+ {"nazwa_zdarzenia":"Burze","stopien":"2","obowiazuje_do":"2030-01-01 00:00:00","teryt":["2207"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].Event != "Upal" {
+ t.Fatalf("got %+v, want only the warning covering 1815", live)
+ }
+}
+
+func TestWarningsDropExpiredButKeepUnparseableDates(t *testing.T) {
+ pinClock(t, time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local))
+ body := `[
+ {"nazwa_zdarzenia":"Wczorajsze","stopien":"1","obowiazuje_do":"2026-08-09 23:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Trwajace","stopien":"1","obowiazuje_do":"2026-08-10 20:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Bezdaty","stopien":"1","obowiazuje_do":"","teryt":["1815"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, w := range live {
+ names = append(names, w.Event)
+ }
+ got := strings.Join(names, ",")
+ // Unparseable is kept: showing a stale warning beats hiding a live one.
+ if got != "Trwajace,Bezdaty" {
+ t.Fatalf("got %q, want \"Trwajace,Bezdaty\"", got)
+ }
+}
+
+// IMGW has been seen to encode TERYT codes as numbers as well as strings.
+func TestWarningsMatchNumericTerytCodes(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serveText(t, `[{"nazwa_zdarzenia":"X","obowiazuje_do":"2030-01-01 00:00:00","teryt":[1815]}]`)
+ warningsURL = srv.URL
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 {
+ t.Fatalf("a numeric teryt entry must still match, got %d warnings", len(live))
+ }
+}
+
+func TestWarningsUnreachableIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ srv.Close()
+ warningsURL = srv.URL
+ if _, err := Warnings("1815"); err == nil {
+ t.Fatal("expected an error: the caller must be able to say 'could not check'")
+ }
+}
+
+// The recorded national feed must parse, and must not match a made-up powiat.
+func TestWarningsParseTheRecordedFeed(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serve(t, "warnings.json", nil)
+ warningsURL = srv.URL
+
+ if _, err := Warnings("1815"); err != nil {
+ t.Fatalf("the recorded feed must parse: %v", err)
+ }
+ none, err := Warnings("9999")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(none) != 0 {
+ t.Fatalf("powiat 9999 does not exist but matched %d warnings", len(none))
+ }
+}