internal/control/screen_test.go
299 lines · 8913 bytes
23 symbols in this file
screenCtxrenderStringsampleScreenTestRenderPlainLayoutTestRenderColourIsOnlyPaintTestRenderMoreLineTestRenderRowBodyTestRenderTextBodyIsNotMarkupTestRenderFieldWrapsTestCmdlineDropsHerelegendSampleTestLegendColumnsTestLegendStacksWhenNarrowTestLegendColourTestEmitViewRouteserrorerrecorderrecorder.Helperrecorder.ErrorfcliLocalcheckActionscheckFlagsTestCheckActions
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}