aboutsummaryrefslogtreecommitdiff
path: root/cmd/prognosis/resolve_test.go
blob: f7f13475ff75d7e24945e7ae12d7dd57bac7fa4f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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)
		}
	}
}