summaryrefslogtreecommitdiff
path: root/internal/render/render_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render/render_test.go')
-rw-r--r--internal/render/render_test.go287
1 files changed, 287 insertions, 0 deletions
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..484a45b
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,287 @@
+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", "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 = 34
+ 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)
+ }
+ }
+ }
+}