aboutsummaryrefslogtreecommitdiff
path: root/internal/render
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render')
-rw-r--r--internal/render/render.go8
-rw-r--r--internal/render/render_test.go122
-rw-r--r--internal/render/table.go66
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,
+ }
+}