internal/control/table_test.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/table_test.go history · blame · raw

270 lines · 8750 bytes

18 symbols in this file
  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}