internal/control/table_test.go
270 lines · 8750 bytes
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7 "time"
8)
9
10func fixtureTable(c *Ctx, w *bytes.Buffer) {
11 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
12 tb.row(cRef("#252"), cState("open"), cFlex("Dependency updates available for every module"), cText("gitbay-bot"))
13 tb.row(cRef("#12"), cState("closed"), cFlex("Android app"), cText("cmc"))
14 tb.flush()
15}
16
17func TestTablePlainIsTabs(t *testing.T) {
18 var b bytes.Buffer
19 fixtureTable(&Ctx{}, &b)
20 want := "#252\topen\tDependency updates available for every module\tgitbay-bot\n" +
21 "#12\tclosed\tAndroid app\tcmc\n"
22 if b.String() != want {
23 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
24 }
25}
26
27func TestTableTerminalFits(t *testing.T) {
28 var b bytes.Buffer
29 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b)
30 want := "#252 open Dependency up… gitbay-bot\n" +
31 "#12 closed Android app cmc\n"
32 if b.String() != want {
33 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want)
34 }
35}
36
37// A column shrunk below its header's width clips the header too. The
38// number column is what keeps the header.
39func TestTableClipsHeaderToColumn(t *testing.T) {
40 var b bytes.Buffer
41 tb := (&Ctx{Term: Term{Cols: 12}}).table(&b, "FINGERPRINTS", "N")
42 tb.row(cFlex("SHA256:abcdefghijklmnopqrstuvwxyz"), cNum(1))
43 tb.flush()
44 for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
45 if cells(line) > 12 {
46 t.Errorf("line of %d cells at 12 columns: %q", cells(line), line)
47 }
48 }
49 if !strings.HasPrefix(b.String(), "FINGERPR… N\n") {
50 t.Errorf("header:\n%s", b.String())
51 }
52}
53
54func TestTableColourOnlyAddsSGR(t *testing.T) {
55 var mono, colour bytes.Buffer
56 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
57 fixtureTable(&Ctx{Term: Term{Cols: 40, Color: true}}, &colour)
58 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) {
59 t.Errorf("open not green: %q", colour.String())
60 }
61 if !strings.HasPrefix(colour.String(), sgrDim+"#252"+sgrReset) {
62 t.Errorf("ref not dim: %q", colour.String())
63 }
64 if stripSGR(colour.String()) != mono.String() {
65 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String())
66 }
67}
68
69func TestTableAgesAndPlainStamps(t *testing.T) {
70 termNow = func() time.Time { return time.Date(2026, 9, 23, 12, 0, 1, 0, time.UTC) }
71 t.Cleanup(func() { termNow = time.Now })
72 var plain, term bytes.Buffer
73 for _, c := range []struct {
74 ctx *Ctx
75 w *bytes.Buffer
76 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
77 tb := c.ctx.table(c.w, "#", "UPDATED")
78 tb.row(cRef("#1"), cAge("2026-09-23T10:00:00.123Z"))
79 tb.flush()
80 }
81 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" {
82 t.Errorf("plain = %q", plain.String())
83 }
84 if term.String() != "#1 2h ago\n" {
85 t.Errorf("term = %q", term.String())
86 }
87}
88
89// A row may carry a cell beyond what the header names — repo list's
90// trailing [archived] marker, only present on some rows. flush must
91// still render it, not silently drop it because it falls past
92// len(header).
93func TestTableKeepsCellsBeyondTheHeader(t *testing.T) {
94 var b bytes.Buffer
95 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "PATH", "VISIBILITY", "DESCRIPTION")
96 tb.row(cRef("a/x"), cState("public"), cFlex("one"))
97 tb.row(cRef("a/y"), cState("public"), cFlex("two"), cText("[archived]"))
98 tb.flush()
99
100 out := b.String()
101 if !strings.Contains(out, "[archived]") {
102 t.Fatalf("archived marker dropped:\n%s", out)
103 }
104 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
105 if len(lines) != 2 {
106 t.Fatalf("want 2 lines, got %d:\n%s", len(lines), out)
107 }
108 oneAt := strings.Index(lines[0], "one")
109 twoAt := strings.Index(lines[1], "two")
110 if oneAt < 0 || twoAt < 0 {
111 t.Fatalf("columns not found:\n%s", out)
112 }
113 if oneAt != twoAt {
114 t.Errorf("description column not aligned: row1 at %d, row2 at %d\n%s", oneAt, twoAt, out)
115 }
116}
117
118func TestTableEmptyPrintsNothing(t *testing.T) {
119 var b bytes.Buffer
120 (&Ctx{Term: Term{Cols: 80}}).table(&b, "#").flush()
121 if b.Len() != 0 {
122 t.Errorf("empty table printed %q", b.String())
123 }
124}
125
126func TestTableNoTrailingSpace(t *testing.T) {
127 var b bytes.Buffer
128 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "ADDRESS", "STATE")
129 tb.row(cRef("a@example.com"), cState("verified"), cText("primary"))
130 tb.row(cRef("b@example.com"), cState("verified"))
131 tb.flush()
132 for _, line := range strings.Split(b.String(), "\n") {
133 if strings.HasSuffix(line, " ") {
134 t.Errorf("trailing space: %q", line)
135 }
136 }
137}
138
139// A flexible column blank on most rows is capped at a third of the
140// terminal.
141func TestTableCapsSparseFlex(t *testing.T) {
142 var b bytes.Buffer
143 tb := (&Ctx{Term: Term{Cols: 90}}).table(&b, "TAG", "TITLE", "ASSETS")
144 tb.row(cRef("v3"), cFlex(""), cText("2"))
145 tb.row(cRef("v2"), cFlex(""), cText("2"))
146 tb.row(cRef("v1"), cFlex(""), cText("2"))
147 tb.row(cRef("v0"), cFlex(strings.Repeat("x", 60)), cText("2"))
148 tb.flush()
149 lines := strings.Split(b.String(), "\n")
150 if at := strings.LastIndex(lines[0], "2"); at != len("v3 ")+30+2 {
151 t.Errorf("assets at %d:\n%s", at, b.String())
152 }
153}
154
155func TestTableSizeCells(t *testing.T) {
156 var plain, term bytes.Buffer
157 for _, c := range []struct {
158 ctx *Ctx
159 w *bytes.Buffer
160 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
161 tb := c.ctx.table(c.w, "NAME", "SIZE")
162 tb.row(cRef("a"), cSize(2048))
163 tb.flush()
164 }
165 if plain.String() != "a\t2048\n" {
166 t.Errorf("plain = %q", plain.String())
167 }
168 if term.String() != "NAME SIZE\na 2.0 KiB\n" {
169 t.Errorf("term = %q", term.String())
170 }
171}
172
173func TestTableDropsEmptyColumns(t *testing.T) {
174 var b bytes.Buffer
175 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "LABELS", "TITLE")
176 tb.row(cRef("#1"), cText(""), cFlex("one"))
177 tb.row(cRef("#2"), cText(""), cFlex("two"))
178 tb.flush()
179 if b.String() != "#1 one\n#2 two\n" {
180 t.Errorf("terminal = %q", b.String())
181 }
182 var plain bytes.Buffer
183 tb = (&Ctx{}).table(&plain, "#", "LABELS", "TITLE")
184 tb.row(cRef("#1"), cText(""), cFlex("one"))
185 tb.flush()
186 if plain.String() != "#1\t\tone\n" {
187 t.Errorf("plain = %q", plain.String())
188 }
189}
190
191// A swatch's dot is counted in the column's width, so the next column
192// stays aligned.
193func TestTableSwatchAligned(t *testing.T) {
194 var b bytes.Buffer
195 tb := (&Ctx{Term: Term{Cols: 80, Color: true, TrueColor: true}}).table(&b, "NAME", "COLOR", "ISSUES")
196 tb.row(cRef("bug"), cSwatch("#cf222e"), cNum(3))
197 tb.row(cRef("none"), cSwatch(""), cNum(1))
198 tb.flush()
199 lines := strings.Split(stripSGR(b.String()), "\n")
200 col := func(line, sub string) int { return cells(line[:strings.Index(line, sub)]) }
201 if at := col(lines[0], "ISSUES"); at != col(lines[1], "3") || at != col(lines[2], "1") {
202 t.Errorf("ISSUES not aligned:\n%s", stripSGR(b.String()))
203 }
204 var plain bytes.Buffer
205 tb = (&Ctx{}).table(&plain, "NAME", "COLOR")
206 tb.row(cRef("bug"), cSwatch("#cf222e"))
207 tb.flush()
208 if plain.String() != "bug\t#cf222e\n" {
209 t.Errorf("plain = %q", plain.String())
210 }
211}
212
213func TestRefsDimNotCyan(t *testing.T) {
214 var b bytes.Buffer
215 tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b, "#", "TITLE")
216 tb.row(cRef("#12"), cFlex("Android app"))
217 tb.flush()
218 if strings.Contains(b.String(), sgrCyan) {
219 t.Errorf("ref painted cyan: %q", b.String())
220 }
221 if !strings.Contains(b.String(), sgrDim+"#12"+sgrReset) {
222 t.Errorf("ref not dim: %q", b.String())
223 }
224}
225
226func TestGlyphAndMetaCells(t *testing.T) {
227 var b bytes.Buffer
228 tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b)
229 tb.row(cRef("1779"), cGlyph("failure"), cFlex("test"), cMeta("gitbay", "2h"))
230 tb.row(cRef("1780"), cYou(), cFlex("review"), cMeta("", "3h"))
231 tb.flush()
232 out := b.String()
233 for _, want := range []string{sgrRed + "✗" + sgrReset, sgrYellow + "●" + sgrReset, sgrDim + "gitbay · 2h" + sgrReset, sgrDim + "3h" + sgrReset} {
234 if !strings.Contains(out, want) {
235 t.Errorf("missing %q in %q", want, out)
236 }
237 }
238}
239
240func TestTableDropsHeaderWithoutNumbers(t *testing.T) {
241 var b bytes.Buffer
242 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "STATE", "TITLE")
243 tb.row(cRef("#12"), cState("open"), cFlex("Android app"))
244 tb.flush()
245 if got, want := b.String(), "#12 open Android app\n"; got != want {
246 t.Errorf("got %q, want %q", got, want)
247 }
248}
249
250func TestTableKeepsHeaderForNumbers(t *testing.T) {
251 var b bytes.Buffer
252 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "RUNNER", "PENDING")
253 tb.row(cText("bay1"), cNum(3))
254 tb.flush()
255 if !strings.HasPrefix(b.String(), "RUNNER PENDING\n") {
256 t.Errorf("header missing: %q", b.String())
257 }
258}
259
260func TestTableLines(t *testing.T) {
261 var b bytes.Buffer
262 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b)
263 tb.row(cRef("!1"), cFlex("one"))
264 tb.row(cRef("!22"), cFlex("two"))
265 got := tb.lines()
266 want := []string{"!1 one", "!22 two"}
267 if strings.Join(got, "\n") != strings.Join(want, "\n") {
268 t.Errorf("got %q, want %q", got, want)
269 }
270}