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) } } } }