internal/control/screen_test.go

main
gitbay/internal/control/screen_test.go history · blame · raw

299 lines · 8913 bytes

23 symbols in this file
  1package control
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"io"
  7	"strings"
  8	"testing"
  9)
 10
 11func screenCtx(cols int, color bool) *Ctx {
 12	return &Ctx{Term: Term{Cols: cols, Color: color}}
 13}
 14
 15func renderString(c *Ctx, s screen) string {
 16	var b strings.Builder
 17	c.render(&b, s)
 18	return b.String()
 19}
 20
 21func sampleScreen() screen {
 22	return screen{
 23		fields: []field{
 24			{"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}},
 25			{"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}},
 26			{"Checks", []cell{cGlyph("running"), cText("1 running")}},
 27		},
 28		body:   "Pages long views.",
 29		format: "md",
 30		sections: []section{
 31			{title: "Commits", n: 2, rows: []row{
 32				rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")),
 33				rowOf(cRef("2b77e90"), cFlex("control: mark views")),
 34			}},
 35			{title: "Discussion", n: 0, empty: true},
 36			{title: "Hidden", n: 0},
 37		},
 38	}
 39}
 40
 41func TestRenderPlainLayout(t *testing.T) {
 42	got := renderString(screenCtx(80, false), sampleScreen())
 43	want := `Merge:   !552  wire $PAGER through long views
 44State:   open  cli-pager → main · cmc
 45Checks:  ◐  1 running
 46
 47Pages long views.
 48
 49Commits (2)
 508f3a1c2  cli: page long output
 512b77e90  control: mark views
 52
 53Discussion (0)
 54`
 55	if got != want {
 56		t.Errorf("got:\n%s\nwant:\n%s", got, want)
 57	}
 58}
 59
 60func TestRenderColourIsOnlyPaint(t *testing.T) {
 61	for _, cols := range []int{80, 120} {
 62		plain := renderString(screenCtx(cols, false), sampleScreen())
 63		colour := renderString(screenCtx(cols, true), sampleScreen())
 64		if stripSGR(colour) != plain {
 65			t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain)
 66		}
 67		if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) {
 68			t.Errorf("cols %d: heading not bold blue: %q", cols, colour)
 69		}
 70		if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) {
 71			t.Errorf("cols %d: label not dim: %q", cols, colour)
 72		}
 73	}
 74}
 75
 76func TestRenderMoreLine(t *testing.T) {
 77	s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"},
 78		rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}}
 79	got := renderString(screenCtx(80, false), s)
 80	if !strings.HasSuffix(got, "1779  test\n+13 more  gitbay build list krz/gitbay\n") {
 81		t.Errorf("got %q", got)
 82	}
 83}
 84
 85func TestRenderRowBody(t *testing.T) {
 86	s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{
 87		{cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "md"},
 88	}}}}
 89	got := renderString(screenCtx(80, false), s)
 90	want := "Discussion (1)\ncmc  2h\n  Looks good.\n"
 91	if got != want {
 92		t.Errorf("got %q, want %q", got, want)
 93	}
 94}
 95
 96func TestRenderTextBodyIsNotMarkup(t *testing.T) {
 97	s := screen{body: "a *b* c", format: "text"}
 98	if got := renderString(screenCtx(80, false), s); got != "a *b* c\n" {
 99		t.Errorf("got %q", got)
100	}
101}
102
103func TestRenderFieldWraps(t *testing.T) {
104	long := strings.Repeat("word ", 20)
105	s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}}
106	out := renderString(screenCtx(40, false), s)
107	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
108	if len(lines) < 2 {
109		t.Fatalf("did not wrap: %q", out)
110	}
111	for _, l := range lines {
112		if cells(l) > 40 {
113			t.Errorf("line wider than 40: %q", l)
114		}
115	}
116	if !strings.HasPrefix(lines[1], strings.Repeat(" ", len("Merge:  !1  "))) {
117		t.Errorf("continuation not under the value: %q", lines[1])
118	}
119}
120
121func TestCmdlineDropsHere(t *testing.T) {
122	c := screenCtx(80, false)
123	c.Term.Here = "krz/gitbay"
124	for _, tc := range []struct {
125		argv []string
126		want string
127	}{
128		{[]string{"mr", "diff", "krz/gitbay", "552"}, "gitbay mr diff 552"},
129		{[]string{"issue", "show", "krz/hutch", "3"}, "gitbay issue show krz/hutch 3"},
130		{[]string{"issue", "list", "--label", "needs review"}, "gitbay issue list --label 'needs review'"},
131	} {
132		if got := c.cmdline(tc.argv); got != tc.want {
133			t.Errorf("cmdline(%q) = %q, want %q", tc.argv, got, tc.want)
134		}
135	}
136}
137
138func legendSample() []action {
139	return []action{
140		{"Unblock", []string{"mr", "rebase", "552"}},
141		{"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}},
142		{"Review", []string{"mr", "comment", "krz/gitbay", "552"}},
143		{"Read", []string{"mr", "diff", "krz/gitbay", "552"}},
144	}
145}
146
147func TestLegendColumns(t *testing.T) {
148	c := screenCtx(120, false)
149	c.Term.Here = "krz/gitbay"
150	got := c.renderLegend(legendSample())
151	want := strings.Repeat("─", 120) + "\n" +
152		fmt.Sprintf("%-22s%-32s%s\n", "Unblock", "Review", "Read") +
153		fmt.Sprintf("%-22s%-32s%s\n", "gitbay mr rebase 552", "gitbay mr review 552 --approve", "gitbay mr diff 552") +
154		fmt.Sprintf("%-22s%s\n", "", "gitbay mr comment 552")
155	if got != want {
156		t.Errorf("got:\n%s\nwant:\n%s", got, want)
157	}
158}
159
160func TestLegendStacksWhenNarrow(t *testing.T) {
161	c := screenCtx(60, false)
162	c.Term.Here = "krz/gitbay"
163	got := c.renderLegend(legendSample())
164	want := strings.Repeat("─", 60) + "\n" +
165		"Unblock\ngitbay mr rebase 552\n" +
166		"Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" +
167		"Read\ngitbay mr diff 552\n"
168	if got != want {
169		t.Errorf("got:\n%s\nwant:\n%s", got, want)
170	}
171}
172
173func TestLegendColour(t *testing.T) {
174	got := screenCtx(120, true).renderLegend(legendSample())
175	if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) {
176		t.Errorf("legend paint: %q", got)
177	}
178	if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) {
179		t.Error("colour legend differs beyond SGR")
180	}
181}
182
183func TestEmitViewRoutes(t *testing.T) {
184	built := false
185	build := func() screen { built = true; return screen{fields: []field{{"Repo", []cell{cText("a/b")}}}} }
186	plain := func(w io.Writer) { io.WriteString(w, "plain\n") }
187
188	var out bytes.Buffer
189	c := &Ctx{Stdout: &out, Stderr: io.Discard}
190	c.emitView(map[string]string{"k": "v"}, plain, build)
191	if out.String() != "plain\n" || built {
192		t.Errorf("piped: %q built=%v", out.String(), built)
193	}
194
195	out.Reset()
196	c = &Ctx{Stdout: &out, Stderr: io.Discard, JSON: true, Term: Term{Cols: 80}}
197	c.emitView(map[string]string{"k": "v"}, plain, build)
198	if !strings.Contains(out.String(), `"k":"v"`) || built {
199		t.Errorf("json: %q built=%v", out.String(), built)
200	}
201
202	out.Reset()
203	c = &Ctx{Stdout: &out, Stderr: io.Discard, Term: Term{Cols: 80}}
204	c.emitView(map[string]string{"k": "v"}, plain, build)
205	if out.String() != "Repo:  a/b\n" || !built {
206		t.Errorf("terminal: %q built=%v", out.String(), built)
207	}
208}
209
210// errorer is the part of *testing.T checkActions uses, so its own test
211// can pass a recorder.
212type errorer interface {
213	Helper()
214	Errorf(format string, args ...any)
215}
216
217type recorder struct{ failed bool }
218
219func (r *recorder) Helper()               {}
220func (r *recorder) Errorf(string, ...any) { r.failed = true }
221
222// cliLocal are commands cmd/gitbay runs itself; the registry does not
223// know them, but a legend may suggest them.
224var cliLocal = map[string]bool{"mr rebase": true, "mr checkout": true, "repo clone": true}
225
226// checkActions fails t for any legend or "more" command that the
227// registry would not dispatch, or whose flags it does not declare.
228func checkActions(t errorer, s screen) {
229	t.Helper()
230	var all [][]string
231	for _, a := range s.actions {
232		all = append(all, a.argv)
233	}
234	for _, sec := range s.sections {
235		if len(sec.more) > 0 {
236			all = append(all, sec.more)
237		}
238	}
239	for _, argv := range all {
240		if len(argv) >= 2 && cliLocal[argv[0]+" "+argv[1]] {
241			continue
242		}
243		cmd, rest, ok := Lookup(argv)
244		if !ok {
245			t.Errorf("no command for %q", argv)
246			continue
247		}
248		if err := checkFlags(cmd, rest); err != nil {
249			t.Errorf("%q: %v", argv, err)
250		}
251	}
252}
253
254// checkFlags refuses a flag the command does not declare, and a value
255// flag with nothing after it. Words that are not flags are positionals.
256func checkFlags(cmd Command, rest []string) error {
257	takes := map[string]bool{}
258	for _, f := range cmd.Flags {
259		takes[f.Name] = f.Arg != ""
260	}
261	for i := 0; i < len(rest); i++ {
262		a := rest[i]
263		if !strings.HasPrefix(a, "--") {
264			continue
265		}
266		name, _, inline := strings.Cut(a, "=")
267		value, ok := takes[name]
268		if !ok {
269			return fmt.Errorf("%s does not take %s", joinPath(cmd.Path), name)
270		}
271		if value && !inline {
272			if i+1 >= len(rest) {
273				return fmt.Errorf("%s needs a value", name)
274			}
275			i++
276		}
277	}
278	return nil
279}
280
281func TestCheckActions(t *testing.T) {
282	for _, tc := range []struct {
283		argv []string
284		fail bool
285	}{
286		{[]string{"mr", "dif", "a/b", "1"}, true},
287		{[]string{"mr", "diff", "a/b", "1", "--bogus"}, true},
288		{[]string{"mr", "review", "a/b", "1", "--approve"}, false},
289		{[]string{"mr", "list", "a/b", "--state"}, true},
290		{[]string{"mr", "list", "a/b", "--state", "all"}, false},
291		{[]string{"mr", "rebase", "1"}, false},
292	} {
293		r := &recorder{}
294		checkActions(r, screen{actions: []action{{"G", tc.argv}}})
295		if r.failed != tc.fail {
296			t.Errorf("%q: failed = %v, want %v", tc.argv, r.failed, tc.fail)
297		}
298	}
299}