diff options
Diffstat (limited to 'internal/render')
| -rw-r--r-- | internal/render/render.go | 8 | ||||
| -rw-r--r-- | internal/render/render_test.go | 122 | ||||
| -rw-r--r-- | internal/render/table.go | 66 |
3 files changed, 190 insertions, 6 deletions
diff --git a/internal/render/render.go b/internal/render/render.go index 4417763..7df9b66 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -209,9 +209,11 @@ func (x ctx) header(v View) []string { 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" { + // A species the user named is always shown, even at zero: they named + // it because they react to it, and "none today" is what they wanted + // to know. With pollen=all nobody chose, so absent taxa are dropped + // rather than printing a line of zeroes. + if band == "none" && !x.cfg.PollenExplicit { continue } text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 484a45b..d06fc7b 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -27,7 +27,7 @@ func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row { func testConfig() config.Config { c := config.Default() - c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"} + c.Columns = []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"} c.Graph = false c.Icons = "none" c.DisplayLang = "en" @@ -255,7 +255,7 @@ func TestTableMinimumWidthIsKnown(t *testing.T) { widest = w } } - const documented = 34 + const documented = 40 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) @@ -285,3 +285,121 @@ func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) { } } } + +func customCfg() config.Config { + c := testConfig() + c.Columns = []string{"hour", "temp", "birch", "soil"} + c.Custom = map[string]config.CustomColumn{ + "birch": {Source: "air", Field: "birch_pollen", Label: "brzoza", Decimals: 1}, + "soil": {Source: "forecast", Field: "soil_temperature_0cm", Suffix: "°", Decimals: 0}, + } + return c +} + +func TestCustomColumnsRender(t *testing.T) { + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("birch")] = 12.34 + r.Vals["soil_temperature_0cm"] = 21.6 + + out := Render(view(r), customCfg(), 80, false) + if !strings.Contains(out, "brzoza") { + t.Errorf("the declared label must be the header:\n%s", out) + } + if !strings.Contains(out, "12.3") { + t.Errorf("decimals=1 should give 12.3:\n%s", out) + } + if !strings.Contains(out, "22°") { + t.Errorf("decimals=0 with a suffix should give 22°:\n%s", out) + } +} + +// "No data" and "zero" are different claims, and for an allergen the difference +// matters. +func TestCustomColumnBlankWhenTheApiGaveNothing(t *testing.T) { + r := row(12, 25, 3, 0, 0) // no custom values set at all + out := Render(view(r), customCfg(), 80, false) + if strings.Contains(out, "0.0") { + t.Errorf("a missing value must render blank, not as zero:\n%s", out) + } + if !strings.Contains(out, "brzoza") { + t.Errorf("the column should still be present:\n%s", out) + } +} + +// A custom column named after a built-in API field must not read that field's +// value; the prefix is what keeps them apart. +func TestCustomKeyDoesNotCollideWithApiFields(t *testing.T) { + if CustomKey("temperature_2m") == "temperature_2m" { + t.Fatal("custom values must be stored under a distinct key") + } + r := row(12, 25, 3, 0, 0) // temperature_2m = 25 + cfg := testConfig() + cfg.Columns = []string{"hour", "mine"} + cfg.Custom = map[string]config.CustomColumn{ + "mine": {Source: "air", Field: "temperature_2m", Decimals: 0}, + } + if out := Render(view(r), cfg, 80, false); strings.Contains(out, "25") { + t.Errorf("the custom column picked up the built-in field's value:\n%s", out) + } +} + +func TestCustomColumnWidthFromLabelWhenUnset(t *testing.T) { + cfg := testConfig() + cfg.Columns = []string{"hour", "verylongname"} + cfg.Custom = map[string]config.CustomColumn{ + "verylongname": {Source: "air", Field: "f", Label: "verylongname"}, + } + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("verylongname")] = 1 + out := Render(view(r), cfg, 80, false) + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "verylongname") && DisplayWidth(l) < 12 { + t.Errorf("header was truncated: %q", l) + } + } +} + +// A species the user named is shown even at zero: they named it because they +// react to it, and "none today" is the answer they wanted. +func TestNamedPollenSpeciesShownEvenAtZero(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"birch"} + cfg.PollenExplicit = true + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "birch") { + t.Errorf("a named species must appear even at zero:\n%s", out) + } +} + +// With pollen=all nobody chose, so a line of six zeroes is noise. +func TestPollenAllHidesAbsentSpecies(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"grass", "birch"} + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 12, "birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "grass") { + t.Errorf("a present species must be shown:\n%s", out) + } + if strings.Contains(out, "birch") { + t.Errorf("an absent species must be dropped when nobody named it:\n%s", out) + } +} + +// No species is privileged. Grass used to be special-cased, which forced it on +// someone allergic to birch while hiding theirs. +func TestNoSpeciesIsPrivileged(t *testing.T) { + cfg := testConfig() + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 0, "birch": 0} + + if out := Render(v, cfg, 80, false); strings.Contains(out, "grass") { + t.Errorf("grass at zero must be dropped like any other species:\n%s", out) + } +} diff --git a/internal/render/table.go b/internal/render/table.go index 3ade296..3219e9d 100644 --- a/internal/render/table.go +++ b/internal/render/table.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/lukaszkasprzak/prognosis/internal/config" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) @@ -14,8 +15,34 @@ type cell struct { left bool } +// custom returns the config's definition of a column, if it has one. +func (x ctx) custom(name string) (config.CustomColumn, bool) { + cc, ok := x.cfg.Custom[name] + return cc, ok +} + +// customLabel is the header for a custom column: what the user asked for, or +// the column's own name. +func customLabel(name string, cc config.CustomColumn) string { + if cc.Label != "" { + return cc.Label + } + return name +} + // colWidth is the reserved display width per column. func (x ctx) colWidth(name string) int { + if cc, ok := x.custom(name); ok { + if cc.Width > 0 { + return cc.Width + } + // Wide enough for the header, and for a value of a few digits. + w := DisplayWidth(customLabel(name, cc)) + if w < 5 { + w = 5 + } + return w + } switch name { case "hour": // Three, not two: the Python leaves a double space after the hour. @@ -39,6 +66,9 @@ func (x ctx) colWidth(name string) int { } func (x ctx) leftAligned(name string) bool { + if _, ok := x.custom(name); ok { + return false // custom columns are numeric + } switch name { case "hour", "icon", "temp", "feels", "conditions": return true @@ -86,7 +116,12 @@ func (x ctx) table(v View) []string { headers := map[string]cell{} for _, name := range x.visible() { - headers[name] = cell{text: x.cat.Header(name), style: ""} + text := x.cat.Header(name) + if cc, ok := x.custom(name); ok { + // A user-declared label is not ours to translate. + text = customLabel(name, cc) + } + headers[name] = cell{text: text, style: ""} } out = append(out, x.c(Underline, x.rowPlain(headers))) @@ -129,6 +164,10 @@ func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell { temp, hasTemp := r.Val("temperature_2m") for _, name := range x.visible() { + if cc, ok := x.custom(name); ok { + out[name] = x.customCell(r, name, cc) + continue + } switch name { case "hour": style := Reset @@ -238,3 +277,28 @@ func abs(f float64) float64 { } return f } + +// CustomKey is where a custom column's value lives in a Row. +// +// Prefixed so a column called "temp" or "visibility" can never shadow the API +// field of the same name that a built-in column reads. +func CustomKey(name string) string { return "x:" + name } + +// customCell formats one user-declared column. +// +// A value the API did not supply renders blank rather than as zero: for an +// allergen or a soil reading, "no data" and "none" are different claims. +func (x ctx) customCell(r openmeteo.Row, name string, cc config.CustomColumn) cell { + key := cc.Field + if cc.Source == "air" { + key = CustomKey(name) + } + v, ok := r.Val(key) + if !ok { + return cell{text: "", style: Dim} + } + return cell{ + text: fmt.Sprintf("%.*f%s", cc.Decimals, v, cc.Suffix), + style: Dim, + } +} |
