aboutsummaryrefslogtreecommitdiff
path: root/internal/render/width_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/width_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/width_test.go')
-rw-r--r--internal/render/width_test.go112
1 files changed, 112 insertions, 0 deletions
diff --git a/internal/render/width_test.go b/internal/render/width_test.go
new file mode 100644
index 0000000..f20038e
--- /dev/null
+++ b/internal/render/width_test.go
@@ -0,0 +1,112 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisplayWidth(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want int
+ }{
+ {"ascii", "temp", 4},
+ {"empty", "", 0},
+ {"degree sign is one cell", "28°", 3},
+ {"polish diacritics are one cell each", "słońce", 6},
+ {"emoji with variation selector counts once", "☀️", 1},
+ {"bare BMP symbol", "☀", 1},
+ {"sun behind cloud is wide", "⛅", 2},
+ {"rain cloud is wide", "\U0001F327", 2},
+ {"nerd font glyph is one cell", "", 1},
+ {"block drawing is one cell", "█", 1},
+ {"combining acute adds nothing", "é", 1},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := DisplayWidth(c.in); got != c.want {
+ t.Errorf("DisplayWidth(%q) = %d, want %d", c.in, got, c.want)
+ }
+ })
+ }
+}
+
+// The invariant that actually matters: whatever goes in a column, the padded
+// result occupies exactly the requested number of cells. If this fails, every
+// column to the right shears -- but only in a real terminal.
+func TestPadReachesExactWidthForEveryIconSet(t *testing.T) {
+ samples := []string{
+ "clear", "", "28°", "słońce",
+ "☀️", "⛅", "\U0001F327", "⛈", // emoji set
+ "", "", "", // nerd set
+ }
+ for _, s := range samples {
+ for _, w := range []int{1, 4, 8, 16} {
+ got := Pad(s, w)
+ if DisplayWidth(s) <= w && DisplayWidth(got) != w {
+ t.Errorf("Pad(%q, %d) has width %d, want %d", s, w, DisplayWidth(got), w)
+ }
+ if !strings.HasPrefix(got, s) {
+ t.Errorf("Pad(%q, %d) = %q, must not alter the content", s, w, got)
+ }
+ }
+ }
+}
+
+func TestPadLeft(t *testing.T) {
+ if got := PadLeft("5", 3); got != " 5" {
+ t.Fatalf("PadLeft = %q, want %q", got, " 5")
+ }
+ if got := PadLeft("⛅", 4); DisplayWidth(got) != 4 {
+ t.Fatalf("PadLeft of a wide glyph has width %d, want 4", DisplayWidth(got))
+ }
+}
+
+func TestPadDoesNotShrink(t *testing.T) {
+ if got := Pad("conditions", 4); got != "conditions" {
+ t.Fatalf("Pad must never truncate: got %q", got)
+ }
+}
+
+func TestTruncateNeverSplitsARune(t *testing.T) {
+ if got := Truncate("słońce", 3); got != "sło" {
+ t.Errorf("Truncate = %q, want %q", got, "sło")
+ }
+ // A wide glyph that does not fit is dropped whole, not halved.
+ if got := Truncate("a⛅", 2); got != "a" {
+ t.Errorf("Truncate = %q, want %q", got, "a")
+ }
+}
+
+func TestPaintGroupsRuns(t *testing.T) {
+ c := Styler(true)
+ cells := []Cell{
+ {Style: "31", Text: "a"}, {Style: "31", Text: "b"}, {Style: "31", Text: "c"},
+ {Style: "33", Text: "d"},
+ }
+ got := Paint(cells, c)
+ if n := strings.Count(got, "\033["); n != 4 { // 2 opens + 2 resets
+ t.Fatalf("expected one escape pair per run, got %d escapes in %q", n, got)
+ }
+ if strings.Count(got, "\033[31m") != 1 {
+ t.Errorf("the three red cells must share one escape: %q", got)
+ }
+}
+
+func TestPaintWithoutColourIsPlain(t *testing.T) {
+ c := Styler(false)
+ got := Paint([]Cell{{Style: "31", Text: "a"}, {Style: "33", Text: "b"}}, c)
+ if got != "ab" {
+ t.Fatalf("colour off must yield plain text, got %q", got)
+ }
+}
+
+// Colour must never reach for a 256-colour index.
+func TestNoExtendedColourCodes(t *testing.T) {
+ for _, code := range []string{Blue, Cyan, Green, Yellow, Red, BrightBlue, BrightRed, BrightWhite} {
+ if strings.Contains(code, "38;5;") || strings.Contains(code, "48;5;") {
+ t.Errorf("%q is a 256-colour index; only slots 0-15 are allowed", code)
+ }
+ }
+}