package filetree import ( "path/filepath" "strings" "testing" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/theme" "rickub.com/turbo-editors/turbo-core/ui" ) // newTestView returns a focused view on a project, sized to fit. func newTestView(t *testing.T, root string, width, height int) *View { t.Helper() v, err := NewView(root) if err != nil { t.Fatalf("NewView(%q) error = %v", root, err) } v.SetFocused(true) v.SetBounds(ui.Rect{W: width, H: height}) return v } // drawnRows paints a view onto a simulated screen and returns what it shows, // trimmed of the scroll bar and trailing blanks. func drawnRows(t *testing.T, v *View) []string { t.Helper() screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatalf("initialising the simulation screen: %v", err) } t.Cleanup(screen.Fini) bounds := v.Bounds() screen.SetSize(bounds.W, bounds.H) v.Draw(ui.NewPainter(screen), theme.Default("")) screen.Show() cells, width, height := screen.GetContents() out := make([]string, height) for row := range height { var text strings.Builder for col := range width - 1 { // the last column is the scroll bar runes := cells[row*width+col].Runes if len(runes) == 0 { text.WriteRune(' ') continue } text.WriteRune(runes[0]) } out[row] = strings.TrimRight(text.String(), " ") } return out } // press sends a key to the view. func press(v *View, key tcell.Key) bool { return v.HandleKey(tcell.NewEventKey(key, 0, tcell.ModNone)) } func TestTheViewDrawsMarkersAndIndentation(t *testing.T) { v := newTestView(t, makeTree(t, "internal/app/app.go", "main.go"), 30, 8) find2(t, v, "internal").Expand() rows := drawnRows(t, v) want := []string{"▼ internal", " ▶ app", " main.go"} for i, line := range want { if rows[i] != line { t.Errorf("row %d is %q, want %q (whole view:\n%s)", i, rows[i], line, strings.Join(rows, "\n")) } } } func TestTheTitleIsTheProjectDirectory(t *testing.T) { root := makeTree(t, "main.go") v := newTestView(t, root, 30, 8) if got := v.Title(); got != filepath.Base(root) { t.Errorf("Title() = %q, want %q", got, filepath.Base(root)) } if got := v.Root(); got != root { t.Errorf("Root() = %q, want %q", got, root) } } func TestTheArrowsWalkTheRows(t *testing.T) { v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go"), 30, 8) press(v, tcell.KeyDown) press(v, tcell.KeyDown) if got := v.Selected().Name(); got != "c.go" { t.Errorf("after two downs the highlight is on %q, want c.go", got) } press(v, tcell.KeyUp) if got := v.Selected().Name(); got != "b.go" { t.Errorf("after an up the highlight is on %q, want b.go", got) } } func TestTheArrowsStopAtEitherEnd(t *testing.T) { v := newTestView(t, makeTree(t, "a.go", "b.go"), 30, 8) for range 5 { press(v, tcell.KeyUp) } if got := v.Selected().Name(); got != "a.go" { t.Errorf("the highlight ran off the top onto %q", got) } for range 5 { press(v, tcell.KeyDown) } if got := v.Selected().Name(); got != "b.go" { t.Errorf("the highlight ran off the bottom onto %q", got) } } func TestHomeAndEndGoToTheEnds(t *testing.T) { v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go"), 30, 8) press(v, tcell.KeyEnd) if got := v.Selected().Name(); got != "c.go" { t.Errorf("End put the highlight on %q", got) } press(v, tcell.KeyHome) if got := v.Selected().Name(); got != "a.go" { t.Errorf("Home put the highlight on %q", got) } } func TestRightOpensAClosedDirectoryAndThenStepsIntoIt(t *testing.T) { v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8) press(v, tcell.KeyRight) if !v.Selected().Expanded() { t.Fatal("right did not open the directory") } press(v, tcell.KeyRight) if got := v.Selected().Name(); got != "app.go" { t.Errorf("a second right put the highlight on %q, want app.go", got) } } func TestLeftClosesAnOpenDirectoryAndThenStepsOut(t *testing.T) { v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8) press(v, tcell.KeyRight) // open internal press(v, tcell.KeyRight) // step onto app.go press(v, tcell.KeyLeft) if got := v.Selected().Name(); got != "internal" { t.Fatalf("left from a file put the highlight on %q, want its directory", got) } press(v, tcell.KeyLeft) if v.Selected().Expanded() { t.Error("a second left did not close the directory") } } func TestChoosingAFileHandsItsPathOver(t *testing.T) { root := makeTree(t, "main.go") v := newTestView(t, root, 30, 8) var opened []string v.OnOpen = func(path string) { opened = append(opened, path) } press(v, tcell.KeyEnter) if len(opened) != 1 || opened[0] != filepath.Join(root, "main.go") { t.Errorf("OnOpen saw %v, want the one file's absolute path", opened) } } func TestChoosingADirectoryOpensItRatherThanTheFile(t *testing.T) { v := newTestView(t, makeTree(t, "internal/app.go"), 30, 8) opened := 0 v.OnOpen = func(string) { opened++ } press(v, tcell.KeyEnter) if opened != 0 { t.Error("choosing a directory was reported as opening a file") } if !v.Selected().Expanded() { t.Error("choosing a directory did not open it") } } func TestChoosingWithNoOnOpenDoesNotPanic(t *testing.T) { v := newTestView(t, makeTree(t, "main.go"), 30, 8) press(v, tcell.KeyEnter) // nobody is listening } func TestAnEmptyProjectHandlesKeysWithoutPanicking(t *testing.T) { v := newTestView(t, t.TempDir(), 30, 8) for _, key := range []tcell.Key{tcell.KeyDown, tcell.KeyUp, tcell.KeyLeft, tcell.KeyRight, tcell.KeyEnter, tcell.KeyEnd} { press(v, key) } if v.Selected() != nil { t.Error("an empty project reported a highlighted node") } } func TestCollapsingABranchKeepsTheHighlightOnARow(t *testing.T) { // The highlight was below the branch that just closed, so it would // otherwise be left pointing past the last row. v := newTestView(t, makeTree(t, "internal/a.go", "internal/b.go", "internal/c.go"), 30, 8) press(v, tcell.KeyRight) // open internal press(v, tcell.KeyEnd) // onto c.go, the last row v.Selected() // c.go press(v, tcell.KeyHome) press(v, tcell.KeyLeft) // close internal if got := v.Selected().Name(); got != "internal" { t.Errorf("the highlight is on %q after collapsing", got) } } func TestF5AndCtrlRRefreshTheTree(t *testing.T) { for _, key := range []tcell.Key{tcell.KeyF5, tcell.KeyCtrlR} { t.Run(tcell.KeyNames[key], func(t *testing.T) { root := makeTree(t, "main.go") v := newTestView(t, root, 30, 8) writeFile(t, filepath.Join(root, "other.go")) press(v, key) if len(v.tree.Rows()) != 2 { t.Errorf("the tree shows %d rows after a refresh, want 2", len(v.tree.Rows())) } }) } } func TestRefreshKeepsTheHighlightOnTheSameFile(t *testing.T) { root := makeTree(t, "b.go", "c.go") v := newTestView(t, root, 30, 8) press(v, tcell.KeyDown) // onto c.go writeFile(t, filepath.Join(root, "a.go")) // sorts before both v.Refresh() if got := v.Selected().Name(); got != "c.go" { t.Errorf("the highlight moved to %q; a new file above it must not carry it along", got) } } func TestRefreshKeepsTheHighlightInRangeWhenItsFileHasGone(t *testing.T) { root := makeTree(t, "a.go", "b.go") v := newTestView(t, root, 30, 8) press(v, tcell.KeyEnd) // onto b.go removeFile(t, filepath.Join(root, "b.go")) v.Refresh() if got := v.Selected(); got == nil || got.Name() != "a.go" { t.Errorf("after its file went the highlight is on %v, want the row that is left", got) } } func TestAnUnfocusedTreeTakesNoKeys(t *testing.T) { v := newTestView(t, makeTree(t, "a.go", "b.go"), 30, 8) v.SetFocused(false) if press(v, tcell.KeyDown) { t.Error("an unfocused tree consumed a key") } if got := v.Selected().Name(); got != "a.go" { t.Errorf("an unfocused tree moved its highlight onto %q", got) } } func TestClickingARowSelectsItAndClickingAgainChoosesIt(t *testing.T) { root := makeTree(t, "a.go", "b.go") v := newTestView(t, root, 30, 8) var opened []string v.OnOpen = func(path string) { opened = append(opened, path) } v.HandleMouse(clickAt(1, 1)) // the second row if got := v.Selected().Name(); got != "b.go" { t.Fatalf("clicking row 1 selected %q", got) } if len(opened) != 0 { t.Error("the first click already opened the file") } v.HandleMouse(clickAt(1, 1)) if len(opened) != 1 || opened[0] != filepath.Join(root, "b.go") { t.Errorf("the second click gave %v", opened) } } func TestTheWheelScrollsTheHighlight(t *testing.T) { v := newTestView(t, makeTree(t, "a.go", "b.go", "c.go", "d.go", "e.go"), 30, 8) v.HandleMouse(wheelAt(0, 0, tcell.WheelDown)) if got := v.Selected().Name(); got != "d.go" { t.Errorf("a wheel notch moved onto %q, want three rows down", got) } v.HandleMouse(wheelAt(0, 0, tcell.WheelUp)) if got := v.Selected().Name(); got != "a.go" { t.Errorf("a wheel notch back moved onto %q", got) } } func TestAClickOutsideTheViewIsNotClaimed(t *testing.T) { v := newTestView(t, makeTree(t, "a.go"), 30, 8) v.SetBounds(ui.Rect{X: 5, Y: 5, W: 10, H: 4}) if v.HandleMouse(clickAt(0, 0)) { t.Error("the view claimed a click that landed outside it") } } func TestALongTreeScrollsToKeepTheHighlightVisible(t *testing.T) { var files []string for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { files = append(files, name+".go") } v := newTestView(t, makeTree(t, files...), 30, 3) // only three rows fit press(v, tcell.KeyEnd) rows := drawnRows(t, v) if !strings.Contains(strings.Join(rows, "\n"), "h.go") { t.Errorf("the highlighted row is not on screen:\n%s", strings.Join(rows, "\n")) } } func TestTheHighlightIsVisibleInEveryShippedTheme(t *testing.T) { // These keys exist because list.selected is coloured against a dialog, and // on a window body it would be navy on navy. A selection nobody can see is // the exact failure this guards. const minimumDistance = 64 for _, name := range theme.Available("") { t.Run(name, func(t *testing.T) { th, err := theme.Load(name, "") if err != nil { t.Fatalf("Load(%q) error = %v", name, err) } _, text, _ := th.Style(theme.KeyTreeText).Decompose() _, selected, _ := th.Style(theme.KeyTreeSelected).Decompose() if got := channelDistance(text, selected); got < minimumDistance { t.Errorf("tree.selected is %d channel values from tree.text, want at least %d", got, minimumDistance) } }) } } // channelDistance returns how far apart two colours are on their furthest // channel, in 0-255 values. func channelDistance(a, b tcell.Color) int32 { ar, ag, ab := a.RGB() br, bg, bb := b.RGB() distance := int32(0) for _, pair := range [][2]int32{{ar, br}, {ag, bg}, {ab, bb}} { if difference := max(pair[0]-pair[1], pair[1]-pair[0]); difference > distance { distance = difference } } return distance } // clickAt returns a left-button press at a position. func clickAt(x, y int) *tcell.EventMouse { return tcell.NewEventMouse(x, y, tcell.Button1, tcell.ModNone) } // wheelAt returns a wheel event at a position. func wheelAt(x, y int, buttons tcell.ButtonMask) *tcell.EventMouse { return tcell.NewEventMouse(x, y, buttons, tcell.ModNone) } // find2 returns the visible node with a name, for tests that need to reach // into the tree behind the view. func find2(t *testing.T, v *View, name string) *Node { t.Helper() for _, row := range v.tree.Rows() { if row.Node.Name() == name { return row.Node } } t.Fatalf("no visible row is called %q", name) return nil }