diff options
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) + } + } +} |
