aboutsummaryrefslogtreecommitdiff
path: root/internal/render/render_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/render/render_test.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
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)
+ }
+ }
+ }
+}