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 | |
| 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')
28 files changed, 4090 insertions, 0 deletions
diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..892df4a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,251 @@ +// Package cache stores the lookups that never change: a place name's +// coordinates, and a coordinate's TERYT powiat code. +// +// Forecasts are never cached -- they would be stale immediately. +package cache + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Geo is a resolved location. +type Geo struct { + Lat float64 + Lon float64 + Label string + Country string +} + +// Geo is stored as a 4-element array, not an object, because the Python +// implementation shares this file and reads [lat, lon, label, country]. The two +// coexist until the Go version reaches parity, and a cache one of them cannot +// read makes the other crash on its own data. +func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() } + +// jsonLine renders the stored array with a space after each comma, which is how +// it is written to disk. encoding/json compacts whatever a Marshaler returns, so +// the file writer calls this directly; going through json.Marshal would strip +// the spaces again. Element order is the contract described above, kept here so +// it is stated once. +func (g Geo) jsonLine() ([]byte, error) { + parts := make([][]byte, 0, 4) + for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + parts = append(parts, b) + } + return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil +} + +func (g *Geo) UnmarshalJSON(b []byte) error { + var raw []any + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if len(raw) < 3 { + return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw)) + } + lat, ok1 := raw[0].(float64) + lon, ok2 := raw[1].(float64) + if !ok1 || !ok2 { + return fmt.Errorf("geo entry has non-numeric coordinates") + } + label, _ := raw[2].(string) + country := "" + if len(raw) > 3 { + country, _ = raw[3].(string) + } + *g = Geo{Lat: lat, Lon: lon, Label: label, Country: country} + return nil +} + +type store struct { + Geo map[string]Geo `json:"geo"` + Teryt map[string]string `json:"teryt"` +} + +// Cache is a JSON file holding both sections. +type Cache struct{ path string } + +// New returns a cache backed by path. The file need not exist. +func New(path string) *Cache { return &Cache{path: path} } + +// DefaultPath is ~/.cache/prognosis/cache.json. +func DefaultPath() string { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "prognosis", "cache.json") + } + return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json") +} + +// load never fails the run: a cache it cannot read is treated as empty, because +// losing a cache costs one extra request and failing the run costs the forecast. +// +// It salvages per entry rather than per file. Decoding the sections whole meant +// one unreadable entry made the entire cache look empty, and the save that +// followed then wrote that emptiness over every entry that was still good. +// +// The second result reports whether the file was usable. It is false only when +// the document itself will not parse, which tells the writers to move it aside +// before replacing it: the cache is disposable, but a file someone hand-edited +// is the only copy of what they typed. +func (c *Cache) load() (store, bool) { + s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}} + data, err := os.ReadFile(c.path) + if err != nil { + return s, true // absent is not corrupt; a fresh cache may be written + } + if len(bytes.TrimSpace(data)) == 0 { + return s, true // an empty file is simply no cache yet + } + var raw struct { + Geo map[string]json.RawMessage `json:"geo"` + Teryt map[string]json.RawMessage `json:"teryt"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return s, false + } + for k, v := range raw.Geo { + var g Geo + if err := json.Unmarshal(v, &g); err == nil { + s.Geo[k] = g + } + } + for k, v := range raw.Teryt { + var code string + if err := json.Unmarshal(v, &code); err == nil { + s.Teryt[k] = code + } + } + return s, true +} + +// encode writes the store one entry per line, keys sorted so the file is stable +// between runs. It is small, hand-edited, and read in a terminal, none of which +// a single long line serves. +func encode(s store) ([]byte, error) { + geo := make(map[string]json.RawMessage, len(s.Geo)) + for k, g := range s.Geo { + v, err := g.jsonLine() + if err != nil { + return nil, err + } + geo[k] = v + } + teryt := make(map[string]json.RawMessage, len(s.Teryt)) + for k, code := range s.Teryt { + v, err := json.Marshal(code) + if err != nil { + return nil, err + } + teryt[k] = v + } + var b bytes.Buffer + b.WriteString("{\n") + writeSection(&b, "geo", geo) + b.WriteString(",\n") + writeSection(&b, "teryt", teryt) + b.WriteString("\n}\n") + return b.Bytes(), nil +} + +func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) { + if len(entries) == 0 { + fmt.Fprintf(b, " %q: {}", name) + return + } + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(b, " %q: {\n", name) + for i, k := range keys { + key, err := json.Marshal(k) // a place name may contain anything + if err != nil { + continue + } + fmt.Fprintf(b, " %s: %s", key, entries[k]) + if i < len(keys)-1 { + b.WriteByte(',') + } + b.WriteByte('\n') + } + b.WriteString(" }") +} + +// save writes atomically: a temporary file in the same directory, then a +// rename. Two runs at once would otherwise interleave and leave truncated JSON +// that the next run silently reads as an empty cache. +func (c *Cache) save(s store) error { + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid()) + data, err := encode(s) + if err != nil { + return err + } + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, c.path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// quarantine moves a cache that will not parse to <path>.bad, so replacing it +// costs nothing that cannot be recovered. Losing the cache is cheap -- one extra +// request -- but losing a hand-edit is not, and the two used to be the same act. +func (c *Cache) quarantine() error { + return os.Rename(c.path, c.path+".bad") +} + +// Geo returns a cached location. +func (c *Cache) Geo(place string) (Geo, bool) { + s, _ := c.load() + g, ok := s.Geo[place] + return g, ok +} + +// PutGeo records a location. +func (c *Cache) PutGeo(place string, g Geo) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Geo[place] = g + return c.save(s) +} + +// Teryt returns a cached powiat code. The empty string is a real answer meaning +// "GUGiK knows this point is not in Poland"; the boolean distinguishes it from +// never having asked. +func (c *Cache) Teryt(key string) (string, bool) { + s, _ := c.load() + code, ok := s.Teryt[key] + return code, ok +} + +// PutTeryt records a powiat code, or "" for a point outside Poland. +func (c *Cache) PutTeryt(key, code string) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Teryt[key] = code + return c.save(s) +} 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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..51d8c27 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,360 @@ +// Package config reads prognosis' KEY=VALUE configuration file. +// +// The format is deliberately the same shape as wego's ~/.wegorc: one KEY=VALUE +// per line, '#' starts a comment, values are never quoted. Parsing it here +// rather than pulling in a config library keeps the binary dependency-free. +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// Columns available for the hourly table, in the order they are documented. +// The value is the Open-Meteo hourly field the column needs, or "" when the +// column is derived from data already fetched. +var columnFields = map[string]string{ + "hour": "", + "icon": "weather_code", + "temp": "temperature_2m", + "feels": "apparent_temperature", + "conditions": "weather_code", + "mm": "precipitation", + "rain": "precipitation_probability", + "wind": "wind_speed_10m", + "gusts": "wind_gusts_10m", + "dir": "wind_direction_10m", + "humidity": "relative_humidity_2m", + "dew": "dew_point_2m", + "uv": "uv_index", + "cloud": "cloud_cover", + "pressure": "pressure_msl", + "visibility": "visibility", +} + +var ( + validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true} + validColors = map[string]bool{"auto": true, "always": true, "never": true} + validUnits = map[string]bool{"metric": true, "imperial": true, "si": true} + validLangs = map[string]bool{"en": true, "pl": true} +) + +// AllSpecies is every pollen taxon Open-Meteo reports for Europe. +var AllSpecies = []string{"grass", "birch", "alder", "mugwort", "ragweed", "olive"} + +// Config is the fully resolved settings for one run. +type Config struct { + Location string + Hours int + Units string + Columns []string + Icons string + Graph bool + GraphHeight int + Warnings bool + Pollen []string + Color string + DisplayLang string + + // Minimal strips everything that is not the forecast itself: sun times, the + // day summary and pollen. Set by -weather, for output meant to be piped to + // someone who did not ask for pollen counts. Flag only -- there is no config + // key, because it describes one invocation rather than a preference. + Minimal bool + + // ASCII restricts output to ASCII so an SMS stays in GSM-7 (160 characters + // per segment) instead of UCS-2 (70). One degree sign costs more than half + // the message. + ASCII bool +} + +// Default returns the built-in configuration, used when no file exists and as +// the base every file and flag overrides. +func Default() Config { + return Config{ + Hours: 12, + Units: "metric", + Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"}, + Icons: "nerd", + Graph: true, + GraphHeight: 5, + Warnings: true, + Pollen: append([]string(nil), AllSpecies...), + Color: "auto", + DisplayLang: "en", + } +} + +// Path is the default location of the config file. +func Path() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "prognosis", "config") + } + return filepath.Join(os.Getenv("HOME"), ".config", "prognosis", "config") +} + +// ValidColumns lists every column name, sorted, for error messages and docs. +func ValidColumns() []string { + names := make([]string, 0, len(columnFields)) + for k := range columnFields { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Fields returns the Open-Meteo hourly fields the selected columns need. +// Only what is displayed is requested, so a narrow table costs a small response. +func (c Config) Fields() []string { + seen := map[string]bool{} + var out []string + for _, col := range c.Columns { + f := columnFields[col] + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// Has reports whether a column is selected. +func (c Config) Has(column string) bool { + for _, col := range c.Columns { + if col == column { + return true + } + } + return false +} + +// Validate rejects unusable settings, naming the offending value and listing +// what would have been accepted. A silently blank column is worse than an error. +func (c Config) Validate() error { + for _, col := range c.Columns { + if _, ok := columnFields[col]; !ok { + return fmt.Errorf("unknown column %q; valid: %s", + col, strings.Join(ValidColumns(), ", ")) + } + } + if !validIcons[c.Icons] { + return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons) + } + if !validColors[c.Color] { + return fmt.Errorf("unknown color %q; valid: auto, always, never", c.Color) + } + if !validUnits[c.Units] { + return fmt.Errorf("unknown units %q; valid: metric, imperial, si", c.Units) + } + if !validLangs[c.DisplayLang] { + return fmt.Errorf("unknown display_lang %q; valid: en, pl", c.DisplayLang) + } + if c.Hours < 1 { + return fmt.Errorf("hours must be at least 1, got %d", c.Hours) + } + if c.GraphHeight < 2 { + return fmt.Errorf("graph_height must be at least 2, got %d", c.GraphHeight) + } + for _, s := range c.Pollen { + if !contains(AllSpecies, s) { + return fmt.Errorf("unknown pollen species %q; valid: %s, all, none", + s, strings.Join(AllSpecies, ", ")) + } + } + return nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// Load reads a config file over the defaults. A missing file is not an error: +// the defaults stand, and the caller may write them out. +func Load(path string) (Config, error) { + cfg := Default() + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for line := 1; sc.Scan(); line++ { + text := strings.TrimSpace(sc.Text()) + if text == "" || strings.HasPrefix(text, "#") { + continue + } + // Trailing comments are allowed so the generated file can annotate keys. + if i := strings.Index(text, "#"); i >= 0 { + text = strings.TrimSpace(text[:i]) + } + key, value, ok := strings.Cut(text, "=") + if !ok { + return cfg, fmt.Errorf("%s:%d: expected KEY=VALUE, got %q", path, line, text) + } + if err := cfg.set(strings.TrimSpace(key), strings.TrimSpace(value)); err != nil { + return cfg, fmt.Errorf("%s:%d: %w", path, line, err) + } + } + return cfg, sc.Err() +} + +func (c *Config) set(key, value string) error { + switch key { + case "location": + c.Location = value + case "units": + c.Units = value + case "icons": + c.Icons = value + case "color": + c.Color = value + case "display_lang": + c.DisplayLang = value + case "ascii": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("ascii: %w", err) + } + c.ASCII = b + case "columns": + c.Columns = splitList(value) + case "pollen": + switch value { + case "all": + c.Pollen = append([]string(nil), AllSpecies...) + case "none": + c.Pollen = nil + default: + c.Pollen = splitList(value) + } + case "hours": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("hours: %q is not a number", value) + } + c.Hours = n + case "graph_height": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("graph_height: %q is not a number", value) + } + c.GraphHeight = n + case "graph": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("graph: %w", err) + } + c.Graph = b + case "warnings": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("warnings: %w", err) + } + c.Warnings = b + default: + return fmt.Errorf("unknown key %q", key) + } + return nil +} + +func parseBool(v string) (bool, error) { + switch strings.ToLower(v) { + case "true", "yes", "on", "1": + return true, nil + case "false", "no", "off", "0": + return false, nil + } + return false, fmt.Errorf("%q is not true or false", v) +} + +func splitList(v string) []string { + var out []string + for _, part := range strings.Split(v, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +const template = `# prognosis configuration +# +# One KEY=VALUE per line. '#' starts a comment. Values are not quoted. +# Command line flags override everything here. + +# Place to query. When empty, location= from ~/.wegorc is used, so prognosis +# and wego never disagree about where you are. +location=%s + +# Default span in hours. -n and -d override it. +hours=%d + +# metric (C, km/h, mm) | imperial (F, mph, inch) | si (C, m/s, mm) +units=%s + +# Columns, in order. Available: +# %s +columns=%s + +# Weather glyph set for the "icon" column: nerd | emoji | none. +# nerd is single-width and monochrome, so it follows the terminal palette. +# emoji are colour glyphs from a fallback font and are not all one cell wide. +icons=%s + +# Temperature chart under the table. +graph=%t +graph_height=%d + +# Official IMGW warnings for your powiat (Poland only). +warnings=%t + +# Pollen species to report, or "all" / "none". +pollen=%s + +# Restrict output to ASCII: no degree sign, no diacritics, no block drawing. +# For SMS, where one non-ASCII character cuts the segment from 160 to 70 chars. +ascii=%t + +# auto (colour when stdout is a terminal) | always | never +color=%s + +# Language for everything prognosis writes itself -- headers, condition names, +# labels, dates, pollen species: en | pl. IMGW publishes its warning text in +# Polish only, so that text stays Polish whatever this is set to. +display_lang=%s +` + +// WriteDefault writes a commented configuration file, creating parent +// directories. The generated file documents every key, so the config is +// discoverable without the README. +func WriteDefault(path string, c Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + pollen := "none" + if len(c.Pollen) > 0 { + pollen = strings.Join(c.Pollen, ",") + } + body := fmt.Sprintf(template, + c.Location, c.Hours, c.Units, + strings.Join(ValidColumns(), ", "), + strings.Join(c.Columns, ","), + c.Icons, c.Graph, c.GraphHeight, c.Warnings, pollen, c.ASCII, c.Color, c.DisplayLang) + return os.WriteFile(path, []byte(body), 0o644) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..06c0234 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadMissingFileKeepsDefaults(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Fatalf("missing file should not be an error: %v", err) + } + if cfg.Hours != 12 || cfg.Icons != "nerd" { + t.Fatalf("defaults not returned: %+v", cfg) + } +} + +func TestLoadOverridesOnlyWhatIsSet(t *testing.T) { + cfg, err := Load(write(t, "hours=24\nicons=emoji\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 24 { + t.Errorf("hours = %d, want 24", cfg.Hours) + } + if cfg.Icons != "emoji" { + t.Errorf("icons = %q, want emoji", cfg.Icons) + } + // Untouched keys must keep their defaults. + if cfg.GraphHeight != 5 || !cfg.Graph { + t.Errorf("unset keys lost their defaults: %+v", cfg) + } +} + +func TestLoadCommentsAndBlanks(t *testing.T) { + cfg, err := Load(write(t, "\n# a comment\n\nhours=6 # trailing comment\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 6 { + t.Fatalf("hours = %d, want 6 (trailing comment must be stripped)", cfg.Hours) + } +} + +func TestLoadErrors(t *testing.T) { + for name, body := range map[string]string{ + "no equals": "hours 12\n", + "unknown key": "colour=always\n", + "not a number": "hours=soon\n", + "not a bool": "graph=maybe\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(write(t, body)); err == nil { + t.Fatalf("expected an error for %q", body) + } + }) + } +} + +func TestPollenAllAndNone(t *testing.T) { + cfg, _ := Load(write(t, "pollen=all\n")) + if len(cfg.Pollen) != len(AllSpecies) { + t.Errorf("pollen=all gave %v", cfg.Pollen) + } + cfg, _ = Load(write(t, "pollen=none\n")) + if len(cfg.Pollen) != 0 { + t.Errorf("pollen=none gave %v", cfg.Pollen) + } +} + +func TestValidateNamesTheOffender(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "tempature"} + err := cfg.Validate() + if err == nil { + t.Fatal("expected an error for an unknown column") + } + if !strings.Contains(err.Error(), "tempature") { + t.Errorf("error must name the offending column, got: %v", err) + } + if !strings.Contains(err.Error(), "conditions") { + t.Errorf("error must list valid columns, got: %v", err) + } +} + +func TestValidateRejectsBadEnums(t *testing.T) { + for name, mutate := range map[string]func(*Config){ + "icons": func(c *Config) { c.Icons = "pictures" }, + "color": func(c *Config) { c.Color = "sometimes" }, + "units": func(c *Config) { c.Units = "furlongs" }, + "hours": func(c *Config) { c.Hours = 0 }, + "graph_height": func(c *Config) { c.GraphHeight = 1 }, + "pollen": func(c *Config) { c.Pollen = []string{"oak"} }, + } { + t.Run(name, func(t *testing.T) { + cfg := Default() + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatalf("expected %s to be rejected", name) + } + }) + } +} + +func TestDefaultIsValid(t *testing.T) { + if err := Default().Validate(); err != nil { + t.Fatalf("the built-in default must be valid: %v", err) + } +} + +func TestFieldsRequestsOnlySelectedColumns(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "temp", "wind"} + got := strings.Join(cfg.Fields(), ",") + want := "temperature_2m,wind_speed_10m" + if got != want { + t.Fatalf("Fields() = %q, want %q", got, want) + } +} + +func TestFieldsDeduplicates(t *testing.T) { + cfg := Default() + // icon and conditions both come from weather_code. + cfg.Columns = []string{"icon", "conditions"} + if got := cfg.Fields(); len(got) != 1 || got[0] != "weather_code" { + t.Fatalf("Fields() = %v, want one weather_code", got) + } +} + +func TestWriteDefaultRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "config") + want := Default() + want.Location = "Krakow" + if err := WriteDefault(path, want); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("the file we generate must parse: %v", err) + } + if got.Location != "Krakow" || got.Hours != want.Hours || got.Icons != want.Icons { + t.Fatalf("round trip changed values:\n got %+v\nwant %+v", got, want) + } + if err := got.Validate(); err != nil { + t.Fatalf("the file we generate must validate: %v", err) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..f0526be --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,196 @@ +// Package i18n holds the translations for everything prognosis writes itself: +// column headers, condition names, section labels, weekday and month names, and +// pollen species and bands. +// +// What is deliberately NOT translated: the body of an IMGW warning. IMGW +// publishes those in Polish only, and rendering an invented English version of +// an official warning would be worse than showing the original. In English mode +// the surrounding labels are English and the warning text stays Polish. +package i18n + +import ( + "fmt" + "time" +) + +// Lang is a supported display language. +type Lang string + +const ( + EN Lang = "en" + PL Lang = "pl" +) + +// Catalog is the set of strings for one language. +type Catalog struct { + lang Lang + + // Table headers, keyed by column name. + headers map[string]string + // WMO weather code descriptions. + conditions map[int]string + // Section labels and short words. + words map[string]string + // Pollen taxa. + species map[string]string + // Qualitative pollen levels. + bands map[string]string + + weekdays [7]string + months [12]string +} + +var catalogs = map[Lang]*Catalog{EN: english(), PL: polish()} + +// For returns the catalogue for a language, falling back to English so a +// mistyped language degrades to readable output rather than empty strings. +func For(lang string) *Catalog { + if c, ok := catalogs[Lang(lang)]; ok { + return c + } + return catalogs[EN] +} + +// Lang reports which language this catalogue is. +func (c *Catalog) Lang() Lang { return c.lang } + +// Header is the column heading for a column name. +func (c *Catalog) Header(column string) string { + if s, ok := c.headers[column]; ok { + return s + } + return column +} + +// Condition describes a WMO weather code. Unknown codes are reported as such +// rather than silently blank, so a new code in the API is visible. +func (c *Catalog) Condition(code int) string { + if s, ok := c.conditions[code]; ok { + return s + } + return fmt.Sprintf("code %d", code) +} + +// Word returns a label such as "day", "pollen" or "warnings". +func (c *Catalog) Word(key string) string { + if s, ok := c.words[key]; ok { + return s + } + return key +} + +// Species is the display name of a pollen taxon. +func (c *Catalog) Species(name string) string { + if s, ok := c.species[name]; ok { + return s + } + return name +} + +// Band is the display name of a qualitative pollen level. +func (c *Catalog) Band(key string) string { + if s, ok := c.bands[key]; ok { + return s + } + return key +} + +// Date formats a date as "Mon 10 Aug" in the catalogue's language. Go's time +// package has no locale support, so the names come from the catalogue. +func (c *Catalog) Date(t time.Time) string { + return fmt.Sprintf("%s %02d %s", c.weekdays[int(t.Weekday())], t.Day(), c.months[int(t.Month())-1]) +} + +func english() *Catalog { + return &Catalog{ + lang: EN, + headers: map[string]string{ + "hour": "hr", "icon": "", "temp": "temp", "feels": "feels", + "conditions": "conditions", "mm": "mm", "rain": "rain", + "wind": "wind", "gusts": "gusts", "dir": "dir", + "humidity": "hum", "dew": "dew", "uv": "uv", + "cloud": "cloud", "pressure": "hPa", "visibility": "vis", + }, + conditions: map[int]string{ + 0: "clear", 1: "mainly clear", 2: "part cloudy", 3: "overcast", + 45: "fog", 48: "rime fog", + 51: "lt drizzle", 53: "drizzle", 55: "hvy drizzle", + 56: "frz drizzle", 57: "frz drizzle", + 61: "lt rain", 63: "rain", 65: "hvy rain", + 66: "frz rain", 67: "frz rain", + 71: "lt snow", 73: "snow", 75: "hvy snow", 77: "snow grains", + 80: "lt showers", 81: "showers", 82: "hvy showers", + 85: "snow showers", 86: "hvy snow showers", + 95: "thunderstorm", 96: "storm + hail", 99: "storm + hail", + }, + words: map[string]string{ + "sun": "sun", "up": "up", "down": "down", + "day": "day", "dry": "dry", "pollen": "pollen", + "rainfall": "rain", "over": "over", "daylight": "daylight", + "of": "of", "max": "max", "level": "level", + "warnings": "warnings", "could_not_check": "could not check IMGW", + "poland_only": "IMGW covers Poland only", + "hours_available": "hours available, not", + "h_per_col": "h/col", "temp_row": "temp", "rain_row": "rain", + }, + species: map[string]string{ + "grass": "grass", "birch": "birch", "alder": "alder", + "mugwort": "mugwort", "ragweed": "ragweed", "olive": "olive", + }, + bands: map[string]string{ + "none": "none", "low": "low", "medium": "medium", + "high": "high", "very high": "very high", + }, + weekdays: [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}, + months: [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}, + } +} + +func polish() *Catalog { + return &Catalog{ + lang: PL, + headers: map[string]string{ + "hour": "godz", "icon": "", "temp": "temp", "feels": "odczuw", + "conditions": "warunki", "mm": "mm", "rain": "deszcz", + "wind": "wiatr", "gusts": "porywy", "dir": "kier", + "humidity": "wilg", "dew": "ros", "uv": "uv", + "cloud": "chmury", "pressure": "hPa", "visibility": "wid", + }, + // Terminology follows IMGW's own vocabulary where it has one, so the + // table and a warning describe the same weather in the same words. + conditions: map[int]string{ + 0: "bezchmurnie", 1: "gł. bezchmurnie", 2: "częśc. zachm.", 3: "zachmurzenie", + 45: "mgła", 48: "mgła osadz.", + 51: "słaba mżawka", 53: "mżawka", 55: "silna mżawka", + 56: "mżawka marzn.", 57: "mżawka marzn.", + 61: "słaby deszcz", 63: "deszcz", 65: "silny deszcz", + 66: "deszcz marzn.", 67: "deszcz marzn.", + 71: "słaby śnieg", 73: "śnieg", 75: "silny śnieg", 77: "ziarna śniegu", + 80: "słabe przelotne", 81: "przelotne", 82: "silne przelotne", + 85: "przelotny śnieg", 86: "silny przel. śnieg", + 95: "burza", 96: "burza z gradem", 99: "burza z gradem", + }, + words: map[string]string{ + "sun": "słońce", "up": "wsch", "down": "zach", + "day": "dzień", "dry": "sucho", "pollen": "pyłek", + "rainfall": "opad", "over": "przez", "daylight": "dnia", + "of": "z", "max": "maks", "level": "stopień", + "warnings": "ostrzeżenia", "could_not_check": "nie udało się sprawdzić IMGW", + "poland_only": "IMGW obejmuje tylko Polskę", + "hours_available": "godzin dostępnych, nie", + "h_per_col": "h/kol", "temp_row": "temp", "rain_row": "opad", + }, + species: map[string]string{ + "grass": "trawy", "birch": "brzoza", "alder": "olcha", + "mugwort": "bylica", "ragweed": "ambrozja", "olive": "oliwka", + }, + bands: map[string]string{ + "none": "brak", "low": "niskie", "medium": "średnie", + "high": "wysokie", "very high": "b. wysokie", + }, + weekdays: [7]string{"nd", "pn", "wt", "śr", "cz", "pt", "sb"}, + months: [12]string{"sty", "lut", "mar", "kwi", "maj", "cze", + "lip", "sie", "wrz", "paź", "lis", "gru"}, + } +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..2a6a617 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,88 @@ +package i18n + +import ( + "testing" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/config" +) + +func TestForFallsBackToEnglish(t *testing.T) { + if For("klingon").Lang() != EN { + t.Fatal("an unknown language must degrade to English, not to empty strings") + } + if For("pl").Lang() != PL { + t.Fatal("pl must resolve to the Polish catalogue") + } +} + +// Every catalogue must cover every column, or a table in that language would +// silently fall back to the internal column name. +func TestEveryLanguageCoversEveryColumn(t *testing.T) { + for _, lang := range []Lang{EN, PL} { + c := For(string(lang)) + for _, col := range config.ValidColumns() { + if _, ok := c.headers[col]; !ok { + t.Errorf("%s: no header for column %q", lang, col) + } + } + } +} + +// The WMO codes must match across languages: a code described in English but +// not Polish would print "code 71" to a Polish reader. +func TestConditionCoverageMatchesAcrossLanguages(t *testing.T) { + en, pl := For("en"), For("pl") + for code := range en.conditions { + if _, ok := pl.conditions[code]; !ok { + t.Errorf("code %d described in English but not Polish", code) + } + } + for code := range pl.conditions { + if _, ok := en.conditions[code]; !ok { + t.Errorf("code %d described in Polish but not English", code) + } + } +} + +func TestWordAndSpeciesCoverageMatches(t *testing.T) { + en, pl := For("en"), For("pl") + for k := range en.words { + if _, ok := pl.words[k]; !ok { + t.Errorf("word %q missing from Polish", k) + } + } + for _, s := range config.AllSpecies { + if _, ok := en.species[s]; !ok { + t.Errorf("species %q missing from English", s) + } + if _, ok := pl.species[s]; !ok { + t.Errorf("species %q missing from Polish", s) + } + } + for _, b := range []string{"none", "low", "medium", "high", "very high"} { + if en.Band(b) == b && b != "none" && b != "low" && b != "medium" && b != "high" && b != "very high" { + t.Errorf("band %q missing from English", b) + } + if pl.Band(b) == b { + t.Errorf("band %q not translated to Polish", b) + } + } +} + +func TestUnknownCodeIsVisible(t *testing.T) { + got := For("en").Condition(4242) + if got != "code 4242" { + t.Fatalf("unknown codes must be visible, got %q", got) + } +} + +func TestDate(t *testing.T) { + d := time.Date(2026, 8, 10, 13, 0, 0, 0, time.UTC) // a Monday + if got, want := For("en").Date(d), "Mon 10 Aug"; got != want { + t.Errorf("en date = %q, want %q", got, want) + } + if got, want := For("pl").Date(d), "pn 10 sie"; got != want { + t.Errorf("pl date = %q, want %q", got, want) + } +} diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go new file mode 100644 index 0000000..48ffe19 --- /dev/null +++ b/internal/imgw/imgw.go @@ -0,0 +1,156 @@ +// Package imgw fetches official Polish meteorological warnings and resolves a +// point to the powiat code those warnings are tagged with. +// +// Both services are public and need no key. +package imgw + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Endpoints are variables so tests can serve recorded fixtures locally. +var ( + warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo" + gugikURL = "https://services.gugik.gov.pl/uug/" +) + +// now is the clock, replaceable in tests: whether a warning has expired depends +// on it. +var now = time.Now + +// Timeout bounds every request. +var Timeout = 15 * time.Second + +// Status is the outcome of resolving a point to a powiat. +type Status int + +const ( + // StatusOK means the point resolved to a powiat code. + StatusOK Status = iota + // StatusOutside means GUGiK answered but knows no address there. It covers + // Poland only, so the point is abroad. + StatusOutside + // StatusError means the service could not be asked. + // + // This must never be conflated with StatusOutside: one means "no warnings + // apply here", the other "I do not know whether any apply". + StatusError +) + +// Warning is one IMGW warning in force. +type Warning struct { + Event string `json:"nazwa_zdarzenia"` + Level string `json:"stopien"` + Probability string `json:"prawdopodobienstwo"` + From string `json:"obowiazuje_od"` + To string `json:"obowiazuje_do"` + Text string `json:"tresc"` + Teryt []any `json:"teryt"` +} + +func fetch(rawURL string, params url.Values, into any) error { + host := "" + if u, err := url.Parse(rawURL); err == nil { + host = u.Host + } + full := rawURL + if len(params) > 0 { + full += "?" + params.Encode() + } + req, err := http.NewRequest("GET", full, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "prognosis/1.0") + resp, err := (&http.Client{Timeout: Timeout}).Do(req) + if err != nil { + return fmt.Errorf("cannot reach %s: %w", host, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, into) +} + +type gugikResponse struct { + Results map[string]struct { + Teryt string `json:"teryt"` + } `json:"results"` +} + +// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK. +// +// IMGW tags every warning with the powiat codes it covers, so this is what +// makes "warnings for my area" mean this area rather than the whole country. +func Powiat(lat, lon float64) (string, Status, error) { + var r gugikResponse + err := fetch(gugikURL, url.Values{ + "request": {"GetAddressReverse"}, + "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)}, + "srid": {"4326"}, + // The default 100 m radius finds nothing in the mountains or deep + // countryside, which looks identical to being abroad and would suppress + // real warnings. GUGiK clamps this to its own 5 km maximum. + "radius": {"10000"}, + }, &r) + if err != nil { + return "", StatusError, err + } + for _, entry := range r.Results { + if len(entry.Teryt) >= 4 { + return entry.Teryt[:4], StatusOK, nil + } + } + return "", StatusOutside, nil +} + +// Warnings returns the warnings in force for a powiat. +func Warnings(powiat string) ([]Warning, error) { + var all []Warning + if err := fetch(warningsURL, nil, &all); err != nil { + return nil, err + } + cut := now() + var live []Warning + for _, w := range all { + if !w.covers(powiat) { + continue + } + // An expired warning is dropped; one whose date will not parse is kept, + // because showing a stale warning beats hiding a live one. + if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil { + if to.Before(cut) { + continue + } + } + live = append(live, w) + } + return live, nil +} + +func (w Warning) covers(powiat string) bool { + for _, a := range w.Teryt { + switch v := a.(type) { + case string: + if v == powiat { + return true + } + case float64: + if strconv.Itoa(int(v)) == powiat { + return true + } + } + } + return false +} diff --git a/internal/imgw/imgw_test.go b/internal/imgw/imgw_test.go new file mode 100644 index 0000000..ffc678e --- /dev/null +++ b/internal/imgw/imgw_test.go @@ -0,0 +1,190 @@ +package imgw + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if gotQuery != nil { + *gotQuery = r.URL.RawQuery + } + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func serveText(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func pinClock(t *testing.T, at time.Time) { + t.Helper() + old := now + now = func() time.Time { return at } + t.Cleanup(func() { now = old }) +} + +// A point inside Poland resolves to the first four digits of its TERYT code. +func TestPowiatTruncatesTerytToFourDigits(t *testing.T) { + srv := serve(t, "gugik_krakow.json", nil) + gugikURL = srv.URL + code, status, err := Powiat(50.0617, 19.9373) + if err != nil { + t.Fatal(err) + } + if status != StatusOK { + t.Fatalf("status = %v, want StatusOK", status) + } + if code != "1815" { + t.Fatalf("code = %q, want 1815 (first four digits of 126101)", code) + } +} + +// GUGiK answers a foreign point with HTTP 200 and no results. That is a real +// answer -- "not in Poland" -- and must never be confused with a failure, which +// is the difference between "no warnings apply" and "I do not know". +func TestPowiatOutsidePolandIsAnAnswerNotAnError(t *testing.T) { + srv := serve(t, "gugik_abroad.json", nil) + gugikURL = srv.URL + code, status, err := Powiat(52.52, 13.40) + if err != nil { + t.Fatalf("a valid 'no results' response must not be an error: %v", err) + } + if status != StatusOutside { + t.Fatalf("status = %v, want StatusOutside", status) + } + if code != "" { + t.Fatalf("code = %q, want empty", code) + } +} + +func TestPowiatUnreachableIsStatusError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + srv.Close() // closed: the request cannot connect at all + gugikURL = srv.URL + _, status, err := Powiat(50, 21) + if status != StatusError { + t.Fatalf("status = %v, want StatusError", status) + } + if err == nil { + t.Fatal("expected an error describing the failure") + } +} + +// The default 100 m radius finds nothing in the mountains, which is +// indistinguishable from being abroad and would suppress real warnings. +func TestPowiatAsksForAWideRadius(t *testing.T) { + var query string + srv := serve(t, "gugik_krakow.json", &query) + gugikURL = srv.URL + if _, _, err := Powiat(50, 21); err != nil { + t.Fatal(err) + } + if !strings.Contains(query, "radius=10000") { + t.Fatalf("query %q must ask for a wide radius", query) + } +} + +func TestWarningsKeepOnlyThisPowiat(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) // nothing expired + body := `[ + {"nazwa_zdarzenia":"Upal","stopien":"1","obowiazuje_do":"2030-01-01 00:00:00","teryt":["1815","1234"]}, + {"nazwa_zdarzenia":"Burze","stopien":"2","obowiazuje_do":"2030-01-01 00:00:00","teryt":["2207"]} + ]` + srv := serveText(t, body) + warningsURL = srv.URL + + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].Event != "Upal" { + t.Fatalf("got %+v, want only the warning covering 1815", live) + } +} + +func TestWarningsDropExpiredButKeepUnparseableDates(t *testing.T) { + pinClock(t, time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local)) + body := `[ + {"nazwa_zdarzenia":"Wczorajsze","stopien":"1","obowiazuje_do":"2026-08-09 23:00:00","teryt":["1815"]}, + {"nazwa_zdarzenia":"Trwajace","stopien":"1","obowiazuje_do":"2026-08-10 20:00:00","teryt":["1815"]}, + {"nazwa_zdarzenia":"Bezdaty","stopien":"1","obowiazuje_do":"","teryt":["1815"]} + ]` + srv := serveText(t, body) + warningsURL = srv.URL + + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + var names []string + for _, w := range live { + names = append(names, w.Event) + } + got := strings.Join(names, ",") + // Unparseable is kept: showing a stale warning beats hiding a live one. + if got != "Trwajace,Bezdaty" { + t.Fatalf("got %q, want \"Trwajace,Bezdaty\"", got) + } +} + +// IMGW has been seen to encode TERYT codes as numbers as well as strings. +func TestWarningsMatchNumericTerytCodes(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) + srv := serveText(t, `[{"nazwa_zdarzenia":"X","obowiazuje_do":"2030-01-01 00:00:00","teryt":[1815]}]`) + warningsURL = srv.URL + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + if len(live) != 1 { + t.Fatalf("a numeric teryt entry must still match, got %d warnings", len(live)) + } +} + +func TestWarningsUnreachableIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + warningsURL = srv.URL + if _, err := Warnings("1815"); err == nil { + t.Fatal("expected an error: the caller must be able to say 'could not check'") + } +} + +// The recorded national feed must parse, and must not match a made-up powiat. +func TestWarningsParseTheRecordedFeed(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) + srv := serve(t, "warnings.json", nil) + warningsURL = srv.URL + + if _, err := Warnings("1815"); err != nil { + t.Fatalf("the recorded feed must parse: %v", err) + } + none, err := Warnings("9999") + if err != nil { + t.Fatal(err) + } + if len(none) != 0 { + t.Fatalf("powiat 9999 does not exist but matched %d warnings", len(none)) + } +} diff --git a/internal/imgw/testdata/gugik_abroad.json b/internal/imgw/testdata/gugik_abroad.json new file mode 100644 index 0000000..f6e58d8 --- /dev/null +++ b/internal/imgw/testdata/gugik_abroad.json @@ -0,0 +1 @@ +{"type":"address","max results limit":1,"radius":5000,"max polygon area":null,"returned objects":0,"results":null,"request time":0.00072391430536905923}
\ No newline at end of file diff --git a/internal/imgw/testdata/warnings.json b/internal/imgw/testdata/warnings.json new file mode 100644 index 0000000..2f6b37f --- /dev/null +++ b/internal/imgw/testdata/warnings.json @@ -0,0 +1 @@ +[{"id":"Wr20260810041817563","nazwa_zdarzenia":"Burze","stopien":"2","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 miejscami burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h, a punktowo mo\u017cliwe porywy do oko\u0142o 100 km\/h. Lokalnie grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["2209","2210","2216","2808","2810","1462","2012","2062","2063","1427","2004","2006","2007","2813","2861","0406","0408","0462","1413","1415","1419","2806","2807","2809","2811","0402","1411","2207","2802","2818","2819","2862","0412","0417","1420","1422","1437","1461","2812","2814","2815","2816","1402","0405","2801","2803","2804","2805","2817"]},{"id":"Wr20260810041839823","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-10 22:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h. Mo\u017cliiwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["3028","0401","0404","0403","0407","0409","2205","2206","2213","2214","3023","3025","3026","3030","3012","3020","3064","3004","0410","0411","0413","0414","2261","2262","2264","3006","3003","3010","3021","3031","3009","2203","3027","0213","0461","0463","0464","2204","3007","3011","3013","3016","3061","3062","3063","2202","3001","0415","0416","0418","0419","3017","3018","3019","3022"]},{"id":"Wr20260810041856167","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 18:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1003","1004","1011","1013","1014","1015","1418","1421","1424","1428","1465","1005","1061","1062","1063","1404","1432","1434","1435","1438","1001","1016","1019","1020","1021","1002","1006","1007","1008","1010","1405","1406","1408","1414"]},{"id":"Wr20260810041907317","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 03:00:00","obowiazuje_od":"2026-08-10 19:00:00","opublikowano":"2026-08-10 06:19:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1401","1407","2001","2005","1423","1426","1430","1436","0613","1433","1463","2014","2010","2013","2061","1412","1417","2009","2011","0661","1403","1410","1416","0601","0608","0611","0616","0614","0615","1425","1429","1464","2002","2003","2008"]},{"id":"Sk20260808093414110","nazwa_zdarzenia":"Upa\u0142","stopien":"2","prawdopodobienstwo":"80","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-09 13:00:00","opublikowano":"2026-08-08 11:34:00","tresc":"Prognozuje si\u0119 upa\u0142y. Temperatura maksymalna niedziel\u0119 09.08 od 29\u00b0C do 31\u00b0C, w poniedzia\u0142ek 10.08 od 30\u00b0C do 33\u00b0C. Temperatura minimalna w nocy od 17\u00b0C do 19\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0812","3210","0211","0201","0802","0803","0804","0805","0807","0808","0809","0810","0811","0862","3212","0204","0264","0203","0801","0209","0216","0218","0220","0861","3206","0222","0223","0225","0262"]},{"id":"Sk20260809095307175","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 20:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:53:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 33\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1808","1418","2607","2609","2610","2611","2612","0617","1002","1206","1409","1405","1410","1430","1438","1603","1818","2470","2608","2613","1412","2010","1602","1604","1605","1608","2468","2469","2471","2472","2473","2474","2475","0214","1425","1201","0609","1812","1864","2416","1062","0614","2406","1203","1204","1205","1207","1208","1209","1210","1212","1215","1216","1218","1219","1261","1262","1263","1601","1606","1607","1802","1803","1804","3020","3027","1005","1006","1007","0610","1805","1806","1807","1809","1810","1811","1813","1815","1816","1819","1820","1861","1862","1863","2401","2402","2403","2404","2405","2407","2408","2409","2410","2411","2412","2413","2414","2415","2417","2461","2462","1008","1009","1010","1011","1012","1013","1014","1015","3017","3018","0620","2478","2601","2606","1202","1814","1609","1610","1611","1661","2463","2464","2465","2466","2467","2661","1016","1017","1018","1019","1020","1021","1061","1063","1403","1406","2005","2013","3008","3009","0606","0607","1428","1429","1432","1433","1434","3007","1401","0616","0664","1213","1214","0202","0611","0618","0663","1001","1003","1004","2476","2477","2479","2602","2603","2604","2605","0224","0604","0605","0613","0615","0215","0612","3061","0208","0217","0602","0608","1407","1417","1421","1423","1426","1436","1463","1464","1465","2003"]},{"id":"Sk20260809095258478","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:52:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 32\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0207","0210","0261","0265","3011","3014","3015","3016","1411","1424","3012","3024","3026","3063","0221","0401","1462","0206","3003","0212","0415","3021","3022","3025","3030","0405","0408","0409","0410","0411","0412","1427","1435","3005","3006","3010","0205","0226","1419","3001","3013","3023","3004","3028","3029","0219","0419","1404","1408","1414","1416","1420","3062","3064","0418","0461","0463","0464","1402","0403","0407","0213"]}]
\ No newline at end of file diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go new file mode 100644 index 0000000..b5220f4 --- /dev/null +++ b/internal/openmeteo/openmeteo.go @@ -0,0 +1,385 @@ +// Package openmeteo talks to Open-Meteo's forecast, air-quality and geocoding +// APIs. None of them needs a key. +package openmeteo + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/cache" +) + +// Endpoints are variables, not constants, so tests can point them at a local +// server serving recorded fixtures instead of the live APIs. +var ( + forecastURL = "https://api.open-meteo.com/v1/forecast" + airURL = "https://air-quality-api.open-meteo.com/v1/air-quality" + geoURL = "https://geocoding-api.open-meteo.com/v1/search" +) + +const ( + // Both APIs reject anything larger; the air-quality one is the stricter. + MaxForecastDays = 16 + MaxAirDays = 7 +) + +// now is the clock, replaceable in tests: the window these functions return +// depends on the hour, so a real clock would make the tests pass or fail +// depending on when they ran. +var now = time.Now + +// Timeout bounds every request. +var Timeout = 15 * time.Second + +// Row is one hour of forecast. Vals is keyed by Open-Meteo field name, so a +// column added to the config needs no change here. +type Row struct { + When time.Time + Vals map[string]float64 + Code int +} + +// Val returns a field, and whether it was present. +func (r Row) Val(field string) (float64, bool) { + v, ok := r.Vals[field] + return v, ok +} + +// Data is a forecast reduced to the requested window. +type Data struct { + TZ string + Rows []Row + Sun map[string][2]string // date -> {sunrise, sunset} as HH:MM + Daily map[string]float64 +} + +// DailyFields are the once-a-day figures shown in the summary line. +var DailyFields = []string{ + "temperature_2m_max", "temperature_2m_min", "precipitation_sum", + "precipitation_hours", "daylight_duration", "sunshine_duration", +} + +func get(rawURL string, params url.Values, into any) error { + host := "" + if u, err := url.Parse(rawURL); err == nil { + host = u.Host + } + client := &http.Client{Timeout: Timeout} + req, err := http.NewRequest("GET", rawURL+"?"+params.Encode(), nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "prognosis/1.0") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot reach %s: %w", host, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading from %s: %w", host, err) + } + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("bad response from %s: %w", host, err) + } + return nil +} + +type geoResponse struct { + Results []struct { + Name string `json:"name"` + Country string `json:"country_code"` + Admin1 string `json:"admin1"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + } `json:"results"` +} + +// Candidate is one place the geocoder matched. Admin1 is the region, which is +// usually the only thing telling two places of the same name apart. +type Candidate struct { + Geo cache.Geo + Admin1 string +} + +// Geocode resolves a place name to every candidate the geocoder returned, best +// match first. Coordinates are kept for all of them: a caller that only knows +// the names of the alternatives cannot offer a choice between them, it can only +// guess. +func Geocode(place string) ([]Candidate, error) { + var r geoResponse + err := get(geoURL, url.Values{ + "name": {place}, + "count": {"5"}, + "format": {"json"}, + }, &r) + if err != nil { + return nil, err + } + if len(r.Results) == 0 { + return nil, fmt.Errorf("no place called %q found by Open-Meteo's geocoder", place) + } + out := make([]Candidate, 0, len(r.Results)) + for _, hit := range r.Results { + g := cache.Geo{Lat: hit.Latitude, Lon: hit.Longitude, Country: hit.Country} + g.Label = hit.Name + if hit.Country != "" { + g.Label += ", " + hit.Country + } + out = append(out, Candidate{Geo: g, Admin1: hit.Admin1}) + } + return out, nil +} + +type forecastResponse struct { + TZAbbrev string `json:"timezone_abbreviation"` + UTCOff int `json:"utc_offset_seconds"` + Hourly map[string]any `json:"hourly"` + Daily map[string]any `json:"daily"` + Error bool `json:"error"` + Reason string `json:"reason"` + _ map[string]float64 // keeps the shape obvious +} + +// unitParams maps our units setting onto Open-Meteo's, so rounding is done by +// the provider rather than by us. +func unitParams(units string) url.Values { + v := url.Values{} + switch units { + case "imperial": + v.Set("temperature_unit", "fahrenheit") + v.Set("wind_speed_unit", "mph") + v.Set("precipitation_unit", "inch") + case "si": + v.Set("wind_speed_unit", "ms") + } + return v +} + +// Forecast fetches `hours` hours from the current hour, in the location's own +// timezone, requesting only the fields asked for. +func Forecast(lat, lon float64, hours int, units string, fields []string) (*Data, error) { + need := append([]string{"weather_code"}, fields...) + sort.Strings(need) + need = dedupe(need) + + days := hours/24 + 2 // we start partway through today + if days > MaxForecastDays { + days = MaxForecastDays + } + params := url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)}, + "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)}, + "hourly": {strings.Join(need, ",")}, + "daily": {"sunrise,sunset," + strings.Join(DailyFields, ",")}, + "forecast_days": {strconv.Itoa(days)}, + "timezone": {"auto"}, + } + for k, vs := range unitParams(units) { + params[k] = vs + } + + var r forecastResponse + if err := get(forecastURL, params, &r); err != nil { + return nil, err + } + if r.Error { + return nil, fmt.Errorf("open-meteo: %s", r.Reason) + } + + times := stringSlice(r.Hourly["time"]) + if len(times) == 0 { + return nil, fmt.Errorf("Open-Meteo returned no hourly data") + } + start := WindowStart(times, r.UTCOff) + + d := &Data{TZ: r.TZAbbrev, Sun: map[string][2]string{}, Daily: map[string]float64{}} + end := start + hours + if end > len(times) { + end = len(times) + } + series := map[string][]float64{} + for _, f := range need { + series[f] = floatSlice(r.Hourly[f]) + } + for i := start; i < end; i++ { + when, err := time.Parse("2006-01-02T15:04", times[i]) + if err != nil { + continue + } + row := Row{When: when, Vals: map[string]float64{}} + for f, vals := range series { + if i < len(vals) { + row.Vals[f] = vals[i] + } + } + if v, ok := row.Vals["weather_code"]; ok { + row.Code = int(v) + } + d.Rows = append(d.Rows, row) + } + if len(d.Rows) == 0 { + return nil, fmt.Errorf("no forecast hours left in the returned window") + } + + dates := stringSlice(r.Daily["time"]) + rises, sets := stringSlice(r.Daily["sunrise"]), stringSlice(r.Daily["sunset"]) + for i, day := range dates { + if i < len(rises) && i < len(sets) && len(rises[i]) >= 16 && len(sets[i]) >= 16 { + d.Sun[day] = [2]string{rises[i][11:16], sets[i][11:16]} + } + } + for _, f := range DailyFields { + if vals := floatSlice(r.Daily[f]); len(vals) > 0 { + d.Daily[f] = vals[0] + } + } + return d, nil +} + +// Pollen returns the peak per species over the window ahead. +func Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error) { + if len(species) == 0 { + return map[string]float64{}, nil + } + fields := make([]string, 0, len(species)) + for _, s := range species { + fields = append(fields, s+"_pollen") + } + days := hours/24 + 2 + if days > MaxAirDays { + days = MaxAirDays + } + var r struct { + UTCOff int `json:"utc_offset_seconds"` + Hourly map[string]any `json:"hourly"` + } + err := get(airURL, url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)}, + "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)}, + "hourly": {strings.Join(fields, ",")}, + "forecast_days": {strconv.Itoa(days)}, + "timezone": {"auto"}, + }, &r) + if err != nil { + return nil, err + } + + times := stringSlice(r.Hourly["time"]) + start := WindowStart(times, r.UTCOff) + // Pollen peaks around midday, so a three-hour request would understate the + // day. Always look at least twelve hours ahead. + span := hours + if span < 12 { + span = 12 + } + end := start + span + if end > len(times) { + end = len(times) + } + + peaks := map[string]float64{} + for _, s := range species { + vals := floatSlice(r.Hourly[s+"_pollen"]) + have := presentSlice(r.Hourly[s+"_pollen"]) + found := false + best := 0.0 + for i := start; i < end && i < len(vals); i++ { + if i < len(have) && !have[i] { + continue // null: no reading here + } + if !found || vals[i] > best { + best, found = vals[i], true + } + } + if found { + peaks[s] = best + } + } + return peaks, nil +} + +// WindowStart is the index of the first timestamp at or after now, in the +// location's timezone. +// +// Open-Meteo's hourly arrays begin at 00:00 local, so anything that slices from +// the front reports the small hours of this morning rather than the hours +// ahead. The offset comes from the response so this stays correct for a place +// in another timezone. +func WindowStart(times []string, utcOffsetSeconds int) int { + cut := now().UTC().Add(time.Duration(utcOffsetSeconds) * time.Second).Truncate(time.Hour) + for i, t := range times { + when, err := time.Parse("2006-01-02T15:04", t) + if err != nil { + continue + } + if !when.Before(cut) { + return i + } + } + return 0 +} + +func dedupe(in []string) []string { + seen := map[string]bool{} + out := in[:0] + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +func stringSlice(v any) []string { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, x := range raw { + s, _ := x.(string) + out = append(out, s) + } + return out +} + +func floatSlice(v any) []float64 { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]float64, 0, len(raw)) + for _, x := range raw { + f, _ := x.(float64) + out = append(out, f) + } + return out +} + +// presentSlice reports, per index, whether the JSON value was non-null. Pollen +// is null outside Europe, and treating that as 0.0 would report a confident +// "grass 0.0 none" where the truth is "no data". +func presentSlice(v any) []bool { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]bool, 0, len(raw)) + for _, x := range raw { + _, isNum := x.(float64) + out = append(out, isNum) + } + return out +} diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go new file mode 100644 index 0000000..758920c --- /dev/null +++ b/internal/openmeteo/openmeteo_test.go @@ -0,0 +1,364 @@ +package openmeteo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// serve returns a server replying with a recorded fixture, and records the +// query it was asked for so tests can assert on the request as well as the +// parsing. +func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if gotQuery != nil { + *gotQuery = r.URL.RawQuery + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +// fixtureStart is the first hour in the recorded forecast, so tests can pin the +// clock relative to real recorded data. +func fixtureStart(t *testing.T) time.Time { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) + if err != nil { + t.Fatal(err) + } + var r struct { + Hourly struct { + Time []string `json:"time"` + } `json:"hourly"` + } + if err := json.Unmarshal(body, &r); err != nil { + t.Fatal(err) + } + when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0]) + if err != nil { + t.Fatal(err) + } + return when +} + +func pinClock(t *testing.T, at time.Time) { + t.Helper() + old := now + now = func() time.Time { return at.UTC() } + t.Cleanup(func() { now = old }) +} + +// The hourly array begins at 00:00 local, so slicing from the front reports the +// small hours of this morning rather than the hours ahead -- the bug that made +// the Python version report the wrong pollen peak. +// +// It also pins the timezone contract: the clock is real UTC, and the offset in +// the response converts it to the *location's* local time. That is what makes +// "prognosis -l krakow" start at the right hour when run from another zone. +func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) { + offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast + if offset == 0 { + t.Skip("fixture has no UTC offset; the conversion cannot be exercised") + } + // 11:00 UTC is 13:00 in a +02:00 location. + utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second) + pinClock(t, utcNoon) + + srv := serve(t, "forecast.json", nil) + forecastURL = srv.URL + d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"}) + if err != nil { + t.Fatal(err) + } + if got := d.Rows[0].When.Hour(); got != 13 { + t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)", + got, utcNoon.Format("15:04"), offset) + } + if len(d.Rows) != 6 { + t.Fatalf("got %d rows, want 6", len(d.Rows)) + } + // Rows must be consecutive hours from there. + for i, r := range d.Rows { + if want := 13 + i; r.When.Hour() != want { + t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want) + } + } +} + +// fixtureOffset is the recorded response's utc_offset_seconds. +func fixtureOffset(t *testing.T) int { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) + if err != nil { + t.Fatal(err) + } + var r struct { + Offset int `json:"utc_offset_seconds"` + } + if err := json.Unmarshal(body, &r); err != nil { + t.Fatal(err) + } + return r.Offset +} + +func TestForecastRequestsOnlySelectedFields(t *testing.T) { + pinClock(t, fixtureStart(t)) + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + + if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil { + t.Fatal(err) + } + hourly := queryValue(t, query, "hourly") + for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} { + if !strings.Contains(hourly, want) { + t.Errorf("hourly=%q is missing %q", hourly, want) + } + } + // A field nobody asked for costs response size for nothing. + for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} { + if strings.Contains(hourly, unwanted) { + t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted) + } + } +} + +func TestForecastUnitsArePassedToTheProvider(t *testing.T) { + pinClock(t, fixtureStart(t)) + cases := map[string]map[string]string{ + "metric": {"temperature_unit": "", "wind_speed_unit": ""}, + "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"}, + "si": {"wind_speed_unit": "ms"}, + } + for units, want := range cases { + t.Run(units, func(t *testing.T) { + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil { + t.Fatal(err) + } + for k, v := range want { + if got := queryValue(t, query, k); got != v { + t.Errorf("%s=%q, want %q", k, got, v) + } + } + }) + } +} + +func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) { + pinClock(t, fixtureStart(t)) + for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} { + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil { + t.Fatal(err) + } + if got := queryValue(t, query, "forecast_days"); got != wantDays { + t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays) + } + } +} + +func TestForecastParsesDailyAndSun(t *testing.T) { + pinClock(t, fixtureStart(t)) + srv := serve(t, "forecast.json", nil) + forecastURL = srv.URL + d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"}) + if err != nil { + t.Fatal(err) + } + if len(d.Sun) == 0 { + t.Error("no sunrise/sunset parsed") + } + for _, day := range d.Sun { + if len(day[0]) != 5 || len(day[1]) != 5 { + t.Errorf("sun times must be HH:MM, got %v", day) + } + } + for _, f := range DailyFields { + if _, ok := d.Daily[f]; !ok { + t.Errorf("daily field %q missing", f) + } + } +} + +func TestForecastErrors(t *testing.T) { + pinClock(t, fixtureStart(t)) + t.Run("http error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { + t.Fatal("expected an error on HTTP 500") + } + }) + t.Run("bad json", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("{not json")) + })) + defer srv.Close() + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { + t.Fatal("expected an error on malformed JSON") + } + }) +} + +// Pollen peaks around midday, so a 3-hour request must still look 12 hours +// ahead or it understates the day for someone with an allergy. +func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) { + start := fixtureStart(t) + pinClock(t, start.Add(6*time.Hour)) + srv := serve(t, "pollen.json", nil) + airURL = srv.URL + + short, err := Pollen(50, 21, 3, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + long, err := Pollen(50, 21, 12, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + if short["grass"] != long["grass"] { + t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+ + "still cover 12 hours", short["grass"], long["grass"]) + } +} + +// Outside Europe the API returns nulls. Treating those as 0.0 reports a +// confident "grass 0.0 none" where the truth is "no data". +func TestPollenNullsAreAbsentNotZero(t *testing.T) { + pinClock(t, fixtureStart(t)) + srv := serve(t, "pollen_nulls.json", nil) + airURL = srv.URL + peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + if v, ok := peaks["grass"]; ok { + t.Fatalf("grass reported as %v, but the fixture has no readings there", v) + } +} + +func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) { + pinClock(t, fixtureStart(t)) + var query string + srv := serve(t, "pollen.json", &query) + airURL = srv.URL + if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil { + t.Fatal(err) + } + if got := queryValue(t, query, "forecast_days"); got != "7" { + t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got) + } +} + +func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + airURL = srv.URL + peaks, err := Pollen(50, 21, 12, nil) + if err != nil || len(peaks) != 0 { + t.Fatalf("got %v, %v", peaks, err) + } + if called { + t.Error("requested pollen despite no species being selected") + } +} + +func TestGeocodeReportsAlternatives(t *testing.T) { + srv := serve(t, "geocode_ambiguous.json", nil) + geoURL = srv.URL + cands, err := Geocode("krakow") + if err != nil { + t.Fatal(err) + } + if len(cands) < 2 { + t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands)) + } + g := cands[0].Geo + if g.Country == "" || g.Label == "" { + t.Errorf("incomplete geo: %+v", g) + } + if !strings.Contains(g.Label, g.Country) { + t.Errorf("label %q should carry the country %q", g.Label, g.Country) + } +} + +// Selecting a place other than the first one is impossible unless its +// coordinates survive the call, which is exactly what the old API discarded. +func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) { + srv := serve(t, "geocode_ambiguous.json", nil) + geoURL = srv.URL + cands, err := Geocode("krakow") + if err != nil { + t.Fatal(err) + } + for i, c := range cands { + if c.Geo.Lat == 0 || c.Geo.Lon == 0 { + t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label) + } + if c.Admin1 == "" { + t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label) + } + } +} + +func TestGeocodeNoResultsIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"generationtime_ms":0.1}`)) + })) + defer srv.Close() + geoURL = srv.URL + if _, err := Geocode("zzzznowhere"); err == nil { + t.Fatal("expected an error when nothing matched") + } +} + +func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) { + pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 { + t.Fatalf("got %d, want 0", got) + } +} + +func queryValue(t *testing.T, rawQuery, key string) string { + t.Helper() + for _, pair := range strings.Split(rawQuery, "&") { + k, v, _ := strings.Cut(pair, "=") + if k == key { + unescaped, err := urlUnescape(v) + if err != nil { + t.Fatal(err) + } + return unescaped + } + } + return "" +} + +func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) } diff --git a/internal/openmeteo/testdata/forecast.json b/internal/openmeteo/testdata/forecast.json new file mode 100644 index 0000000..b8948f7 --- /dev/null +++ b/internal/openmeteo/testdata/forecast.json @@ -0,0 +1 @@ +{"latitude":50.061700,"longitude":19.937300,"generationtime_ms":1.4580488204956055,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","precipitation_probability":"%","weather_code":"wmo code"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"temperature_2m":[15.0,14.4,13.4,13.0,12.9,12.1,11.9,14.4,19.2,22.4,24.6,26.2,27.5,28.7,29.3,29.4,29.3,28.9,28.4,27.0,24.8,23.6,22.9,22.6,22.3,22.5,22.4,21.9,21.6,20.3,18.9,19.8,20.9,22.4,22.0,22.1,23.1,22.7,21.5,22.2,21.9,21.8,21.7,21.3,19.5,18.3,16.8,15.6],"apparent_temperature":[13.8,13.3,12.5,11.8,11.5,11.2,11.0,14.0,18.3,21.6,24.1,26.2,27.6,29.1,29.5,28.2,28.3,28.7,28.9,28.2,25.5,24.1,22.2,21.8,21.3,20.4,20.0,19.5,19.7,18.7,18.3,19.4,21.3,23.1,21.6,22.0,23.3,21.8,20.4,20.8,19.8,19.7,20.2,19.6,18.3,16.3,15.1,13.7],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"precipitation_probability":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,3,4,4,3,2,1,0,0,2,6,8,8,6,4,3,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"weather_code":[1,3,2,0,0,0,0,1,0,2,2,3,3,0,3,3,3,3,3,0,0,3,1,0,0,0,0,0,0,0,0,0,3,3,3,3,2,3,3,3,3,3,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","precipitation_hours":"h","daylight_duration":"s","sunshine_duration":"s"},"daily":{"time":["2026-08-10","2026-08-11"],"sunrise":["2026-08-10T05:15","2026-08-11T05:16"],"sunset":["2026-08-10T20:01","2026-08-11T19:59"],"temperature_2m_max":[29.4,23.1],"temperature_2m_min":[11.9,15.6],"precipitation_sum":[0.00,0.00],"precipitation_hours":[0.0,0.0],"daylight_duration":[53154.79,52962.40],"sunshine_duration":[43387.38,856.60]}}
\ No newline at end of file diff --git a/internal/openmeteo/testdata/geocode_ambiguous.json b/internal/openmeteo/testdata/geocode_ambiguous.json new file mode 100644 index 0000000..62f9cda --- /dev/null +++ b/internal/openmeteo/testdata/geocode_ambiguous.json @@ -0,0 +1 @@ +{"results":[{"id":3094802,"name":"Krakow","latitude":50.06143,"longitude":19.93658,"elevation":219.0,"feature_code":"PPLA","country_code":"PL","admin1_id":858786,"admin2_id":6690154,"admin3_id":7531791,"timezone":"Europe/Warsaw","population":804237,"country_id":798544,"country":"Poland","admin1":"Lesser Poland","admin2":"Kraków","admin3":"Kraków"},{"id":5258888,"name":"Krakow","latitude":44.76166,"longitude":-88.25149,"elevation":237.0,"feature_code":"PPL","country_code":"US","admin1_id":5279468,"admin2_id":5265518,"admin3_id":5248361,"timezone":"America/Chicago","population":354,"postcodes":["54137"],"country_id":6252001,"country":"United States","admin1":"Wisconsin","admin2":"Oconto","admin3":"Town of Chase"},{"id":2884850,"name":"Krakow am See","latitude":53.65142,"longitude":12.26748,"elevation":48.0,"feature_code":"PPLA4","country_code":"DE","admin1_id":2872567,"admin3_id":8648342,"admin4_id":6550687,"timezone":"Europe/Berlin","population":3450,"country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Rostock","admin4":"Krakow am See"},{"id":2884852,"name":"Krakow","latitude":54.12372,"longitude":12.78276,"elevation":18.0,"feature_code":"PPL","country_code":"DE","admin1_id":2872567,"admin3_id":2843324,"admin4_id":6548111,"timezone":"Europe/Berlin","country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Vorpommern-Rügen","admin4":"Drechow"},{"id":3094801,"name":"Krąków","latitude":51.72921,"longitude":18.51595,"elevation":136.0,"feature_code":"PPL","country_code":"PL","admin1_id":3337493,"admin2_id":7531009,"admin3_id":7533290,"timezone":"Europe/Warsaw","country_id":798544,"country":"Poland","admin1":"Łódź Voivodeship","admin2":"Sieradz County","admin3":"Warta"}],"generationtime_ms":0.44548512}
\ No newline at end of file diff --git a/internal/openmeteo/testdata/pollen.json b/internal/openmeteo/testdata/pollen.json new file mode 100644 index 0000000..eba1a73 --- /dev/null +++ b/internal/openmeteo/testdata/pollen.json @@ -0,0 +1 @@ +{"latitude":50.0,"longitude":21.8,"generationtime_ms":0.19991397857666016,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³","birch_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"grass_pollen":[7.2,6.7,5.4,2.6,1.7,2.6,2.6,6.2,6.3,6.5,6.9,6.6,5.5,4.7,4.5,4.7,5.0,4.9,5.5,6.0,10.6,6.4,6.3,6.5,6.4,5.4,5.4,6.0,5.4,4.9,4.8,4.6,4.6,3.8,3.4,3.5,3.5,3.8,3.9,3.8,3.7,4.4,4.2,4.9,5.8,5.8,6.6,8.0],"birch_pollen":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}}
\ No newline at end of file diff --git a/internal/openmeteo/testdata/pollen_nulls.json b/internal/openmeteo/testdata/pollen_nulls.json new file mode 100644 index 0000000..46effa0 --- /dev/null +++ b/internal/openmeteo/testdata/pollen_nulls.json @@ -0,0 +1 @@ +{"latitude":-54.8,"longitude":-68.299995,"generationtime_ms":0.09846687316894531,"utc_offset_seconds":-10800,"timezone":"America/Argentina/Ushuaia","timezone_abbreviation":"GMT-3","elevation":52.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00"],"grass_pollen":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}}
\ No newline at end of file diff --git a/internal/render/ascii.go b/internal/render/ascii.go new file mode 100644 index 0000000..4203bc9 --- /dev/null +++ b/internal/render/ascii.go @@ -0,0 +1,67 @@ +package render + +import "strings" + +// asciiMap is deliberately one rune to one ASCII character. +// +// Substitution happens after the table has been laid out, so anything that +// changed the number of characters would shear every column to its right. That +// constraint is why the wind arrows become single letters rather than the +// two-letter compass points that would read better. +var asciiMap = map[rune]string{ + // Polish diacritics, so a Polish forecast survives GSM-7. + 'ą': "a", 'ć': "c", 'ę': "e", 'ł': "l", 'ń': "n", + 'ó': "o", 'ś': "s", 'ź': "z", 'ż': "z", + 'Ą': "A", 'Ć': "C", 'Ę': "E", 'Ł': "L", 'Ń': "N", + 'Ó': "O", 'Ś': "S", 'Ź': "Z", 'Ż': "Z", + + // Wind arrows. The arrow points the way the wind blows, so v is a northerly. + '↑': "^", '↓': "v", '←': "<", '→': ">", + '↖': "\\", '↗': "/", '↙': "/", '↘': "\\", + + // Chart: blocks and the axis rule. + '█': "#", '▇': "#", '▆': "=", '▅': "=", + '▄': "_", '▃': ":", '▂': ".", '▁': ".", + '│': "|", +} + +// ASCII rewrites output to pure ASCII, one character for one character. +// +// The point is SMS: a single non-ASCII character forces the whole message from +// GSM-7 into UCS-2, which cuts a segment from 160 characters to 70. A degree +// sign alone therefore more than doubles the cost of sending a forecast. +// +// The degree sign becomes the unit letter, which is both ASCII and clearer to +// someone reading it cold: "29C" rather than "29". +func ASCII(s, units string) string { + degree := "C" + if units == "imperial" { + degree = "F" + } + runes := []rune(s) + var b strings.Builder + b.Grow(len(s)) + for i, r := range runes { + switch { + case r == '°': + // Prose from IMGW already writes "30°C"; appending the unit again + // would give "30CC". Only a bare degree sign gains the letter. + if i+1 < len(runes) && (runes[i+1] == 'C' || runes[i+1] == 'F') { + continue + } + b.WriteString(degree) + case r < 128: + b.WriteRune(r) + default: + if sub, ok := asciiMap[r]; ok { + b.WriteString(sub) + } else { + // Anything unmapped -- a place name in another script, a weather + // code description we have not transliterated -- becomes '?' + // rather than silently vanishing and misaligning the row. + b.WriteString("?") + } + } + } + return b.String() +} diff --git a/internal/render/ascii_test.go b/internal/render/ascii_test.go new file mode 100644 index 0000000..7e6a805 --- /dev/null +++ b/internal/render/ascii_test.go @@ -0,0 +1,55 @@ +package render + +import ( + "strings" + "testing" +) + +func TestASCIIIsPureASCII(t *testing.T) { + in := " ! Upał stopień 1\n 14 29° (28) zachmurzenie ↗\n 30°│███▄▄▁" + got := ASCII(in, "metric") + for _, r := range got { + if r > 127 { + t.Fatalf("non-ASCII %q survived in %q", r, got) + } + } +} + +// Substitution runs after layout, so it must not change the character count -- +// otherwise every column to the right of a degree sign shears. +func TestASCIIPreservesLength(t *testing.T) { + for _, in := range []string{ + " 14 29° (28) zachmurzenie", + " 30°│███▄▄▁▂", + " godz temp odczuw warunki", + " słońce 12h03m z 14h45m dnia", + } { + if got := ASCII(in, "metric"); len([]rune(got)) != len([]rune(in)) { + t.Errorf("length changed: %q (%d) -> %q (%d)", + in, len([]rune(in)), got, len([]rune(got))) + } + } +} + +// IMGW prose already writes "30°C"; the unit letter must not be doubled. +func TestASCIIDoesNotDoubleTheUnitInProse(t *testing.T) { + got := ASCII("temperatura od 30°C do 33°C", "metric") + if strings.Contains(got, "CC") { + t.Fatalf("doubled unit: %q", got) + } + if got != "temperatura od 30C do 33C" { + t.Fatalf("got %q", got) + } +} + +func TestASCIIUsesFahrenheitLetterInImperial(t *testing.T) { + if got := ASCII("85°", "imperial"); got != "85F" { + t.Fatalf("got %q, want 85F", got) + } +} + +func TestASCIIMarksUnmappedRunesRatherThanDroppingThem(t *testing.T) { + if got := ASCII("東京", "metric"); got != "??" { + t.Fatalf("got %q, want ??", got) + } +} diff --git a/internal/render/chart.go b/internal/render/chart.go new file mode 100644 index 0000000..cc20ca9 --- /dev/null +++ b/internal/render/chart.go @@ -0,0 +1,205 @@ +package render + +import ( + "fmt" + "math" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +const blocks = "▁▂▃▄▅▆▇█" + +// column is one drawn chart column. Keeping level, colour, rain and hour in one +// struct means they cannot drift apart, which four parallel slices would allow. +type column struct { + level int + style string + rain float64 + hour string +} + +// chart draws the temperature over several rows with a labelled axis. +// +// A one-row sparkline gives only 8 levels, so a single cold hour flattens the +// rest of the week into the top two blocks. Drawing over height rows with +// half-block cells gives height*2 levels, enough to see the daily rise and fall. +// Long spans are downsampled so the chart fits the terminal: a week is 168 +// hourly points and would otherwise wrap into mush. +func (x ctx) chart(v View) []string { + height := x.cfg.GraphHeight + const gutter = 5 // "NN°" label plus the axis rule + cols := x.width - gutter - 1 + if cols < 8 { + cols = 8 + } + + groups := buckets(min(len(v.Rows), cols), len(v.Rows)) + temps := make([]float64, len(groups)) + rains := make([]float64, len(groups)) + for i, g := range groups { + sum, maxRain := 0.0, 0.0 + for _, r := range v.Rows[g[0]:g[1]] { + t, _ := r.Val("temperature_2m") + sum += t + if mm, ok := r.Val("precipitation"); ok && mm > maxRain { + maxRain = mm + } + } + temps[i] = sum / float64(g[1]-g[0]) + rains[i] = maxRain + } + + lo, hi := temps[0], temps[0] + for _, t := range temps { + lo, hi = math.Min(lo, t), math.Max(hi, t) + } + span := hi - lo + if span == 0 { + span = 1 + } + steps := height * 2 + + // A 12-hour chart would occupy 12 of 80 columns; widen each point to use the + // terminal rather than leaving the curve cramped in the corner. + scale := cols / len(groups) + if scale < 1 { + scale = 1 + } + var drawn []column + for i, g := range groups { + lvl := int(math.Round((temps[i] - lo) / span * float64(steps))) + if lvl < 1 { + lvl = 1 // always one half-cell, so the coldest column still shows + } + for k := 0; k < scale; k++ { + drawn = append(drawn, column{ + level: lvl, + style: TempStyle(x.celsius(temps[i])), + rain: rains[i], + hour: v.Rows[g[0]].When.Format("15"), + }) + } + } + + out := []string{""} + for r := 0; r < height; r++ { + full := (height - r) * 2 + value := lo + (hi-lo)*float64(height-1-r)/float64(height-1) + label := " " + switch { + case r == 0: + label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4)) + case r == height-1: + label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4)) + case height >= 5 && r == height/2: + label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4)) + } + cells := make([]Cell, 0, len(drawn)) + for _, d := range drawn { + switch { + case d.level >= full: + cells = append(cells, Cell{Style: d.style, Text: "█"}) + case d.level == full-1: + cells = append(cells, Cell{Style: d.style, Text: "▄"}) + default: + cells = append(cells, Cell{Text: " "}) + } + } + out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c)) + } + + note := fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi)) + if len(groups) < len(v.Rows) { + note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col")) + } + + // A flat row of empty blocks says nothing; only draw rain if there is any. + maxRain := 0.0 + for _, d := range drawn { + maxRain = math.Max(maxRain, d.rain) + } + if maxRain > 0 { + series := make([]float64, len(drawn)) + for i, d := range drawn { + series[i] = d.rain + } + out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), 4)+"│")+ + x.c(Cyan, spark(series))+ + x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain))) + } + + every := scale * maxInt(1, ceilDiv(len(groups), 8)) // at most 8 labels + hours := make([]string, len(drawn)) + for i, d := range drawn { + hours[i] = d.hour + } + out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+axis(hours, every))) + out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+note)) + return out +} + +// axis places hour labels under the chart, one character per column so they +// line up. A label that would run off the end is skipped rather than printed as +// a half label. +func axis(hours []string, every int) string { + line := []rune(strings.Repeat(" ", len(hours))) + for i := 0; i < len(hours); i += every { + label := []rune(hours[i]) + if i+len(label) > len(line) { + continue + } + copy(line[i:], label) + } + return string(line) +} + +// buckets splits total rows into count contiguous groups. +func buckets(count, total int) [][2]int { + out := make([][2]int, count) + for i := range out { + lo := i * total / count + hi := (i + 1) * total / count + if hi <= lo { + hi = lo + 1 + } + out[i] = [2]int{lo, hi} + } + return out +} + +// spark renders one block character per value, scaled to the series' own range. +func spark(values []float64) string { + lo, hi := values[0], values[0] + for _, v := range values { + lo, hi = math.Min(lo, v), math.Max(hi, v) + } + if hi-lo < 1e-9 { // flat: sit on the baseline rather than divide by zero + return strings.Repeat(string([]rune(blocks)[0]), len(values)) + } + runes := []rune(blocks) + step := (hi - lo) / float64(len(runes)-1) + var b strings.Builder + for _, v := range values { + b.WriteRune(runes[int(math.Round((v-lo)/step))]) + } + return b.String() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func ceilDiv(a, b int) int { return (a + b - 1) / b } + +var _ = openmeteo.Row{} diff --git a/internal/render/color.go b/internal/render/color.go new file mode 100644 index 0000000..cf2af89 --- /dev/null +++ b/internal/render/color.go @@ -0,0 +1,62 @@ +package render + +import "strings" + +// Colour uses ANSI slots 0-15 only -- codes 30-37 and 90-97, plus the +// attributes 1 (bold), 2 (dim) and 4 (underline). Never 256-colour indices: +// this runs on terminals whose palette remaps the low slots (the phone's is +// entirely green), where a hardcoded 38;5;196 would be the one off-palette +// thing on screen. +const ( + Reset = "0" + Bold = "1" + Dim = "2" + Underline = "4" + + Blue = "34" + Cyan = "36" + Green = "32" + Yellow = "33" + Red = "31" + BrightBlue = "94" + BrightRed = "91" + BrightWhite = "97" +) + +// Styler returns a function that wraps text in an ANSI code, or returns it +// unchanged when colour is off. +func Styler(colour bool) func(code, text string) string { + if !colour { + return func(_, text string) string { return text } + } + return func(code, text string) string { + if code == "" { + return text + } + return "\033[" + code + "m" + text + "\033[0m" + } +} + +// Cell is one character of chart output with the style it should carry. +type Cell struct { + Style string + Text string +} + +// Paint joins cells, emitting one escape sequence per run of identical style +// rather than one per character. A per-character version makes the chart around +// ten times larger for output that looks exactly the same. +func Paint(cells []Cell, c func(string, string) string) string { + var b strings.Builder + for i := 0; i < len(cells); { + j := i + var run strings.Builder + for j < len(cells) && cells[j].Style == cells[i].Style { + run.WriteString(cells[j].Text) + j++ + } + b.WriteString(c(cells[i].Style, run.String())) + i = j + } + return b.String() +} diff --git a/internal/render/icons.go b/internal/render/icons.go new file mode 100644 index 0000000..59ae3d3 --- /dev/null +++ b/internal/render/icons.go @@ -0,0 +1,84 @@ +package render + +// Weather glyphs per icon set, keyed by WMO code group. +// +// The "nerd" codepoints are from the Nerd Fonts Weather range (U+E300-U+E3E3), +// which JetBrains Mono Nerd Font and MesloLGS NF both carry. They are single +// cell and monochrome, so they take the terminal's foreground colour and do not +// break a remapped palette. +// +// The "emoji" set is colour and comes from a fallback font. Its glyphs are not +// all one cell wide -- see width.go -- which is why every pad goes through +// DisplayWidth. +var iconSets = map[string]map[string]string{ + "nerd": { + "clear": "", + "partly": "", + "cloudy": "", + "fog": "", + "drizzle": "", + "rain": "", + "snow": "", + "storm": "", + }, + "emoji": { + "clear": "☀", + "partly": "⛅", + "cloudy": "☁", + "fog": "\U0001F32B", + "drizzle": "\U0001F326", + "rain": "\U0001F327", + "snow": "\U0001F328", + "storm": "⛈", + }, +} + +// iconGroup maps a WMO weather code onto a glyph group. +func iconGroup(code int) string { + switch { + case code == 0 || code == 1: + return "clear" + case code == 2: + return "partly" + case code == 3: + return "cloudy" + case code == 45 || code == 48: + return "fog" + case code >= 51 && code <= 57: + return "drizzle" + case code >= 61 && code <= 67, code >= 80 && code <= 82: + return "rain" + case code >= 71 && code <= 77, code == 85 || code == 86: + return "snow" + case code >= 95: + return "storm" + } + return "cloudy" +} + +// Icon returns the glyph for a weather code in the named set. An unknown set, +// or "none", yields an empty string so the column simply renders blank. +func Icon(set string, code int) string { + glyphs, ok := iconSets[set] + if !ok { + return "" + } + return glyphs[iconGroup(code)] +} + +// IconWidth is the display width the icon column should reserve for a set. +// The emoji set contains wide glyphs, so its column is two cells even for the +// entries that happen to be one. +func IconWidth(set string) int { + glyphs, ok := iconSets[set] + if !ok { + return 0 + } + w := 1 + for _, g := range glyphs { + if gw := DisplayWidth(g); gw > w { + w = gw + } + } + return w +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..4417763 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,283 @@ +// Package render turns fetched weather into terminal output. It is pure: no +// network, no clock beyond what it is given, so every rule below is testable. +package render + +import ( + "fmt" + "math" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/i18n" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// RainInterestPct is the chance of rain below which the mm and rain columns are +// hidden: on a dry day they are a block of zeroes that pushes real content +// sideways. +const RainInterestPct = 20 + +// View is everything one run needs to render. +type View struct { + Label string + TZ string + Rows []openmeteo.Row + Sun map[string][2]string + Daily map[string]float64 + Pollen map[string]float64 + Warnings []imgw.Warning + WarnNote string + WarnFailed bool +} + +type ctx struct { + cfg config.Config + cat *i18n.Catalog + c func(string, string) string + width int + wet bool +} + +// celsius converts a displayed temperature back to Celsius. +// +// IMGW's thresholds are defined in Celsius, so they must be compared in +// Celsius: a warning threshold does not move because the display was switched +// to Fahrenheit. Without this, 85F (a mild 29C) renders in the "IMGW would warn +// about this" red. +func (x ctx) celsius(v float64) float64 { + if x.cfg.Units == "imperial" { + return (v - 32) * 5 / 9 + } + return v +} + +// Render produces the whole output. +func Render(v View, cfg config.Config, width int, colour bool) string { + cx := ctx{ + cfg: cfg, + cat: i18n.For(cfg.DisplayLang), + c: Styler(colour), + width: width, + wet: isWet(v.Rows), + } + var out []string + out = append(out, cx.warnings(v)...) + out = append(out, cx.header(v)...) + out = append(out, cx.table(v)...) + if cfg.Graph { + out = append(out, cx.chart(v)...) + } + text := strings.Join(out, "\n") + // Applied last, one character for one, so the layout above is unaffected. + if cfg.ASCII { + text = ASCII(text, cfg.Units) + } + return text +} + +func isWet(rows []openmeteo.Row) bool { + for _, r := range rows { + if mm, ok := r.Val("precipitation"); ok && mm > 0 { + return true + } + if p, ok := r.Val("precipitation_probability"); ok && p >= RainInterestPct { + return true + } + } + return false +} + +// warnings renders IMGW warnings, or an explicit line saying why none are shown. +// +// WarnFailed must never look the same as "none in force": silence would be read +// as all-clear. +func (x ctx) warnings(v View) []string { + var out []string + switch { + case x.WarnDisabled(): + return nil + case v.WarnFailed: + out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+x.cat.Word("could_not_check"))) + case v.WarnNote != "": + out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+v.WarnNote)) + } + for _, w := range v.Warnings { + style := Yellow + switch w.Level { + case "2": + style = Red + case "3": + style = Bold + ";" + Red + } + head := fmt.Sprintf(" ! %s %s %s %s -> %s (%s%%)", + w.Event, x.cat.Word("level"), w.Level, + clip(w.From), clip(w.To), w.Probability) + out = append(out, x.c(style, head)) + for _, line := range wrap(w.Text, x.width) { + out = append(out, x.c(Dim, " "+line)) + } + } + if len(out) > 0 { + out = append(out, "") + } + return out +} + +// WarnDisabled reports whether warnings were switched off in config. +func (x ctx) WarnDisabled() bool { return !x.cfg.Warnings } + +// clip shortens "2026-08-10 11:00:00" to "08-10 11:00". +func clip(s string) string { + if len(s) >= 16 { + return s[5:16] + } + return s +} + +func (x ctx) header(v View) []string { + var out []string + first := v.Rows[0].When + title := v.Label + " " + x.cat.Date(first) + if x.cfg.Minimal { + // Just the place and the date: no sun times, no summary, no pollen. + return append(out, x.c(Bold, title)) + } + sun, hasSun := v.Sun[first.Format("2006-01-02")] + suffix := "" + if hasSun { + suffix = fmt.Sprintf(" %s %s %s %s", + sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")) + } + tz := "" + if v.TZ != "" { + tz = " " + v.TZ + } + // Keep it to one line where it fits; a phone is narrow enough that it often + // does not, and a wrapped title reads worse than two deliberate lines. + if hasSun && DisplayWidth(title)+DisplayWidth(suffix)+DisplayWidth(tz) <= x.width { + out = append(out, x.c(Bold, title)+x.c(Dim, suffix+tz)) + } else { + out = append(out, x.c(Bold, title)+x.c(Dim, tz)) + if hasSun { + out = append(out, x.c(Dim, fmt.Sprintf(" %s %s %s %s %s", + x.cat.Word("sun"), sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")))) + } + } + + if len(v.Daily) > 0 { + var bits []string + lo, okLo := v.Daily["temperature_2m_min"] + hi, okHi := v.Daily["temperature_2m_max"] + if okLo && okHi { + bits = append(bits, fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi))) + } + if mm, ok := v.Daily["precipitation_sum"]; ok { + if mm == 0 { + bits = append(bits, x.cat.Word("dry")) + } else { + bits = append(bits, fmt.Sprintf("%s %.1fmm %s %.0fh", + x.cat.Word("rainfall"), mm, x.cat.Word("over"), + v.Daily["precipitation_hours"])) + } + } + if sun, ok := v.Daily["sunshine_duration"]; ok { + if day, ok2 := v.Daily["daylight_duration"]; ok2 { + bits = append(bits, fmt.Sprintf("%s %s %s %s %s", + x.cat.Word("sun"), hm(sun), x.cat.Word("of"), hm(day), + x.cat.Word("daylight"))) + } + } + label := " " + Pad(x.cat.Word("day"), 6) + " " + // Joined with wide separators when it fits; only a line too long for the + // terminal is re-wrapped, and then on single spaces. + joined := strings.Join(bits, " ") + lines := []string{joined} + if DisplayWidth(label)+DisplayWidth(joined) > x.width { + lines = wrap(joined, x.width-DisplayWidth(label)) + } + for i, line := range lines { + prefix := label + if i > 0 { + prefix = strings.Repeat(" ", DisplayWidth(label)) + } + out = append(out, x.c(Dim, prefix+line)) + } + } + + if len(v.Pollen) > 0 { + var bits []string + for _, s := range sortedByValue(v.Pollen) { + band := PollenBand(s, v.Pollen[s]) + // Skip taxa that are simply absent, but never hide grass: it is the + // one someone may be allergic to and its absence is information. + if band == "none" && s != "grass" { + continue + } + text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) + if band != "" { + text += " " + x.cat.Band(band) + } + bits = append(bits, text) + } + if len(bits) > 0 { + if len(bits) > 4 { + bits = bits[:4] + } + out = append(out, x.c(Dim, " "+Pad(x.cat.Word("pollen"), 6)+" ")+strings.Join(bits, " ")) + } + } + return out +} + +func hm(seconds float64) string { + s := int(seconds) + return fmt.Sprintf("%dh%02dm", s/3600, (s%3600)/60) +} + +func sortedByValue(m map[string]float64) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && m[keys[j]] > m[keys[j-1]]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + return keys +} + +func wrap(text string, width int) []string { + if width < 8 { + width = 8 + } + var lines []string + var line string + for _, word := range strings.Fields(text) { + switch { + case line == "": + line = word + case DisplayWidth(line)+1+DisplayWidth(word) <= width: + line += " " + word + default: + lines = append(lines, line) + line = word + } + } + if line != "" { + lines = append(lines, line) + } + return lines +} + +func round1(v float64) string { return fmt.Sprintf("%.1f", v) } + +func compass(deg float64) string { + dirs := []string{"↓", "↙", "←", "↖", "↑", "↗", "→", "↘"} + i := int(math.Mod(math.Round(deg/45), 8)) + if i < 0 { + i += 8 + } + return dirs[i] +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..484a45b --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,287 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// row builds one hour. mm and pop default to dry. +func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row { + return openmeteo.Row{ + When: time.Date(2026, 8, 10, hour, 0, 0, 0, time.UTC), + Code: code, + Vals: map[string]float64{ + "temperature_2m": temp, + "apparent_temperature": temp, + "precipitation": mm, + "precipitation_probability": pop, + "weather_code": float64(code), + }, + } +} + +func testConfig() config.Config { + c := config.Default() + c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"} + c.Graph = false + c.Icons = "none" + c.DisplayLang = "en" + return c +} + +func view(rows ...openmeteo.Row) View { + return View{Label: "Test, PL", Rows: rows, Sun: map[string][2]string{}} +} + +// On a dry day the mm and rain columns are a block of zeroes pushing the real +// content sideways. +func TestDryWindowHidesTheRainColumns(t *testing.T) { + dry := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 3, 0, 5)), testConfig(), 80, false) + if strings.Contains(dry, "mm") || strings.Contains(dry, "rain") { + t.Errorf("dry window still shows rain columns:\n%s", dry) + } + + wet := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 61, 0.4, 80)), testConfig(), 80, false) + if !strings.Contains(wet, "mm") || !strings.Contains(wet, "rain") { + t.Errorf("wet window must show rain columns:\n%s", wet) + } +} + +// Probability alone is enough: rain that has not started yet still matters. +func TestProbabilityAloneBringsBackTheRainColumns(t *testing.T) { + v := view(row(12, 25, 3, 0, RainInterestPct), row(13, 26, 3, 0, RainInterestPct)) + if out := Render(v, testConfig(), 80, false); !strings.Contains(out, "rain") { + t.Errorf("%d%% chance must show the columns:\n%s", RainInterestPct, out) + } + below := view(row(12, 25, 3, 0, RainInterestPct-1), row(13, 26, 3, 0, 0)) + if out := Render(below, testConfig(), 80, false); strings.Contains(out, "rain") { + t.Errorf("below the threshold the columns must stay hidden:\n%s", out) + } +} + +// An unbroken column of "overcast" hides the hour it stops being overcast, +// which is the only interesting part. +func TestConditionsPrintOnlyWhenTheyChange(t *testing.T) { + v := view( + row(12, 25, 3, 0, 0), // overcast + row(13, 25, 3, 0, 0), // still overcast: blank + row(14, 25, 0, 0, 0), // clear: printed + row(15, 25, 0, 0, 0), // still clear: blank + ) + out := Render(v, testConfig(), 80, false) + if n := strings.Count(out, "overcast"); n != 1 { + t.Errorf("overcast appears %d times, want 1:\n%s", n, out) + } + if n := strings.Count(out, "clear"); n != 1 { + t.Errorf("clear appears %d times, want 1:\n%s", n, out) + } +} + +// Across a day boundary the conditions are repeated once, so a reader starting +// at the new day is not looking at a blank column. +func TestConditionsRepeatAfterADaySeparator(t *testing.T) { + next := row(0, 20, 3, 0, 0) + next.When = time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + v := view(row(23, 22, 3, 0, 0), next) + v.Sun["2026-08-11"] = [2]string{"05:16", "19:59"} + + out := Render(v, testConfig(), 80, false) + if n := strings.Count(out, "overcast"); n != 2 { + t.Errorf("conditions must repeat once per day, appeared %d times:\n%s", n, out) + } + if !strings.Contains(out, "Tue 11 Aug") { + t.Errorf("missing the day separator:\n%s", out) + } +} + +func TestFeelsLikeOnlyWhenItDiffers(t *testing.T) { + same := row(12, 25, 3, 0, 0) + diff := row(13, 25, 3, 0, 0) + diff.Vals["apparent_temperature"] = 28 + + out := Render(view(same, diff), testConfig(), 80, false) + if strings.Count(out, "(") != 1 { + t.Errorf("feels-like must appear once, only where it differs:\n%s", out) + } + if !strings.Contains(out, "(28)") { + t.Errorf("expected (28):\n%s", out) + } +} + +// The four warning states must stay distinguishable: silence read as all-clear +// is the failure that matters. +func TestWarningStatesAreDistinct(t *testing.T) { + cfg := testConfig() + base := view(row(12, 25, 3, 0, 0)) + + inForce := base + inForce.Warnings = []imgw.Warning{{ + Event: "Upal", Level: "1", Probability: "85", + From: "2026-08-10 11:00:00", To: "2026-08-10 20:00:00", + Text: "Prognozuje sie upal.", + }} + out := Render(inForce, cfg, 80, false) + if !strings.Contains(out, "Upal") || !strings.Contains(out, "level 1") { + t.Errorf("a live warning must be shown:\n%s", out) + } + + if out := Render(base, cfg, 80, false); strings.Contains(out, "warnings:") { + t.Errorf("checked-and-none must print nothing about warnings:\n%s", out) + } + + failed := base + failed.WarnFailed = true + if out := Render(failed, cfg, 80, false); !strings.Contains(out, "could not check") { + t.Errorf("a failed check must say so, not stay silent:\n%s", out) + } + + abroad := base + abroad.WarnNote = "IMGW covers Poland only" + if out := Render(abroad, cfg, 80, false); !strings.Contains(out, "Poland only") { + t.Errorf("an abroad location must explain itself:\n%s", out) + } +} + +func TestWarningsDisabledSuppressesEvenAFailure(t *testing.T) { + cfg := testConfig() + cfg.Warnings = false + v := view(row(12, 25, 3, 0, 0)) + v.WarnFailed = true + if out := Render(v, cfg, 80, false); strings.Contains(out, "could not check") { + t.Errorf("warnings=false must suppress the notice too:\n%s", out) + } +} + +// -weather strips everything that is not the forecast. +func TestMinimalStripsTheExtras(t *testing.T) { + cfg := testConfig() + cfg.Minimal = true + v := view(row(12, 25, 3, 0, 0)) + v.Sun["2026-08-10"] = [2]string{"05:15", "20:01"} + v.Daily = map[string]float64{"temperature_2m_min": 12, "temperature_2m_max": 30} + v.Pollen = map[string]float64{"grass": 10} + + out := Render(v, cfg, 80, false) + for _, unwanted := range []string{"sun", "up", "day", "pollen", "grass"} { + if strings.Contains(out, unwanted) { + t.Errorf("minimal output still contains %q:\n%s", unwanted, out) + } + } + if !strings.Contains(out, "Test, PL") || !strings.Contains(out, "25°") { + t.Errorf("minimal output must still carry place and forecast:\n%s", out) + } +} + +func chartOf(t *testing.T, hours int, width int) []string { + t.Helper() + cfg := testConfig() + cfg.Graph = true + var rows []openmeteo.Row + for i := 0; i < hours; i++ { + when := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour) + r := row(0, 20+float64(i%10), 3, 0, 0) + r.When = when + rows = append(rows, r) + } + out := Render(view(rows...), cfg, width, false) + return strings.Split(out, "\n") +} + +// A 12-hour chart drawn one column per hour would occupy 12 of 80 columns. +func TestChartWidensShortSpansToFillTheTerminal(t *testing.T) { + lines := chartOf(t, 12, 80) + var widest int + for _, l := range lines { + if strings.Contains(l, "│") { + if w := DisplayWidth(l); w > widest { + widest = w + } + } + } + if widest < 40 { + t.Fatalf("chart is only %d columns wide for a 12-hour span; it should widen", widest) + } +} + +// A week is 168 points and would wrap into mush; columns must cover several +// hours and the label must say so. +func TestChartDownsamplesLongSpansAndSaysSo(t *testing.T) { + out := strings.Join(chartOf(t, 168, 80), "\n") + if !strings.Contains(out, "h/col") { + t.Fatalf("a downsampled chart must disclose the ratio:\n%s", out) + } + for _, l := range strings.Split(out, "\n") { + if w := DisplayWidth(l); w > 80 { + t.Fatalf("chart line is %d columns wide, wider than the terminal:\n%s", w, l) + } + } +} + +func TestChartNeverExceedsTheTerminalWidth(t *testing.T) { + for _, width := range []int{32, 53, 80, 96} { + for _, hours := range []int{1, 6, 24, 72} { + lines := chartOf(t, hours, width) + // Only the chart: the table has fixed column widths and is measured + // separately, below. + for i, l := range lines { + isChart := strings.Contains(l, "│") || + (i >= len(lines)-2 && strings.TrimSpace(l) != "") + if !isChart { + continue + } + if w := DisplayWidth(l); w > width { + t.Errorf("width=%d hours=%d: chart line is %d columns:\n%s", width, hours, w, l) + } + } + } + } +} + +// The table has fixed column widths, so unlike the chart it does not shrink to +// fit. This pins the width the default column set needs: the phone is 53 +// columns, so there is headroom, but a narrower terminal will wrap and there is +// no code preventing it. Narrow the columns instead -- see -columns. +func TestTableMinimumWidthIsKnown(t *testing.T) { + cfg := testConfig() + out := Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false) + widest := 0 + for _, l := range strings.Split(out, "\n") { + if w := DisplayWidth(l); w > widest { + widest = w + } + } + const documented = 34 + if widest != documented { + t.Fatalf("the default table now needs %d columns, not the documented %d; "+ + "update the README if this is intended", widest, documented) + } + // A narrower column set must actually be narrower, or -columns is no remedy. + cfg.Columns = []string{"hour", "temp", "conditions"} + narrow := 0 + for _, l := range strings.Split(Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false), "\n") { + if w := DisplayWidth(l); w > narrow { + narrow = w + } + } + if narrow >= documented { + t.Errorf("narrow column set is %d columns, no better than %d", narrow, documented) + } +} + +// A label that would run off the end is skipped, never printed as half a label. +func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) { + for _, hours := range []int{1, 2, 3, 5, 12, 24} { + lines := chartOf(t, hours, 40) + axis := lines[len(lines)-2] // axis sits above the range note + for _, field := range strings.Fields(axis) { + if len(field) != 2 { + t.Errorf("hours=%d: axis has a partial label %q in %q", hours, field, axis) + } + } + } +} diff --git a/internal/render/scale.go b/internal/render/scale.go new file mode 100644 index 0000000..a2eb2dc --- /dev/null +++ b/internal/render/scale.go @@ -0,0 +1,92 @@ +package render + +import "math" + +// Temperature bands. The two ends are IMGW's own warning criteria, so a red +// temperature means the met office would issue a warning about it rather than +// that it looked hot to whoever wrote this: +// +// Tmin <= -15 silny mroz, stopien 1 -> bright blue +// Tmax >= 30 upal, stopien 1 -> red +// Tmax > 35 the higher heat level -> bright red +// +// The splits between (0, 10, 20) are round numbers, not thresholds from any +// source; they only subdivide the range nobody warns about. +var tempBands = []struct { + below float64 + code string +}{ + {0, Blue}, {10, Cyan}, {20, Green}, {30, Yellow}, +} + +// Deg rounds to whole degrees. Rounding through int conversion avoids "-0", +// which is arithmetically fine and visually wrong. +func Deg(t float64) int { + return int(math.Round(t)) +} + +// TempStyle is the ANSI code for a temperature in Celsius. +// +// The warning edges are written out rather than folded into tempBands so they +// match IMGW's criteria exactly, inclusive and exclusive included. +// +// The value is rounded first, so the colour always matches the number printed +// beside it: otherwise -14.6 prints as "-15" in a different colour than a true +// -15 and looks like a rendering bug. +func TempStyle(celsius float64) string { + t := float64(Deg(celsius)) + switch { + case t <= -15: + return BrightBlue + case t > 35: + return BrightRed + case t >= 30: + return Red + } + for _, b := range tempBands { + if t < b.below { + return b.code + } + } + return Yellow +} + +// Pollen bands in grains/m3, from Polish clinical sources. Each entry is an +// upper bound (exclusive) and a band key; a nil bound means "everything above". +// +// grass -- alergen.info.pl symptom table: 20 = first nasal symptoms in 25% +// of sufferers, 50 = symptoms in all tested, 65 = intensified in +// over 75%, 120 = dyspnoea after 30 minutes of exposure. +// birch -- mp.pl: 80 provokes symptoms in over 95% of allergics. +// mugwort -- mp.pl: over 70 counts as high, intensified symptoms. +// +// Birch and mugwort have a single published anchor each, so they get a two-way +// split rather than four bands: their "low" is weaker evidence than grass's. +// Alder, olive and ragweed have no Polish threshold that could be sourced and +// are deliberately left unbanded rather than banded on a guess. +var pollenBands = map[string][]struct { + below float64 + band string +}{ + "grass": {{20, "low"}, {50, "medium"}, {65, "high"}, {math.Inf(1), "very high"}}, + "birch": {{80, "low"}, {math.Inf(1), "high"}}, + "mugwort": {{70, "low"}, {math.Inf(1), "high"}}, +} + +// PollenBand is the qualitative level for a count, or "" where no threshold +// exists for that species. +func PollenBand(species string, value float64) string { + if value < 1 { + return "none" + } + bands, ok := pollenBands[species] + if !ok { + return "" + } + for _, b := range bands { + if value < b.below { + return b.band + } + } + return "" +} diff --git a/internal/render/scale_test.go b/internal/render/scale_test.go new file mode 100644 index 0000000..9754bdc --- /dev/null +++ b/internal/render/scale_test.go @@ -0,0 +1,57 @@ +package render + +import ( + "testing" + + "github.com/lukaszkasprzak/prognosis/internal/config" +) + +// IMGW's criteria are in Celsius. Switching the display to Fahrenheit must not +// move the temperature at which the met office is said to warn. +func TestThresholdsAreComparedInCelsiusWhateverTheDisplayUnits(t *testing.T) { + metric := ctx{cfg: config.Config{Units: "metric"}} + imperial := ctx{cfg: config.Config{Units: "imperial"}} + + cases := []struct { + celsius float64 + fahrenheit float64 + want string + why string + }{ + {29, 84.2, Yellow, "below the upal threshold"}, + {30, 86, Red, "upal stopien 1 is Tmax >= 30C"}, + {36, 96.8, BrightRed, "the higher heat level is Tmax > 35C"}, + {-15, 5, BrightBlue, "silny mroz stopien 1 is Tmin <= -15C"}, + {-14, 6.8, Blue, "just above the frost threshold"}, + } + for _, c := range cases { + if got := TempStyle(metric.celsius(c.celsius)); got != c.want { + t.Errorf("%.0fC -> %s, want %s (%s)", c.celsius, got, c.want, c.why) + } + if got := TempStyle(imperial.celsius(c.fahrenheit)); got != c.want { + t.Errorf("%.1fF (=%.0fC) -> %s, want %s (%s)", + c.fahrenheit, c.celsius, got, c.want, c.why) + } + } +} + +func TestPollenBandEdges(t *testing.T) { + cases := []struct { + species string + value float64 + want string + }{ + {"grass", 0.5, "none"}, {"grass", 19, "low"}, {"grass", 20, "medium"}, + {"grass", 49, "medium"}, {"grass", 50, "high"}, {"grass", 64, "high"}, + {"grass", 65, "very high"}, {"grass", 200, "very high"}, + {"birch", 79, "low"}, {"birch", 80, "high"}, + {"mugwort", 69, "low"}, {"mugwort", 70, "high"}, + {"ragweed", 50, ""}, // no Polish threshold sourced: deliberately unbanded + {"alder", 500, ""}, + } + for _, c := range cases { + if got := PollenBand(c.species, c.value); got != c.want { + t.Errorf("PollenBand(%q, %v) = %q, want %q", c.species, c.value, got, c.want) + } + } +} diff --git a/internal/render/table.go b/internal/render/table.go new file mode 100644 index 0000000..3ade296 --- /dev/null +++ b/internal/render/table.go @@ -0,0 +1,240 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// cell is one rendered table cell: text, its colour, and how it is aligned. +type cell struct { + text string + style string + left bool +} + +// colWidth is the reserved display width per column. +func (x ctx) colWidth(name string) int { + switch name { + case "hour": + // Three, not two: the Python leaves a double space after the hour. + return 3 + case "icon": + return IconWidth(x.cfg.Icons) + case "temp": + return 5 + case "feels": + return 6 + case "conditions": + return 16 + case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility": + return 5 + case "dir": + return 3 + case "pressure": + return 6 + } + return 6 +} + +func (x ctx) leftAligned(name string) bool { + switch name { + case "hour", "icon", "temp", "feels", "conditions": + return true + } + return false +} + +// visible drops columns that have nothing to say: the icon column when icons +// are off, and the rain pair on a dry window. +func (x ctx) visible() []string { + var out []string + for _, name := range x.cfg.Columns { + if name == "icon" && x.cfg.Icons == "none" { + continue + } + if (name == "mm" || name == "rain") && !x.wet { + continue + } + out = append(out, name) + } + return out +} + +// row assembles one line from cells, padding each to its column width BEFORE +// colouring it. Escape sequences carry no display width, so padding a coloured +// string misaligns every column to its right -- invisible when piped, obvious +// in a terminal. +func (x ctx) row(cells map[string]cell) string { + var b strings.Builder + for _, name := range x.visible() { + c := cells[name] + w := x.colWidth(name) + padded := PadLeft(c.text, w) + if x.leftAligned(name) { + padded = Pad(c.text, w) + } + b.WriteString(" ") + b.WriteString(x.c(c.style, padded)) + } + return strings.TrimRight(b.String(), " ") +} + +func (x ctx) table(v View) []string { + out := []string{""} + + headers := map[string]cell{} + for _, name := range x.visible() { + headers[name] = cell{text: x.cat.Header(name), style: ""} + } + out = append(out, x.c(Underline, x.rowPlain(headers))) + + day := v.Rows[0].When.Format("2006-01-02") + prevCode := -1 + for i, r := range v.Rows { + if d := r.When.Format("2006-01-02"); d != day { + day = d + sep := " -- " + x.cat.Date(r.When) + " --" + if sun, ok := v.Sun[d]; ok { + sep += fmt.Sprintf(" %s %s / %s", x.cat.Word("sun"), sun[0], sun[1]) + } + out = append(out, x.c(Dim, sep)) + prevCode = -1 // repeat the conditions once per day for context + } + out = append(out, x.row(x.cells(r, i == 0, &prevCode))) + } + return out +} + +// rowPlain is the header row: padded like the data but never coloured per cell, +// so the underline runs unbroken across it. +func (x ctx) rowPlain(cells map[string]cell) string { + var b strings.Builder + for _, name := range x.visible() { + w := x.colWidth(name) + text := cells[name].text + padded := PadLeft(text, w) + if x.leftAligned(name) { + padded = Pad(text, w) + } + b.WriteString(" ") + b.WriteString(padded) + } + return b.String() +} + +func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell { + out := map[string]cell{} + temp, hasTemp := r.Val("temperature_2m") + + for _, name := range x.visible() { + switch name { + case "hour": + style := Reset + if isNow { + style = Bold + } + out[name] = cell{text: r.When.Format("15"), style: style} + + case "icon": + out[name] = cell{text: Icon(x.cfg.Icons, r.Code)} + + case "temp": + style := TempStyle(x.celsius(temp)) + // The current hour keeps its emphasis on top of the heat colour. + if isNow { + style = Bold + ";" + style + } + out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style} + + case "feels": + text := "" + if feels, ok := r.Val("apparent_temperature"); ok && hasTemp { + // Only shown when it differs; otherwise it is a column of noise. + if abs(feels-temp) >= 1 { + text = fmt.Sprintf("(%d)", Deg(feels)) + } + } + out[name] = cell{text: text, style: Dim} + + case "conditions": + text := "" + // Only when they change: an unbroken column of "overcast" hides the + // hour it stops being overcast, which is the only interesting part. + if r.Code != *prevCode { + text = Truncate(x.cat.Condition(r.Code), x.colWidth(name)) + } + out[name] = cell{text: text} + + case "mm": + mm, _ := r.Val("precipitation") + style := Dim + if mm > 0 { + style = Cyan + } + out[name] = cell{text: round1(mm), style: style} + + case "rain": + p, _ := r.Val("precipitation_probability") + style := Dim + if p >= 50 { + style = Yellow + } + out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style} + + case "wind": + v, _ := r.Val("wind_speed_10m") + out[name] = cell{text: fmt.Sprintf("%d", int(v))} + + case "gusts": + v, _ := r.Val("wind_gusts_10m") + style := "" + if v >= 60 { + style = Yellow + } + out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style} + + case "dir": + v, _ := r.Val("wind_direction_10m") + out[name] = cell{text: compass(v)} + + case "humidity": + v, _ := r.Val("relative_humidity_2m") + out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim} + + case "dew": + v, _ := r.Val("dew_point_2m") + out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim} + + case "uv": + v, _ := r.Val("uv_index") + style := Dim + if v >= 6 { + style = Yellow + } + out[name] = cell{text: round1(v), style: style} + + case "cloud": + v, _ := r.Val("cloud_cover") + out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim} + + case "pressure": + v, _ := r.Val("pressure_msl") + out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim} + + case "visibility": + v, _ := r.Val("visibility") + out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim} + } + } + *prevCode = r.Code + return out +} + +func abs(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/internal/render/width.go b/internal/render/width.go new file mode 100644 index 0000000..c5676d5 --- /dev/null +++ b/internal/render/width.go @@ -0,0 +1,110 @@ +package render + +import ( + "strings" + "unicode" +) + +// wideRanges are the code point ranges this program can emit that occupy two +// terminal cells. Only the sets we actually produce are covered -- our own icon +// glyphs, plus the CJK blocks a place name could contain -- rather than the +// whole Unicode width table, which would be a dependency or a large generated +// file for no gain. +var wideRanges = [][2]rune{ + {0x1100, 0x115F}, // Hangul Jamo + {0x2329, 0x232A}, + {0x2E80, 0x303E}, // CJK radicals, Kangxi + {0x3041, 0x33FF}, // kana, CJK compatibility + {0x3400, 0x4DBF}, // CJK extension A + {0x4E00, 0x9FFF}, // CJK unified + {0xA000, 0xA4CF}, // Yi + {0xAC00, 0xD7A3}, // Hangul syllables + {0xF900, 0xFAFF}, // CJK compatibility ideographs + {0xFE30, 0xFE6F}, // CJK compatibility forms + {0xFF00, 0xFF60}, // fullwidth forms + {0xFFE0, 0xFFE6}, + {0x1F300, 0x1F64F}, // emoji: weather, faces + {0x1F680, 0x1F6FF}, // emoji: transport and symbols + {0x1F900, 0x1F9FF}, // supplemental symbols + {0x26C4, 0x26C8}, // snowman, thundercloud + {0x2614, 0x2615}, // umbrella with rain, hot beverage +} + +// ambiguousWide lists the individual code points we emit whose East-Asian width +// is Ambiguous but which terminals in this estate render as two cells. +var ambiguousWide = map[rune]bool{ + 0x26C5: true, // sun behind cloud + 0x26C8: true, // thunder cloud and rain +} + +func runeWidth(r rune) int { + switch { + case r == 0xFE0F: + // Variation Selector-16 requests emoji presentation. It has no width of + // its own; its effect is already counted on the base rune. + return 0 + case r == 0xFE0E: + return 0 + case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r): + return 0 // combining and formatting marks occupy no cell + case r == '': + return 0 // zero-width joiner + case r < 0x20: + return 0 + case ambiguousWide[r]: + return 2 + } + for _, rng := range wideRanges { + if r >= rng[0] && r <= rng[1] { + return 2 + } + } + return 1 +} + +// DisplayWidth is the number of terminal cells a string occupies. +// +// Neither len() nor a rune count will do: an emoji may be two cells, a +// variation selector is zero, and combining marks are zero. Padding with the +// wrong number shears every column to the right of it -- and only in a real +// terminal, never when the output is piped, which is what makes it easy to miss. +func DisplayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// Pad returns s padded with spaces to at least w display cells (left aligned). +func Pad(s string, w int) string { + if n := w - DisplayWidth(s); n > 0 { + return s + strings.Repeat(" ", n) + } + return s +} + +// PadLeft returns s padded with spaces to at least w display cells (right aligned). +func PadLeft(s string, w int) string { + if n := w - DisplayWidth(s); n > 0 { + return strings.Repeat(" ", n) + s + } + return s +} + +// Truncate cuts s to at most w display cells, never splitting a rune. +func Truncate(s string, w int) string { + if DisplayWidth(s) <= w { + return s + } + out, used := make([]rune, 0, len(s)), 0 + for _, r := range s { + rw := runeWidth(r) + if used+rw > w { + break + } + out = append(out, r) + used += rw + } + return string(out) +} diff --git a/internal/render/width_test.go b/internal/render/width_test.go new file mode 100644 index 0000000..f20038e --- /dev/null +++ b/internal/render/width_test.go @@ -0,0 +1,112 @@ +package render + +import ( + "strings" + "testing" +) + +func TestDisplayWidth(t *testing.T) { + cases := []struct { + name string + in string + want int + }{ + {"ascii", "temp", 4}, + {"empty", "", 0}, + {"degree sign is one cell", "28°", 3}, + {"polish diacritics are one cell each", "słońce", 6}, + {"emoji with variation selector counts once", "☀️", 1}, + {"bare BMP symbol", "☀", 1}, + {"sun behind cloud is wide", "⛅", 2}, + {"rain cloud is wide", "\U0001F327", 2}, + {"nerd font glyph is one cell", "", 1}, + {"block drawing is one cell", "█", 1}, + {"combining acute adds nothing", "é", 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := DisplayWidth(c.in); got != c.want { + t.Errorf("DisplayWidth(%q) = %d, want %d", c.in, got, c.want) + } + }) + } +} + +// The invariant that actually matters: whatever goes in a column, the padded +// result occupies exactly the requested number of cells. If this fails, every +// column to the right shears -- but only in a real terminal. +func TestPadReachesExactWidthForEveryIconSet(t *testing.T) { + samples := []string{ + "clear", "", "28°", "słońce", + "☀️", "⛅", "\U0001F327", "⛈", // emoji set + "", "", "", // nerd set + } + for _, s := range samples { + for _, w := range []int{1, 4, 8, 16} { + got := Pad(s, w) + if DisplayWidth(s) <= w && DisplayWidth(got) != w { + t.Errorf("Pad(%q, %d) has width %d, want %d", s, w, DisplayWidth(got), w) + } + if !strings.HasPrefix(got, s) { + t.Errorf("Pad(%q, %d) = %q, must not alter the content", s, w, got) + } + } + } +} + +func TestPadLeft(t *testing.T) { + if got := PadLeft("5", 3); got != " 5" { + t.Fatalf("PadLeft = %q, want %q", got, " 5") + } + if got := PadLeft("⛅", 4); DisplayWidth(got) != 4 { + t.Fatalf("PadLeft of a wide glyph has width %d, want 4", DisplayWidth(got)) + } +} + +func TestPadDoesNotShrink(t *testing.T) { + if got := Pad("conditions", 4); got != "conditions" { + t.Fatalf("Pad must never truncate: got %q", got) + } +} + +func TestTruncateNeverSplitsARune(t *testing.T) { + if got := Truncate("słońce", 3); got != "sło" { + t.Errorf("Truncate = %q, want %q", got, "sło") + } + // A wide glyph that does not fit is dropped whole, not halved. + if got := Truncate("a⛅", 2); got != "a" { + t.Errorf("Truncate = %q, want %q", got, "a") + } +} + +func TestPaintGroupsRuns(t *testing.T) { + c := Styler(true) + cells := []Cell{ + {Style: "31", Text: "a"}, {Style: "31", Text: "b"}, {Style: "31", Text: "c"}, + {Style: "33", Text: "d"}, + } + got := Paint(cells, c) + if n := strings.Count(got, "\033["); n != 4 { // 2 opens + 2 resets + t.Fatalf("expected one escape pair per run, got %d escapes in %q", n, got) + } + if strings.Count(got, "\033[31m") != 1 { + t.Errorf("the three red cells must share one escape: %q", got) + } +} + +func TestPaintWithoutColourIsPlain(t *testing.T) { + c := Styler(false) + got := Paint([]Cell{{Style: "31", Text: "a"}, {Style: "33", Text: "b"}}, c) + if got != "ab" { + t.Fatalf("colour off must yield plain text, got %q", got) + } +} + +// Colour must never reach for a 256-colour index. +func TestNoExtendedColourCodes(t *testing.T) { + for _, code := range []string{Blue, Cyan, Green, Yellow, Red, BrightBlue, BrightRed, BrightWhite} { + if strings.Contains(code, "38;5;") || strings.Contains(code, "48;5;") { + t.Errorf("%q is a 256-colour index; only slots 0-15 are allowed", code) + } + } +} |
