package filetree import ( "os" "path/filepath" "strings" "testing" ) // makeTree builds a directory from a list of paths; one ending in a slash // becomes a directory, and intermediate directories are made as needed. func makeTree(t *testing.T, paths ...string) string { t.Helper() root := t.TempDir() for _, path := range paths { full := filepath.Join(root, strings.TrimSuffix(path, "/")) if strings.HasSuffix(path, "/") { if err := os.MkdirAll(full, 0o755); err != nil { t.Fatalf("creating %s: %v", path, err) } continue } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("creating the parent of %s: %v", path, err) } if err := os.WriteFile(full, nil, 0o644); err != nil { t.Fatalf("creating %s: %v", path, err) } } return root } // names renders the visible rows as " ".Depth + name, which reads like the // tree does on screen and makes a failure legible. func names(t *Tree) []string { rows := t.Rows() out := make([]string, len(rows)) for i, row := range rows { name := row.Node.Name() if row.Node.IsDir() { name += "/" } out[i] = strings.Repeat(" ", row.Depth) + name } return out } // wantRows compares the visible rows against what they should be. func wantRows(t *testing.T, tree *Tree, want ...string) { t.Helper() got := names(tree) if len(got) != len(want) { t.Fatalf("the tree shows\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } for i := range want { if got[i] != want[i] { t.Errorf("row %d is %q, want %q (whole tree:\n%s)", i, got[i], want[i], strings.Join(got, "\n")) } } } // find returns the node with a name, failing the test when there is none. func find(t *testing.T, tree *Tree, name string) *Node { t.Helper() for _, row := range tree.Rows() { if row.Node.Name() == name { return row.Node } } t.Fatalf("no visible row is called %q; the tree shows:\n%s", name, strings.Join(names(tree), "\n")) return nil } // open returns a tree on a directory, failing the test if it cannot be read. func open(t *testing.T, root string) *Tree { t.Helper() tree, err := New(root) if err != nil { t.Fatalf("New(%q) error = %v", root, err) } return tree } func TestANewTreeShowsTheRootsContents(t *testing.T) { tree := open(t, makeTree(t, "main.go", "go.mod")) wantRows(t, tree, "go.mod", "main.go") } func TestDirectoriesComeBeforeFilesEachSorted(t *testing.T) { tree := open(t, makeTree(t, "zebra.go", "alpha.go", "ui/", "app/")) wantRows(t, tree, "app/", "ui/", "alpha.go", "zebra.go") } func TestTheGitDirectoryIsHidden(t *testing.T) { tree := open(t, makeTree(t, ".git/config", "main.go")) wantRows(t, tree, "main.go") } func TestEveryOtherDotEntryIsShown(t *testing.T) { // .turbo-go holds the project's own settings, which the editor encourages // people to edit; a tree that hid it would make them unreachable from it. tree := open(t, makeTree(t, ".turbo-go/settings.toml", ".gitignore", "main.go")) wantRows(t, tree, ".turbo-go/", ".gitignore", "main.go") } func TestADirectoryIsNotReadUntilItIsExpanded(t *testing.T) { tree := open(t, makeTree(t, "internal/app/app.go")) if got := find(t, tree, "internal").Children(); len(got) != 0 { t.Errorf("a closed directory already holds %d children", len(got)) } wantRows(t, tree, "internal/") } func TestExpandingADirectoryShowsItsContents(t *testing.T) { tree := open(t, makeTree(t, "internal/app/app.go", "main.go")) find(t, tree, "internal").Expand() wantRows(t, tree, "internal/", " app/", "main.go") } func TestExpandingNestsAsDeepAsItIsOpened(t *testing.T) { tree := open(t, makeTree(t, "internal/app/app.go", "main.go")) find(t, tree, "internal").Expand() find(t, tree, "app").Expand() wantRows(t, tree, "internal/", " app/", " app.go", "main.go") } func TestCollapsingHidesTheContentsWithoutForgettingThem(t *testing.T) { tree := open(t, makeTree(t, "internal/app.go")) node := find(t, tree, "internal") node.Expand() node.Collapse() wantRows(t, tree, "internal/") if len(node.Children()) == 0 { t.Error("collapsing threw away what had been read") } node.Expand() wantRows(t, tree, "internal/", " app.go") } func TestToggleOpensThenCloses(t *testing.T) { tree := open(t, makeTree(t, "internal/app.go")) node := find(t, tree, "internal") node.Toggle() if !node.Expanded() { t.Error("Toggle() did not open a closed directory") } node.Toggle() if node.Expanded() { t.Error("Toggle() did not close an open directory") } } func TestExpandingAFileDoesNothing(t *testing.T) { tree := open(t, makeTree(t, "main.go")) node := find(t, tree, "main.go") node.Expand() if node.Expanded() { t.Error("a file reports itself as expanded") } wantRows(t, tree, "main.go") } func TestANodeCarriesItsAbsolutePath(t *testing.T) { root := makeTree(t, "internal/app.go") tree := open(t, root) find(t, tree, "internal").Expand() if got, want := find(t, tree, "app.go").Path(), filepath.Join(root, "internal", "app.go"); got != want { t.Errorf("Path() = %q, want %q", got, want) } } func TestTheRootIsNamedAfterItsDirectory(t *testing.T) { root := makeTree(t, "main.go") tree := open(t, root) if got := tree.Root().Name(); got != filepath.Base(root) { t.Errorf("Root().Name() = %q, want %q", got, filepath.Base(root)) } if !tree.Root().IsDir() { t.Error("the root does not call itself a directory") } } func TestNewRefusesAFileAndAMissingPath(t *testing.T) { root := makeTree(t, "main.go") if _, err := New(filepath.Join(root, "main.go")); err == nil { t.Error("New() accepted a file as the root of a tree") } if _, err := New(filepath.Join(root, "nowhere")); err == nil { t.Error("New() accepted a path that does not exist") } } func TestAnUnreadableDirectoryShowsAsEmptyRatherThanFailing(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root, which can read a directory with no permissions") } root := makeTree(t, "locked/secret.go", "main.go") locked := filepath.Join(root, "locked") if err := os.Chmod(locked, 0o000); err != nil { t.Fatalf("removing the permissions on %s: %v", locked, err) } t.Cleanup(func() { os.Chmod(locked, 0o755) }) tree := open(t, root) find(t, tree, "locked").Expand() // One unreadable directory is not a reason to refuse the rest of the tree. wantRows(t, tree, "locked/", "main.go") } func TestRefreshShowsAFileMadeAfterTheTreeWasRead(t *testing.T) { root := makeTree(t, "main.go") tree := open(t, root) if err := os.WriteFile(filepath.Join(root, "other.go"), nil, 0o644); err != nil { t.Fatalf("creating the new file: %v", err) } wantRows(t, tree, "main.go") // not yet tree.Refresh() wantRows(t, tree, "main.go", "other.go") } func TestRefreshDropsAFileThatHasGone(t *testing.T) { root := makeTree(t, "main.go", "other.go") tree := open(t, root) if err := os.Remove(filepath.Join(root, "other.go")); err != nil { t.Fatalf("removing the file: %v", err) } tree.Refresh() wantRows(t, tree, "main.go") } func TestRefreshKeepsWhatWasOpenOpen(t *testing.T) { root := makeTree(t, "internal/app/app.go", "main.go") tree := open(t, root) find(t, tree, "internal").Expand() find(t, tree, "app").Expand() if err := os.WriteFile(filepath.Join(root, "internal", "app", "new.go"), nil, 0o644); err != nil { t.Fatalf("creating the new file: %v", err) } tree.Refresh() // The whole shape survives, and the new file appears in the right branch. wantRows(t, tree, "internal/", " app/", " app.go", " new.go", "main.go") } func TestRefreshLeavesUnopenedDirectoriesUnread(t *testing.T) { // Refreshing a large project must cost what is on screen, not a walk of // everything under it. root := makeTree(t, "internal/app/app.go") tree := open(t, root) tree.Refresh() if got := find(t, tree, "internal").Children(); len(got) != 0 { t.Errorf("refreshing read a directory nobody opened: %d children", len(got)) } } func TestRefreshTakesAwayTheBranchOfADeletedDirectory(t *testing.T) { root := makeTree(t, "internal/app/app.go", "main.go") tree := open(t, root) find(t, tree, "internal").Expand() if err := os.RemoveAll(filepath.Join(root, "internal")); err != nil { t.Fatalf("removing the directory: %v", err) } tree.Refresh() wantRows(t, tree, "main.go") } func TestAnEmptyProjectHasNoRows(t *testing.T) { tree := open(t, t.TempDir()) if got := len(tree.Rows()); got != 0 { t.Errorf("an empty project shows %d rows", got) } } // writeFile creates an empty file, failing the test if it cannot. func writeFile(t *testing.T, path string) { t.Helper() if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("creating %s: %v", path, err) } } // removeFile deletes a file, failing the test if it cannot. func removeFile(t *testing.T, path string) { t.Helper() if err := os.Remove(path); err != nil { t.Fatalf("removing %s: %v", path, err) } }