diff options
Diffstat (limited to 'cmd/prognosis/resolve_test.go')
| -rw-r--r-- | cmd/prognosis/resolve_test.go | 171 |
1 files changed, 171 insertions, 0 deletions
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) + } + } +} |
