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", "humidity", "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 = 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) } // 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) } } } } 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) } } // A narrow range cannot be labelled in whole degrees: rounding makes adjacent // rows read the same, so the axis claims two different temperatures are equal. func TestNarrowRangeGetsDecimalAxisLabels(t *testing.T) { cfg := testConfig() cfg.Graph = true var rows []openmeteo.Row for i, temp := range []float64{15.7, 16.0, 16.0, 15.2, 14.8, 15.1, 15.2, 15.2} { r := row(0, temp, 3, 0, 0) r.When = time.Date(2026, 8, 25, 15+i, 0, 0, 0, time.UTC) rows = append(rows, r) } out := Render(view(rows...), cfg, 80, false) var labels []string for _, l := range strings.Split(out, "\n") { if i := strings.Index(l, "│"); i > 0 { if lbl := strings.TrimSpace(l[:i]); lbl != "" && lbl != "rain" { labels = append(labels, lbl) } } } if len(labels) < 3 { t.Fatalf("expected three axis labels, got %v\n%s", labels, out) } seen := map[string]bool{} for _, l := range labels { if seen[l] { t.Errorf("axis label %q repeats; a 1.2 degree span needs decimals:\n%s", l, out) } seen[l] = true if !strings.Contains(l, ".") { t.Errorf("label %q should carry a decimal for a narrow range", l) } } } // A wide range stays in whole degrees: decimals there are noise. func TestWideRangeKeepsWholeDegreeLabels(t *testing.T) { cfg := testConfig() cfg.Graph = true var rows []openmeteo.Row for i, temp := range []float64{5, 12, 18, 24, 28, 22, 14, 7} { r := row(0, temp, 3, 0, 0) r.When = time.Date(2026, 8, 25, 15+i, 0, 0, 0, time.UTC) rows = append(rows, r) } out := Render(view(rows...), cfg, 80, false) for _, l := range strings.Split(out, "\n") { if i := strings.Index(l, "│"); i > 0 { lbl := strings.TrimSpace(l[:i]) if strings.Contains(lbl, ".") { t.Errorf("wide range should use whole degrees, got %q", lbl) } } } } func TestAxisLabelPrecisionAndWidth(t *testing.T) { cases := []struct { lo, hi float64 maxWidth int wantDot bool why string }{ {14.8, 16.0, 5, true, "1.2 degrees needs decimals"}, {12, 28, 5, false, "16 degrees does not"}, {15, 17, 5, false, "exactly the threshold stays whole"}, {-20.4, -19.6, 5, false, "decimals would not fit, so fall back"}, } for _, c := range cases { f := tempLabeller(c.lo, c.hi, c.maxWidth) got := f(c.lo) if strings.Contains(got, ".") != c.wantDot { t.Errorf("lo=%v hi=%v gave %q; %s", c.lo, c.hi, got, c.why) } for _, v := range []float64{c.lo, c.hi} { if w := DisplayWidth(f(v)); w > c.maxWidth { t.Errorf("label %q is %d cells, over the %d-cell gutter", f(v), w, c.maxWidth) } } } }