From e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 13:04:10 +0200 Subject: 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. --- cmd/prognosis/resolve_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 cmd/prognosis/resolve_test.go (limited to 'cmd/prognosis/resolve_test.go') diff --git a/cmd/prognosis/resolve_test.go b/cmd/prognosis/resolve_test.go new file mode 100644 index 0000000..f7f1347 --- /dev/null +++ b/cmd/prognosis/resolve_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/lukaszkasprzak/prognosis/internal/cache" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +func tmpStore(t *testing.T) *cache.Cache { + t.Helper() + return cache.New(filepath.Join(t.TempDir(), "cache.json")) +} + +// stubGeocoder replaces the network for the duration of one test. +func stubGeocoder(t *testing.T, cands []openmeteo.Candidate) *int { + t.Helper() + calls := 0 + prev := geocode + geocode = func(string) ([]openmeteo.Candidate, error) { + calls++ + return cands, nil + } + t.Cleanup(func() { geocode = prev }) + return &calls +} + +func twoWirys() []openmeteo.Candidate { + return []openmeteo.Candidate{ + {Geo: cache.Geo{Lat: 52.3205, Lon: 16.8532, Label: "Wiry, PL", Country: "PL"}, Admin1: "Greater Poland"}, + {Geo: cache.Geo{Lat: 50.8367, Lon: 16.6467, Label: "Wiry, PL", Country: "PL"}, Admin1: "Lower Silesia"}, + } +} + +func TestResolveRefusesAnAmbiguousName(t *testing.T) { + stubGeocoder(t, twoWirys()) + _, err := resolve(tmpStore(t), "Wiry, PL", 0) + var amb *ambiguousError + if !errors.As(err, &amb) { + t.Fatalf("got err %v, want an ambiguousError: guessing is what sent the user to the wrong country", err) + } + if len(amb.candidates) != 2 { + t.Errorf("error carries %d candidates, want 2 so the user can choose", len(amb.candidates)) + } +} + +func TestResolveDoesNotCacheAnAmbiguousName(t *testing.T) { + stubGeocoder(t, twoWirys()) + store := tmpStore(t) + if _, err := resolve(store, "Wiry, PL", 0); err == nil { + t.Fatal("expected a refusal") + } + if g, ok := store.Geo("Wiry, PL"); ok { + t.Errorf("cached %+v for an ambiguous name; a wrong guess would stick forever", g) + } +} + +func TestResolvePickSelectsTheNthCandidate(t *testing.T) { + stubGeocoder(t, twoWirys()) + got, err := resolve(tmpStore(t), "Wiry, PL", 2) + if err != nil { + t.Fatal(err) + } + if got.Lat != 50.8367 { + t.Errorf("got lat %v, want 50.8367 (Lower Silesia, the second candidate)", got.Lat) + } +} + +func TestResolvePickRemembersTheChoice(t *testing.T) { + stubGeocoder(t, twoWirys()) + store := tmpStore(t) + if _, err := resolve(store, "Wiry, PL", 2); err != nil { + t.Fatal(err) + } + g, ok := store.Geo("Wiry, PL") + if !ok { + t.Fatal("a picked place was not cached, so the choice must be repeated every run") + } + if g.Lat != 50.8367 { + t.Errorf("cached lat %v, want the picked candidate's 50.8367", g.Lat) + } +} + +func TestResolvePickOutOfRangeIsAnError(t *testing.T) { + stubGeocoder(t, twoWirys()) + _, err := resolve(tmpStore(t), "Wiry, PL", 3) + if err == nil { + t.Fatal("expected an error: silently clamping would pick a place the user did not ask for") + } + // Every other bad flag value in this program exits 2; this must too, which + // means run() has to be able to tell it apart from a network failure. + var pe *pickError + if !errors.As(err, &pe) { + t.Errorf("got %T, want *pickError so run() can exit 2 rather than 1", err) + } +} + +// A name resolved wrongly before this change is still in the cache, and the +// cache is consulted first. Without this, --pick could never repair it. +func TestResolvePickBypassesAPoisonedCacheEntry(t *testing.T) { + calls := stubGeocoder(t, twoWirys()) + store := tmpStore(t) + poison := cache.Geo{Lat: 51.2417, Lon: 26.9411, Label: "Vyry, UA", Country: "UA"} + if err := store.PutGeo("Wiry, PL", poison); err != nil { + t.Fatal(err) + } + got, err := resolve(store, "Wiry, PL", 2) + if err != nil { + t.Fatal(err) + } + if *calls == 0 { + t.Error("--pick used the cache instead of re-resolving, so a bad entry can never be corrected") + } + if got.Country != "PL" { + t.Errorf("got %+v, want the picked Polish candidate", got) + } + if g, _ := store.Geo("Wiry, PL"); g.Country != "PL" { + t.Errorf("cache still holds %+v; the pick should overwrite it", g) + } +} + +func TestResolveCachesAnUnambiguousName(t *testing.T) { + only := []openmeteo.Candidate{ + {Geo: cache.Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}, Admin1: "Subcarpathia"}, + } + calls := stubGeocoder(t, only) + store := tmpStore(t) + for i := 0; i < 2; i++ { + got, err := resolve(store, "Krakow", 0) + if err != nil { + t.Fatalf("run %d: %v", i+1, err) + } + if got.Label != "Krakow, PL" { + t.Fatalf("run %d: got %+v", i+1, got) + } + } + if *calls != 1 { + t.Errorf("geocoded %d times, want 1: the second run should hit the cache", *calls) + } +} + +func TestResolveAcceptsBareCoordinates(t *testing.T) { + stubGeocoder(t, nil) + got, err := resolve(tmpStore(t), "50.8367,16.6467", 0) + if err != nil { + t.Fatal(err) + } + if got.Lat != 50.8367 || got.Lon != 16.6467 { + t.Errorf("got %+v, want the coordinates parsed as given", got) + } +} + +// The listing is the whole remedy: if it omits the region the user cannot tell +// the duplicates apart, and if it omits coordinates there is no way to reach a +// candidate that -pick is not being used for. +func TestAmbiguousListingIsActionable(t *testing.T) { + out := ambiguousListing(&ambiguousError{place: "Wiry, PL", candidates: twoWirys()}) + for _, want := range []string{ + "1", "2", + "Greater Poland", "Lower Silesia", + "52.3205", "50.8367", + "-pick", + } { + if !strings.Contains(out, want) { + t.Errorf("listing is missing %q; user cannot act on it:\n%s", want, out) + } + } +} -- cgit v1.3