turbo-editors/turbo-corepublic Fork 0
d662cebdb65b319885da903daf7eff9ab1bfbb78
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 · 399 lines · 11.2 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 21h ago1package filetree
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
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 returns a focused view on a project, sized to fit.
15func newTestView(t *testing.T, root string, width, height int) *View {
16 t.Helper()
17
18 v, err := NewView(root)
19 if err != nil {
20 t.Fatalf("NewView(%q) error = %v", root, err)
21 }
22 v.SetFocused(true)
23 v.SetBounds(ui.Rect{W: width, H: height})
24 return v
25}
26
27// drawnRows paints a view onto a simulated screen and returns what it shows,
28// trimmed of the scroll bar and trailing blanks.
29func drawnRows(t *testing.T, v *View) []string {
30 t.Helper()
31
32 screen := tcell.NewSimulationScreen("UTF-8")
33 if err := screen.Init(); err != nil {
34 t.Fatalf("initialising the simulation screen: %v", err)
35 }
36 t.Cleanup(screen.Fini)
37
38 bounds := v.Bounds()
39 screen.SetSize(bounds.W, bounds.H)
40 v.Draw(ui.NewPainter(screen), theme.Default(""))
41 screen.Show()
42
43 cells, width, height := screen.GetContents()
44 out := make([]string, height)
45 for row := range height {
46 var text strings.Builder
47 for col := range width - 1 { // the last column is the scroll bar
48 runes := cells[row*width+col].Runes
49 if len(runes) == 0 {
50 text.WriteRune(' ')
51 continue
52 }
53 text.WriteRune(runes[0])
54 }
55 out[row] = strings.TrimRight(text.String(), " ")
56 }
57 return out
58}
59
60// press sends a key to the view.
61func press(v *View, key tcell.Key) bool {
62 return v.HandleKey(tcell.NewEventKey(key, 0, tcell.ModNone))
63}
64
65func TestTheViewDrawsMarkersAndIndentation(t *testing.T) {
66 v := newTestView(t, makeTree(t, "internal/app/app.go", "main.go"), 30, 8)
67 find2(t, v, "internal").Expand()
68
69 rows := drawnRows(t, v)
70
71 want := []string{"▼ internal", " ▶ app", " main.go"}
72 for i, line := range want {
73 if rows[i] != line {
74 t.Errorf("row %d is %q, want %q (whole view:\n%s)", i, rows[i], line, strings.Join(rows, "\n"))
75 }
76 }
77}
78
79func TestTheTitleIsTheProjectDirectory(t *testing.T) {
80 root := makeTree(t, "main.go")
81 v := newTestView(t, root, 30, 8)
82
83 if got := v.Title(); got != filepath.Base(root) {
84 t.Errorf("Title() = %q, want %q", got, filepath.Base(root))
85 }
86 if got := v.Root(); got != root {
87 t.Errorf("Root() = %q, want %q", got, root)
88 }
89}
90
91func TestTheArrowsWalkTheRows(t *testing.T) {
92 v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go"), 30, 8)
93
94 press(v, tcell.KeyDown)
95 press(v, tcell.KeyDown)
96 if got := v.Selected().Name(); got != "c.go" {
97 t.Errorf("after two downs the highlight is on %q, want c.go", got)
98 }
99
100 press(v, tcell.KeyUp)
101 if got := v.Selected().Name(); got != "b.go" {
102 t.Errorf("after an up the highlight is on %q, want b.go", got)
103 }
104}
105
106func TestTheArrowsStopAtEitherEnd(t *testing.T) {
107 v := newTestView(t, makeTree(t, "a.go", "b.go"), 30, 8)
108
109 for range 5 {
110 press(v, tcell.KeyUp)
111 }
112 if got := v.Selected().Name(); got != "a.go" {
113 t.Errorf("the highlight ran off the top onto %q", got)
114 }
115 for range 5 {
116 press(v, tcell.KeyDown)
117 }
118 if got := v.Selected().Name(); got != "b.go" {
119 t.Errorf("the highlight ran off the bottom onto %q", got)
120 }
121}
122
123func TestHomeAndEndGoToTheEnds(t *testing.T) {
124 v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go"), 30, 8)
125
126 press(v, tcell.KeyEnd)
127 if got := v.Selected().Name(); got != "c.go" {
128 t.Errorf("End put the highlight on %q", got)
129 }
130 press(v, tcell.KeyHome)
131 if got := v.Selected().Name(); got != "a.go" {
132 t.Errorf("Home put the highlight on %q", got)
133 }
134}
135
136func TestRightOpensAClosedDirectoryAndThenStepsIntoIt(t *testing.T) {
137 v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8)
138
139 press(v, tcell.KeyRight)
140 if !v.Selected().Expanded() {
141 t.Fatal("right did not open the directory")
142 }
143
144 press(v, tcell.KeyRight)
145 if got := v.Selected().Name(); got != "app.go" {
146 t.Errorf("a second right put the highlight on %q, want app.go", got)
147 }
148}
149
150func TestLeftClosesAnOpenDirectoryAndThenStepsOut(t *testing.T) {
151 v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8)
152 press(v, tcell.KeyRight) // open internal
153 press(v, tcell.KeyRight) // step onto app.go
154
155 press(v, tcell.KeyLeft)
156 if got := v.Selected().Name(); got != "internal" {
157 t.Fatalf("left from a file put the highlight on %q, want its directory", got)
158 }
159
160 press(v, tcell.KeyLeft)
161 if v.Selected().Expanded() {
162 t.Error("a second left did not close the directory")
163 }
164}
165
166func TestChoosingAFileHandsItsPathOver(t *testing.T) {
167 root := makeTree(t, "main.go")
168 v := newTestView(t, root, 30, 8)
169
170 var opened []string
171 v.OnOpen = func(path string) { opened = append(opened, path) }
172 press(v, tcell.KeyEnter)
173
174 if len(opened) != 1 || opened[0] != filepath.Join(root, "main.go") {
175 t.Errorf("OnOpen saw %v, want the one file's absolute path", opened)
176 }
177}
178
179func TestChoosingADirectoryOpensItRatherThanTheFile(t *testing.T) {
180 v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8)
181
182 opened := 0
183 v.OnOpen = func(string) { opened++ }
184 press(v, tcell.KeyEnter)
185
186 if opened != 0 {
187 t.Error("choosing a directory was reported as opening a file")
188 }
189 if !v.Selected().Expanded() {
190 t.Error("choosing a directory did not open it")
191 }
192}
193
194func TestChoosingWithNoOnOpenDoesNotPanic(t *testing.T) {
195 v := newTestView(t, makeTree(t, "main.go"), 30, 8)
196
197 press(v, tcell.KeyEnter) // nobody is listening
198}
199
200func TestAnEmptyProjectHandlesKeysWithoutPanicking(t *testing.T) {
201 v := newTestView(t, t.TempDir(), 30, 8)
202
203 for _, key := range []tcell.Key{tcell.KeyDown, tcell.KeyUp, tcell.KeyLeft, tcell.KeyRight, tcell.KeyEnter, tcell.KeyEnd} {
204 press(v, key)
205 }
206 if v.Selected() != nil {
207 t.Error("an empty project reported a highlighted node")
208 }
209}
210
211func TestCollapsingABranchKeepsTheHighlightOnARow(t *testing.T) {
212 // The highlight was below the branch that just closed, so it would
213 // otherwise be left pointing past the last row.
214 v := newTestView(t, makeTree(t, "internal/a.go", "internal/b.go", "internal/c.go"), 30, 8)
215 press(v, tcell.KeyRight) // open internal
216 press(v, tcell.KeyEnd) // onto c.go, the last row
217
218 v.Selected() // c.go
219 press(v, tcell.KeyHome)
220 press(v, tcell.KeyLeft) // close internal
221
222 if got := v.Selected().Name(); got != "internal" {
223 t.Errorf("the highlight is on %q after collapsing", got)
224 }
225}
226
227func TestF5AndCtrlRRefreshTheTree(t *testing.T) {
228 for _, key := range []tcell.Key{tcell.KeyF5, tcell.KeyCtrlR} {
229 t.Run(tcell.KeyNames[key], func(t *testing.T) {
230 root := makeTree(t, "main.go")
231 v := newTestView(t, root, 30, 8)
232 writeFile(t, filepath.Join(root, "other.go"))
233
234 press(v, key)
235
236 if len(v.tree.Rows()) != 2 {
237 t.Errorf("the tree shows %d rows after a refresh, want 2", len(v.tree.Rows()))
238 }
239 })
240 }
241}
242
243func TestRefreshKeepsTheHighlightOnTheSameFile(t *testing.T) {
244 root := makeTree(t, "b.go", "c.go")
245 v := newTestView(t, root, 30, 8)
246 press(v, tcell.KeyDown) // onto c.go
247
248 writeFile(t, filepath.Join(root, "a.go")) // sorts before both
249 v.Refresh()
250
251 if got := v.Selected().Name(); got != "c.go" {
252 t.Errorf("the highlight moved to %q; a new file above it must not carry it along", got)
253 }
254}
255
256func TestRefreshKeepsTheHighlightInRangeWhenItsFileHasGone(t *testing.T) {
257 root := makeTree(t, "a.go", "b.go")
258 v := newTestView(t, root, 30, 8)
259 press(v, tcell.KeyEnd) // onto b.go
260
261 removeFile(t, filepath.Join(root, "b.go"))
262 v.Refresh()
263
264 if got := v.Selected(); got == nil || got.Name() != "a.go" {
265 t.Errorf("after its file went the highlight is on %v, want the row that is left", got)
266 }
267}
268
269func TestAnUnfocusedTreeTakesNoKeys(t *testing.T) {
270 v := newTestView(t, makeTree(t, "a.go", "b.go"), 30, 8)
271 v.SetFocused(false)
272
273 if press(v, tcell.KeyDown) {
274 t.Error("an unfocused tree consumed a key")
275 }
276 if got := v.Selected().Name(); got != "a.go" {
277 t.Errorf("an unfocused tree moved its highlight onto %q", got)
278 }
279}
280
281func TestClickingARowSelectsItAndClickingAgainChoosesIt(t *testing.T) {
282 root := makeTree(t, "a.go", "b.go")
283 v := newTestView(t, root, 30, 8)
284
285 var opened []string
286 v.OnOpen = func(path string) { opened = append(opened, path) }
287
288 v.HandleMouse(clickAt(1, 1)) // the second row
289 if got := v.Selected().Name(); got != "b.go" {
290 t.Fatalf("clicking row 1 selected %q", got)
291 }
292 if len(opened) != 0 {
293 t.Error("the first click already opened the file")
294 }
295
296 v.HandleMouse(clickAt(1, 1))
297 if len(opened) != 1 || opened[0] != filepath.Join(root, "b.go") {
298 t.Errorf("the second click gave %v", opened)
299 }
300}
301
302func TestTheWheelScrollsTheHighlight(t *testing.T) {
303 v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go", "d.go", "e.go"), 30, 8)
304
305 v.HandleMouse(wheelAt(0, 0, tcell.WheelDown))
306 if got := v.Selected().Name(); got != "d.go" {
307 t.Errorf("a wheel notch moved onto %q, want three rows down", got)
308 }
309 v.HandleMouse(wheelAt(0, 0, tcell.WheelUp))
310 if got := v.Selected().Name(); got != "a.go" {
311 t.Errorf("a wheel notch back moved onto %q", got)
312 }
313}
314
315func TestAClickOutsideTheViewIsNotClaimed(t *testing.T) {
316 v := newTestView(t, makeTree(t, "a.go"), 30, 8)
317 v.SetBounds(ui.Rect{X: 5, Y: 5, W: 10, H: 4})
318
319 if v.HandleMouse(clickAt(0, 0)) {
320 t.Error("the view claimed a click that landed outside it")
321 }
322}
323
324func TestALongTreeScrollsToKeepTheHighlightVisible(t *testing.T) {
325 var files []string
326 for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
327 files = append(files, name+".go")
328 }
329 v := newTestView(t, makeTree(t, files...), 30, 3) // only three rows fit
330
331 press(v, tcell.KeyEnd)
332 rows := drawnRows(t, v)
333
334 if !strings.Contains(strings.Join(rows, "\n"), "h.go") {
335 t.Errorf("the highlighted row is not on screen:\n%s", strings.Join(rows, "\n"))
336 }
337}
338
339func TestTheHighlightIsVisibleInEveryShippedTheme(t *testing.T) {
340 // These keys exist because list.selected is coloured against a dialog, and
341 // on a window body it would be navy on navy. A selection nobody can see is
342 // the exact failure this guards.
343 const minimumDistance = 64
344
345 for _, name := range theme.Available("") {
346 t.Run(name, func(t *testing.T) {
347 th, err := theme.Load(name, "")
348 if err != nil {
349 t.Fatalf("Load(%q) error = %v", name, err)
350 }
351
352 _, text, _ := th.Style(theme.KeyTreeText).Decompose()
353 _, selected, _ := th.Style(theme.KeyTreeSelected).Decompose()
354 if got := channelDistance(text, selected); got < minimumDistance {
355 t.Errorf("tree.selected is %d channel values from tree.text, want at least %d",
356 got, minimumDistance)
357 }
358 })
359 }
360}
361
362// channelDistance returns how far apart two colours are on their furthest
363// channel, in 0-255 values.
364func channelDistance(a, b tcell.Color) int32 {
365 ar, ag, ab := a.RGB()
366 br, bg, bb := b.RGB()
367
368 distance := int32(0)
369 for _, pair := range [][2]int32{{ar, br}, {ag, bg}, {ab, bb}} {
370 if difference := max(pair[0]-pair[1], pair[1]-pair[0]); difference > distance {
371 distance = difference
372 }
373 }
374 return distance
375}
376
377// clickAt returns a left-button press at a position.
378func clickAt(x, y int) *tcell.EventMouse {
379 return tcell.NewEventMouse(x, y, tcell.Button1, tcell.ModNone)
380}
381
382// wheelAt returns a wheel event at a position.
383func wheelAt(x, y int, buttons tcell.ButtonMask) *tcell.EventMouse {
384 return tcell.NewEventMouse(x, y, buttons, tcell.ModNone)
385}
386
387// find2 returns the visible node with a name, for tests that need to reach
388// into the tree behind the view.
389func find2(t *testing.T, v *View, name string) *Node {
390 t.Helper()
391
392 for _, row := range v.tree.Rows() {
393 if row.Node.Name() == name {
394 return row.Node
395 }
396 }
397 t.Fatalf("no visible row is called %q", name)
398 return nil
399}