From e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 13:04:10 +0200 Subject: Initial commit: prognosis, the Go implementation An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests. --- internal/config/config_test.go | 158 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 internal/config/config_test.go (limited to 'internal/config/config_test.go') 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) + } +} -- cgit v1.3