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) } } }