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) } } func TestCustomColumnDeclaration(t *testing.T) { cfg, err := Load(write(t, `columns=hour,temp,birch column.birch = air:birch_pollen label.birch = brzoza width.birch = 7 decimals.birch = 2 suffix.birch = g `)) if err != nil { t.Fatal(err) } cc, ok := cfg.Custom["birch"] if !ok { t.Fatal("birch was not declared") } if cc.Source != "air" || cc.Field != "birch_pollen" { t.Errorf("source/field = %q/%q", cc.Source, cc.Field) } if cc.Label != "brzoza" || cc.Width != 7 || cc.Decimals != 2 || cc.Suffix != "g" { t.Errorf("attributes not parsed: %+v", cc) } if err := cfg.Validate(); err != nil { t.Fatalf("a complete declaration must validate: %v", err) } } // Only the fields a selected column needs, split by which API serves them. func TestCustomColumnsSplitFieldsByApi(t *testing.T) { cfg, err := Load(write(t, `columns=hour,temp,birch,soil column.birch = air:birch_pollen column.soil = forecast:soil_temperature_0cm `)) if err != nil { t.Fatal(err) } fc := strings.Join(cfg.Fields(), ",") if !strings.Contains(fc, "soil_temperature_0cm") || !strings.Contains(fc, "temperature_2m") { t.Errorf("forecast fields = %q", fc) } if strings.Contains(fc, "birch_pollen") { t.Errorf("an air field must not be asked of the forecast API: %q", fc) } if air := strings.Join(cfg.AirFields(), ","); air != "birch_pollen" { t.Errorf("air fields = %q, want birch_pollen", air) } } // No custom air column means no second request at all. func TestNoAirFieldsWhenNoneDeclared(t *testing.T) { if got := Default().AirFields(); len(got) != 0 { t.Fatalf("AirFields() = %v, want empty", got) } } func TestCustomColumnErrors(t *testing.T) { for name, body := range map[string]string{ "no source": "column.x = birch_pollen\ncolumns=hour,x\n", "unknown source": "column.x = weather:birch_pollen\ncolumns=hour,x\n", "empty field": "column.x = air:\ncolumns=hour,x\n", "bad width": "column.x = air:f\nwidth.x = wide\n", "bad decimals": "column.x = air:f\ndecimals.x = 9\n", } { t.Run(name, func(t *testing.T) { if _, err := Load(write(t, body)); err == nil { t.Fatalf("expected an error for %q", body) } }) } } // Attributes without a declaration are a typo, not a silent no-op. func TestAttributesWithoutDeclarationAreRejected(t *testing.T) { cfg, err := Load(write(t, "label.birch = brzoza\n")) if err != nil { t.Fatal(err) } if err := cfg.Validate(); err == nil { t.Fatal("label.birch without column.birch must be an error") } } // Shadowing a built-in would make which column you get depend on lookup order. func TestCustomColumnCannotShadowABuiltIn(t *testing.T) { cfg, err := Load(write(t, "column.temp = air:birch_pollen\n")) if err != nil { t.Fatal(err) } err = cfg.Validate() if err == nil || !strings.Contains(err.Error(), "built-in") { t.Fatalf("expected a built-in clash error, got %v", err) } } // An undeclared column name should say how to declare it. func TestUnknownColumnSuggestsDeclaringIt(t *testing.T) { cfg := Default() cfg.Columns = []string{"hour", "birch"} err := cfg.Validate() if err == nil || !strings.Contains(err.Error(), "column.birch") { t.Fatalf("error should show how to declare it, got %v", err) } } func TestPollenExplicitTracksWhoChose(t *testing.T) { cases := map[string]bool{ "pollen=grass,birch\n": true, "pollen=all\n": false, "pollen=none\n": false, } for body, want := range cases { cfg, err := Load(write(t, body)) if err != nil { t.Fatal(err) } if cfg.PollenExplicit != want { t.Errorf("%q gave PollenExplicit=%v, want %v", body, cfg.PollenExplicit, want) } } if Default().PollenExplicit { t.Error("the default is not an explicit choice") } }