diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
| commit | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch) | |
| tree | 670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/cache/cache_test.go | |
| download | prognosis-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/cache/cache_test.go')
| -rw-r--r-- | internal/cache/cache_test.go | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..3283db2 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,282 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" +) + +func tmpCache(t *testing.T) *Cache { + t.Helper() + return New(filepath.Join(t.TempDir(), "sub", "cache.json")) +} + +func TestGeoRoundTrip(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Geo("Krakow"); ok { + t.Fatal("empty cache reported a hit") + } + want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + got, ok := c.Geo("Krakow") + if !ok || got != want { + t.Fatalf("got %+v (%v), want %+v", got, ok, want) + } +} + +// "" is a real answer -- not in Poland -- and must be distinguishable from +// never having asked, or every foreign location re-queries GUGiK forever. +func TestTerytEmptyStringIsARealAnswer(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Teryt("52.5200,13.4000"); ok { + t.Fatal("empty cache reported a hit") + } + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + code, ok := c.Teryt("52.5200,13.4000") + if !ok { + t.Fatal("a cached empty code must report as present") + } + if code != "" { + t.Fatalf("code = %q, want empty", code) + } +} + +func TestCorruptFileIsTreatedAsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + if _, ok := c.Geo("anything"); ok { + t.Fatal("corrupt cache must read as empty, not error") + } + if err := c.PutGeo("x", Geo{Lat: 1}); err != nil { + t.Fatalf("must be able to overwrite a corrupt cache: %v", err) + } +} + +func TestSaveLeavesNoTempFiles(t *testing.T) { + dir := t.TempDir() + c := New(filepath.Join(dir, "cache.json")) + if err := c.PutGeo("a", Geo{Lat: 1}); err != nil { + t.Fatal(err) + } + entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp")) + if len(entries) != 0 { + t.Fatalf("temp files left behind: %v", entries) + } +} + +// The whole point of the atomic write: concurrent writers must never leave a +// file that fails to parse. +func TestConcurrentWritesKeepValidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + New(path).PutGeo("place", Geo{Lat: float64(i)}) + }(i) + } + wg.Wait() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var s store + if err := json.Unmarshal(data, &s); err != nil { + t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data) + } +} + +// The Python implementation shares this file and stores geo entries as +// [lat, lon, label, country]. If Go writes an object instead, Python crashes on +// its own cache -- which is exactly what happened once. +func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + c := New(path) + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + var probe struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(data, &probe); err != nil { + t.Fatalf("geo must decode as arrays: %v\n%s", err, data) + } + entry := probe.Geo["Krakow"] + if len(entry) != 4 { + t.Fatalf("geo entry = %v, want 4 elements", entry) + } + if entry[2] != "Krakow, PL" { + t.Errorf("third element must be the label, got %v", entry[2]) + } +} + +// A cache written in the Python shape must load here unchanged. +func TestReadsPythonWrittenCache(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + body := `{"geo":{"Krakow":[50.06170,19.93730,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1815"}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + g, ok := c.Geo("Krakow") + if !ok || g.Label != "Krakow, PL" || g.Country != "PL" { + t.Fatalf("got %+v (%v)", g, ok) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Fatalf("teryt = %q (%v)", code, ok) + } +} + +// writeCache puts raw bytes where the cache expects its file. +func writeCache(t *testing.T, c *Cache, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// One unreadable entry used to cost the whole file: load treated the parse +// error as "empty cache", and the next save wrote that emptiness over +// everything that was still fine. +func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) { + c := tmpCache(t) + writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1815"}}`) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if _, ok := c.Geo("Gdansk"); !ok { + t.Error("a good entry was destroyed by an unrelated malformed one") + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the new entry was not stored") + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("teryt section lost too: got %q, %v", code, ok) + } + if _, ok := c.Geo("Broken"); ok { + t.Error("the malformed entry should be dropped, not resurrected") + } +} + +// A hand-edit that breaks the whole document must not cost the file. The cache +// is disposable, but whatever was typed into it is not, so it moves aside +// rather than being overwritten -- and the run still gets a working cache. +func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) { + c := tmpCache(t) + const broken = `{"geo":{"Gdansk":[54.35227,` + writeCache(t, c, broken) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil { + t.Fatalf("the unparseable file was not preserved: %v", err) + } + if string(kept) != broken { + t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken) + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the fresh cache did not take the new entry") + } +} + +func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) { + c := tmpCache(t) + const broken = `{oops` + writeCache(t, c, broken) + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil || string(kept) != broken { + t.Errorf("unparseable file not preserved: %q, %v", kept, err) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("fresh cache did not take the entry: %q, %v", code, ok) + } +} + +// The file is read by a human at least as often as by the program. +func TestSaveWritesOneEntryPerLine(t *testing.T) { + c := tmpCache(t) + if err := c.PutGeo("Krakow", Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil { + t.Fatal(err) + } + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + want := `{ + "geo": { + "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"], + "Krakow": [50.06170, 19.93730, "Krakow, PL", "PL"] + }, + "teryt": { + "50.0617,19.9373": "1815" + } +} +` + if string(body) != want { + t.Errorf("got:\n%s\nwant:\n%s", body, want) + } +} + +func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) { + c := tmpCache(t) + want := Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + var got struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("a stock JSON parser could not read it: %v", err) + } + row := got.Geo["Krakow"] + if len(row) != 4 { + t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row) + } + if row[2] != "Krakow, PL" { + t.Errorf("label field is %v, want the third element", row[2]) + } +} + +func TestEmptyCacheSavesReadableJSON(t *testing.T) { + c := tmpCache(t) + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(c.path) + if !json.Valid(body) { + t.Fatalf("invalid JSON with an empty geo section:\n%s", body) + } +} |
