turbo-editors/turbo-corepublic Fork 0
v0.9.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

view_test.go · 537 lines · 15.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1package terminal
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/gdamore/tcell/v2"
9
10 "codeberg.org/turbo-editors/turbo-core/theme"
11 "codeberg.org/turbo-editors/turbo-core/ui"
12)
13
14// newTestView starts a view on a bare shell, closed when the test ends.
15// newTestViewWith returns a view on a shell, wired to the given callbacks.
16//
17// They are passed in rather than assigned afterwards because NewView starts the
18// goroutine that calls them — assigning them later is the data race this whole
19// arrangement exists to prevent.
20func newTestViewWith(t *testing.T, width, height int, onChange, onExit func()) *View {
21 t.Helper()
22 skipWithoutPTY(t)
23
24 v, err := NewView(ViewOptions{
25 Options: Options{Shell: "/bin/sh", Dir: t.TempDir(), Width: width, Height: height},
26 OnChange: onChange,
27 OnExit: onExit,
28 })
29 if err != nil {
30 t.Fatalf("NewView() error = %v", err)
31 }
32 t.Cleanup(func() { v.Close() })
33 v.SetBounds(ui.Rect{W: width, H: height})
34 return v
35}
36
37func newTestView(t *testing.T, width, height int) *View {
38 t.Helper()
39 skipWithoutPTY(t)
40
41 v, err := NewView(ViewOptions{
42 Options: Options{Shell: "/bin/sh", Dir: t.TempDir(), Width: width, Height: height},
43 })
44 if err != nil {
45 t.Fatalf("NewView() error = %v", err)
46 }
47 t.Cleanup(func() { v.Close() })
48 v.SetBounds(ui.Rect{W: width, H: height})
49 return v
50}
51
52// newOfflineView returns a view with no shell behind it.
53//
54// Drawing needs no session, and a test about what reaches the screen must not
55// race the shell's own startup output for the cell it is looking at — which is
56// exactly the flake this helper replaced.
57func newOfflineView(t *testing.T, width, height int) *View {
58 t.Helper()
59
60 v := &View{
61 parser: NewParser(NewScreen(width, height)),
62 dirty: make(chan struct{}, 1),
63 closed: make(chan struct{}),
64 }
65 v.SetFocused(true)
66 v.FocusBox.SetBounds(ui.Rect{W: width, H: height})
67 return v
68}
69
70// waitUntil waits for a condition on the view's screen, which is checked with
71// the lock held.
72func waitUntil(t *testing.T, v *View, description string, condition func() bool) {
73 t.Helper()
74
75 deadline := time.After(10 * time.Second)
76 for {
77 v.mu.Lock()
78 met := condition()
79 shown := screenText(v.parser.Screen())
80 v.mu.Unlock()
81
82 if met {
83 return
84 }
85 select {
86 case <-deadline:
87 t.Fatalf("the terminal never showed %s; it shows:\n%s", description, shown)
88 case <-time.After(5 * time.Millisecond):
89 }
90 }
91}
92
93// waitForScreen waits until some row of the view's screen holds the text.
94func waitForScreen(t *testing.T, v *View, want string) {
95 t.Helper()
96
97 deadline := time.After(10 * time.Second)
98 for {
99 v.mu.Lock()
100 found := screenContains(v.parser.Screen(), want)
101 shown := screenText(v.parser.Screen())
102 v.mu.Unlock()
103
104 if found {
105 return
106 }
107 select {
108 case <-deadline:
109 t.Fatalf("the terminal never showed %q; it shows:\n%s", want, shown)
110 case <-time.After(5 * time.Millisecond):
111 }
112 }
113}
114
115// drawView paints a view onto a simulated screen and returns what it shows.
116func drawView(t *testing.T, v *View) (tcell.SimulationScreen, []string) {
117 t.Helper()
118
119 screen := tcell.NewSimulationScreen("UTF-8")
120 if err := screen.Init(); err != nil {
121 t.Fatalf("initialising the simulation screen: %v", err)
122 }
123 t.Cleanup(screen.Fini)
124
125 bounds := v.Bounds()
126 screen.SetSize(bounds.W, bounds.H)
127 v.Draw(ui.NewPainter(screen).Sub(bounds), theme.Default(""))
128 screen.Show()
129
130 cells, width, height := screen.GetContents()
131 lines := make([]string, height)
132 for row := range height {
133 var text strings.Builder
134 for col := range width {
135 runes := cells[row*width+col].Runes
136 if len(runes) == 0 {
137 text.WriteRune(' ')
138 continue
139 }
140 text.WriteRune(runes[0])
141 }
142 lines[row] = strings.TrimRight(text.String(), " ")
143 }
144 return screen, lines
145}
146
147func TestAViewShowsWhatTheShellWrites(t *testing.T) {
148 v := newTestView(t, 40, 8)
149
150 v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'e', tcell.ModNone))
151 typeInto(v, "cho drawn-here\r")
152
153 waitForScreen(t, v, "drawn-here")
154
155 _, lines := drawView(t, v)
156 if !containsLine(lines, "drawn-here") {
157 t.Errorf("the drawn view does not show the output:\n%s", strings.Join(lines, "\n"))
158 }
159}
160
161func TestTheThemeFillsInColoursTheProgramDidNotChoose(t *testing.T) {
162 v := newOfflineView(t, 20, 4)
163 th := theme.Default("")
164
165 screen, _ := drawView(t, v)
166 cells, width, _ := screen.GetContents()
167
168 // The second row, because the cursor sits on the first one and is drawn in
169 // a colour of its own.
170 wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalText).Decompose()
171 got, gotBackground, _ := cells[width].Style.Decompose()
172
173 if got != wantForeground || gotBackground != wantBackground {
174 t.Errorf("an untouched cell is %v on %v, want the theme's %v on %v",
175 got, gotBackground, wantForeground, wantBackground)
176 }
177}
178
179func TestTheCursorIsDrawnInTheThemesCursorColour(t *testing.T) {
180 v := newOfflineView(t, 20, 4)
181 th := theme.Default("")
182
183 screen, _ := drawView(t, v)
184 cells, _, _ := screen.GetContents()
185
186 wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalCursor).Decompose()
187 got, gotBackground, _ := cells[0].Style.Decompose()
188
189 if got != wantForeground || gotBackground != wantBackground {
190 t.Errorf("the cursor cell is %v on %v, want the theme's %v on %v",
191 got, gotBackground, wantForeground, wantBackground)
192 }
193}
194
195func TestAnUnfocusedTerminalDrawsNoCursor(t *testing.T) {
196 v := newOfflineView(t, 20, 4)
197 v.SetFocused(false)
198 th := theme.Default("")
199
200 screen, _ := drawView(t, v)
201 cells, _, _ := screen.GetContents()
202
203 // Two terminals both showing a cursor would be two claims on the keyboard.
204 wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalText).Decompose()
205 got, gotBackground, _ := cells[0].Style.Decompose()
206
207 if got != wantForeground || gotBackground != wantBackground {
208 t.Errorf("an unfocused terminal drew its cursor: %v on %v", got, gotBackground)
209 }
210}
211
212func TestAProgramsChosenColoursAreLeftAlone(t *testing.T) {
213 v := newOfflineView(t, 20, 4)
214
215 // A program that named both halves must get both back untouched, however
216 // little they resemble the theme.
217 v.parser.Write([]byte("\x1b[38;5;196;48;5;21mX")) //nolint:errcheck // the parser never fails
218
219 screen, _ := drawView(t, v)
220 cells, _, _ := screen.GetContents()
221
222 foreground, background, _ := cells[0].Style.Decompose()
223 if foreground != tcell.PaletteColor(196) || background != tcell.PaletteColor(21) {
224 t.Errorf("the cell is %v on %v, want the program's own 196 on 21", foreground, background)
225 }
226}
227
228func TestAProgramsOwnColoursSurvive(t *testing.T) {
229 v := newTestView(t, 40, 6)
230
231 // Waiting for the word itself would prove nothing: the pseudo-terminal
232 // echoes the command line, so "red" appears before the command has run.
233 // The colour is what only the output can produce.
234 typeInto(v, "printf '\\033[31mred\\033[0m\\n'\r")
235
236 waitUntil(t, v, "a red cell", func() bool {
237 return screenHasColour(v.parser.Screen(), tcell.ColorMaroon)
238 })
239}
240
241func TestTheCursorIsDrawnOnlyWhenTheViewHasTheFocus(t *testing.T) {
242 v := newTestView(t, 20, 4)
243
244 screen, _ := drawView(t, v)
245 if _, _, visible := screen.GetCursor(); !visible {
246 t.Error("a focused terminal did not place the cursor")
247 }
248
249 v.SetFocused(false)
250 screen, _ = drawView(t, v)
251 if _, _, visible := screen.GetCursor(); visible {
252 t.Error("an unfocused terminal placed the cursor")
253 }
254}
255
256func TestResizingTellsTheShell(t *testing.T) {
257 v := newTestView(t, 40, 8)
258
259 v.SetBounds(ui.Rect{W: 100, H: 30})
260 typeInto(v, "stty size\r")
261
262 waitForScreen(t, v, "30 100")
263}
264
265func TestTheViewTitleIsTheShellUntilTheProgramSaysOtherwise(t *testing.T) {
266 v := newTestView(t, 40, 6)
267
268 if got := v.Title(); got != "sh" {
269 t.Errorf("Title() = %q, want the shell's name", got)
270 }
271
272 typeInto(v, "printf '\\033]0;my title\\007'\r")
273
274 waitUntil(t, v, "the title the program set", func() bool {
275 return v.parser.Title() == "my title"
276 })
277}
278
279func TestScrollingBackThroughTheHistory(t *testing.T) {
280 v := newTestView(t, 40, 6)
281
282 typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r")
283 waitForScreen(t, v, "line-9")
284
285 if got := v.ScrollOffset(); got != 0 {
286 t.Fatalf("ScrollOffset() = %d, want the live screen", got)
287 }
288
289 v.ScrollBy(4)
290 if got := v.ScrollOffset(); got == 0 {
291 t.Fatal("ScrollBy did not move back into the history")
292 }
293
294 _, lines := drawView(t, v)
295 if containsLine(lines, "line-9") {
296 t.Errorf("the view still shows the newest line after scrolling back:\n%s", strings.Join(lines, "\n"))
297 }
298
299 v.ScrollToBottom()
300 _, lines = drawView(t, v)
301 if !containsLine(lines, "line-9") {
302 t.Errorf("the view did not come back to the live screen:\n%s", strings.Join(lines, "\n"))
303 }
304}
305
306func TestScrollingStopsAtBothEnds(t *testing.T) {
307 v := newTestView(t, 40, 6)
308
309 v.ScrollBy(-100)
310 if got := v.ScrollOffset(); got != 0 {
311 t.Errorf("ScrollOffset() = %d, want it held at the live screen", got)
312 }
313
314 v.ScrollBy(10_000)
315 v.mu.Lock()
316 history := v.parser.Screen().ScrollbackLen()
317 v.mu.Unlock()
318 if got := v.ScrollOffset(); got > history {
319 t.Errorf("ScrollOffset() = %d, want no more than the %d lines of history", got, history)
320 }
321}
322
323func TestShiftPageUpReadsBackAndAKeyComesForward(t *testing.T) {
324 v := newTestView(t, 40, 6)
325 typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r")
326 waitForScreen(t, v, "line-9")
327
328 v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModShift))
329 if v.ScrollOffset() == 0 {
330 t.Fatal("Shift-PageUp did not read back through the history")
331 }
332
333 // Typing must bring the view back, or the user cannot see what they type.
334 v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone))
335 if got := v.ScrollOffset(); got != 0 {
336 t.Errorf("ScrollOffset() = %d after a key press, want the live screen", got)
337 }
338}
339
340func TestPlainPageUpGoesToTheShell(t *testing.T) {
341 // Shift is what scrolls; a bare Page Up belongs to the program, which may
342 // well be a pager.
343 v := newTestView(t, 40, 6)
344 typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r")
345 waitForScreen(t, v, "line-9")
346
347 v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone))
348
349 if got := v.ScrollOffset(); got != 0 {
350 t.Errorf("ScrollOffset() = %d, want a bare Page Up to have gone to the shell", got)
351 }
352}
353
354func TestTheWheelReadsBackThroughTheHistory(t *testing.T) {
355 v := newTestView(t, 40, 6)
356 typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r")
357 waitForScreen(t, v, "line-9")
358
359 v.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelUp, tcell.ModNone))
360
361 if got := v.ScrollOffset(); got != wheelStep {
362 t.Errorf("ScrollOffset() = %d, want %d", got, wheelStep)
363 }
364
365 v.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelDown, tcell.ModNone))
366 if got := v.ScrollOffset(); got != 0 {
367 t.Errorf("ScrollOffset() = %d, want it back at the live screen", got)
368 }
369}
370
371func TestAMouseEventThatMissesTheViewIsNotItsBusiness(t *testing.T) {
372 v := newTestView(t, 10, 4)
373
374 if v.HandleMouse(tcell.NewEventMouse(50, 50, tcell.WheelUp, tcell.ModNone)) {
375 t.Error("the view claimed a wheel event from outside it")
376 }
377}
378
379func TestAnUnfocusedViewIgnoresKeys(t *testing.T) {
380 v := newTestView(t, 20, 4)
381 v.SetFocused(false)
382
383 if v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) {
384 t.Error("an unfocused terminal claimed a key")
385 }
386}
387
388func TestOutputArrivingWhileReadingHistoryComesForward(t *testing.T) {
389 v := newTestView(t, 40, 6)
390 typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r")
391 waitForScreen(t, v, "line-9")
392
393 v.ScrollBy(4)
394 typeInto(v, "echo pulled-forward\r")
395 waitForScreen(t, v, "pulled-forward")
396
397 if got := v.ScrollOffset(); got != 0 {
398 t.Errorf("ScrollOffset() = %d, want new output to have brought the view forward", got)
399 }
400}
401
402func TestTheEditorIsToldWhenTheShellExits(t *testing.T) {
403 gone := make(chan struct{})
404 v := newTestViewWith(t, 20, 4, nil, func() { close(gone) })
405
406 typeInto(v, "exit\r")
407
408 select {
409 case <-gone:
410 case <-time.After(10 * time.Second):
411 t.Error("OnExit was never called after the shell left")
412 }
413}
414
415func TestTheEditorIsAskedToRedraw(t *testing.T) {
416 drawn := make(chan struct{}, 64)
417 v := newTestViewWith(t, 20, 4, func() {
418 select {
419 case drawn <- struct{}{}:
420 default:
421 }
422 }, nil)
423
424 typeInto(v, "echo something\r")
425
426 select {
427 case <-drawn:
428 case <-time.After(10 * time.Second):
429 t.Error("the editor was never asked to redraw")
430 }
431}
432
433func TestNewViewFailsWhenTheShellIsNotThere(t *testing.T) {
434 skipWithoutPTY(t)
435
436 options := ViewOptions{Options: Options{Shell: "/nonexistent/shell", Width: 20, Height: 5}}
437 if _, err := NewView(options); err == nil {
438 t.Fatal("NewView() error = nil for a shell that does not exist")
439 }
440}
441
442// typeInto sends a string to a view one key at a time, as typing would.
443func typeInto(v *View, text string) {
444 for _, r := range text {
445 key := tcell.KeyRune
446 if r == '\r' {
447 key = tcell.KeyEnter
448 r = 0
449 }
450 v.HandleKey(tcell.NewEventKey(key, r, tcell.ModNone))
451 }
452}
453
454// containsLine reports whether any of the lines holds the text.
455func containsLine(lines []string, want string) bool {
456 for _, line := range lines {
457 if strings.Contains(line, want) {
458 return true
459 }
460 }
461 return false
462}
463
464// screenHasColour reports whether any cell is painted in a colour.
465func screenHasColour(s *Screen, want tcell.Color) bool {
466 _, height := s.Size()
467 width, _ := s.Size()
468
469 for row := range height {
470 for col := range width {
471 if foreground, _, _ := s.CellAt(row, col).Style.Decompose(); foreground == want {
472 return true
473 }
474 }
475 }
476 return false
477}
478
479func TestTheNameIsUsedWhenTheProgramSetsNoTitle(t *testing.T) {
480 // A command run through a shell would otherwise show as "sh", which says
481 // nothing about what is in the window.
482 v := newOfflineView(t, 20, 4)
483 v.name = "go test ./..."
484
485 if got := v.Title(); got != "go test ./..." {
486 t.Errorf("Title() = %q, want the name the caller gave", got)
487 }
488}
489
490func TestATitleTheProgramAsksForWinsOverTheName(t *testing.T) {
491 // vim naming the file it has open is saying something the caller could not
492 // have known.
493 v := newOfflineView(t, 20, 4)
494 v.name = "go run ."
495 v.parser.Write([]byte("\x1b]0;vim main.go\x07")) //nolint:errcheck // the parser never fails
496
497 if got := v.Title(); got != "vim main.go" {
498 t.Errorf("Title() = %q, want the program's own title", got)
499 }
500}
501
502func TestAFinishedTerminalStopsTakingKeys(t *testing.T) {
503 // Otherwise the write to the dead shell fails silently, the key is consumed
504 // anyway, and Ctrl-W can never close the window — leaving the mouse as the
505 // only way out of a command that has finished.
506 v := newTestView(t, 20, 4)
507 typeInto(v, "exit\r")
508
509 waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() })
510
511 if v.HandleKey(tcell.NewEventKey(tcell.KeyCtrlW, 0, tcell.ModCtrl)) {
512 t.Error("a finished terminal consumed Ctrl-W")
513 }
514 if v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) {
515 t.Error("a finished terminal consumed a printable key")
516 }
517}
518
519func TestAFinishedTerminalStillScrollsItsOutput(t *testing.T) {
520 // Reading back through what a command printed is the whole reason the
521 // window stays open after it exits.
522 v := newTestView(t, 20, 4)
523 typeInto(v, "exit\r")
524 waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() })
525
526 if !v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModShift)) {
527 t.Error("a finished terminal will not scroll back through its output")
528 }
529}
530
531func TestALiveTerminalIsNotExited(t *testing.T) {
532 v := newTestView(t, 20, 4)
533
534 if v.Exited() {
535 t.Error("a running shell reports itself gone")
536 }
537}