| 🛟 Updated. 28d5985 k33g 6h ago | 1 | package filetree |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // makeTree builds a directory from a list of paths; one ending in a slash |
| 11 | // becomes a directory, and intermediate directories are made as needed. |
| 12 | func makeTree(t *testing.T, paths ...string) string { |
| 13 | t.Helper() |
| 14 | |
| 15 | root := t.TempDir() |
| 16 | for _, path := range paths { |
| 17 | full := filepath.Join(root, strings.TrimSuffix(path, "/")) |
| 18 | if strings.HasSuffix(path, "/") { |
| 19 | if err := os.MkdirAll(full, 0o755); err != nil { |
| 20 | t.Fatalf("creating %s: %v", path, err) |
| 21 | } |
| 22 | continue |
| 23 | } |
| 24 | if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { |
| 25 | t.Fatalf("creating the parent of %s: %v", path, err) |
| 26 | } |
| 27 | if err := os.WriteFile(full, nil, 0o644); err != nil { |
| 28 | t.Fatalf("creating %s: %v", path, err) |
| 29 | } |
| 30 | } |
| 31 | return root |
| 32 | } |
| 33 | |
| 34 | // names renders the visible rows as " ".Depth + name, which reads like the |
| 35 | // tree does on screen and makes a failure legible. |
| 36 | func names(t *Tree) []string { |
| 37 | rows := t.Rows() |
| 38 | out := make([]string, len(rows)) |
| 39 | for i, row := range rows { |
| 40 | name := row.Node.Name() |
| 41 | if row.Node.IsDir() { |
| 42 | name += "/" |
| 43 | } |
| 44 | out[i] = strings.Repeat(" ", row.Depth) + name |
| 45 | } |
| 46 | return out |
| 47 | } |
| 48 | |
| 49 | // wantRows compares the visible rows against what they should be. |
| 50 | func wantRows(t *testing.T, tree *Tree, want ...string) { |
| 51 | t.Helper() |
| 52 | |
| 53 | got := names(tree) |
| 54 | if len(got) != len(want) { |
| 55 | t.Fatalf("the tree shows\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) |
| 56 | } |
| 57 | for i := range want { |
| 58 | if got[i] != want[i] { |
| 59 | t.Errorf("row %d is %q, want %q (whole tree:\n%s)", i, got[i], want[i], strings.Join(got, "\n")) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // find returns the node with a name, failing the test when there is none. |
| 65 | func find(t *testing.T, tree *Tree, name string) *Node { |
| 66 | t.Helper() |
| 67 | |
| 68 | for _, row := range tree.Rows() { |
| 69 | if row.Node.Name() == name { |
| 70 | return row.Node |
| 71 | } |
| 72 | } |
| 73 | t.Fatalf("no visible row is called %q; the tree shows:\n%s", name, strings.Join(names(tree), "\n")) |
| 74 | return nil |
| 75 | } |
| 76 | |
| 77 | // open returns a tree on a directory, failing the test if it cannot be read. |
| 78 | func open(t *testing.T, root string) *Tree { |
| 79 | t.Helper() |
| 80 | |
| 81 | tree, err := New(root) |
| 82 | if err != nil { |
| 83 | t.Fatalf("New(%q) error = %v", root, err) |
| 84 | } |
| 85 | return tree |
| 86 | } |
| 87 | |
| 88 | func TestANewTreeShowsTheRootsContents(t *testing.T) { |
| 89 | tree := open(t, makeTree(t, "main.go", "go.mod")) |
| 90 | |
| 91 | wantRows(t, tree, "go.mod", "main.go") |
| 92 | } |
| 93 | |
| 94 | func TestDirectoriesComeBeforeFilesEachSorted(t *testing.T) { |
| 95 | tree := open(t, makeTree(t, "zebra.go", "alpha.go", "ui/", "app/")) |
| 96 | |
| 97 | wantRows(t, tree, "app/", "ui/", "alpha.go", "zebra.go") |
| 98 | } |
| 99 | |
| 100 | func TestTheGitDirectoryIsHidden(t *testing.T) { |
| 101 | tree := open(t, makeTree(t, ".git/config", "main.go")) |
| 102 | |
| 103 | wantRows(t, tree, "main.go") |
| 104 | } |
| 105 | |
| 106 | func TestEveryOtherDotEntryIsShown(t *testing.T) { |
| 107 | // .turbo-go holds the project's own settings, which the editor encourages |
| 108 | // people to edit; a tree that hid it would make them unreachable from it. |
| 109 | tree := open(t, makeTree(t, ".turbo-go/settings.toml", ".gitignore", "main.go")) |
| 110 | |
| 111 | wantRows(t, tree, ".turbo-go/", ".gitignore", "main.go") |
| 112 | } |
| 113 | |
| 114 | func TestADirectoryIsNotReadUntilItIsExpanded(t *testing.T) { |
| 115 | tree := open(t, makeTree(t, "internal/app/app.go")) |
| 116 | |
| 117 | if got := find(t, tree, "internal").Children(); len(got) != 0 { |
| 118 | t.Errorf("a closed directory already holds %d children", len(got)) |
| 119 | } |
| 120 | wantRows(t, tree, "internal/") |
| 121 | } |
| 122 | |
| 123 | func TestExpandingADirectoryShowsItsContents(t *testing.T) { |
| 124 | tree := open(t, makeTree(t, "internal/app/app.go", "main.go")) |
| 125 | |
| 126 | find(t, tree, "internal").Expand() |
| 127 | |
| 128 | wantRows(t, tree, "internal/", " app/", "main.go") |
| 129 | } |
| 130 | |
| 131 | func TestExpandingNestsAsDeepAsItIsOpened(t *testing.T) { |
| 132 | tree := open(t, makeTree(t, "internal/app/app.go", "main.go")) |
| 133 | |
| 134 | find(t, tree, "internal").Expand() |
| 135 | find(t, tree, "app").Expand() |
| 136 | |
| 137 | wantRows(t, tree, "internal/", " app/", " app.go", "main.go") |
| 138 | } |
| 139 | |
| 140 | func TestCollapsingHidesTheContentsWithoutForgettingThem(t *testing.T) { |
| 141 | tree := open(t, makeTree(t, "internal/app.go")) |
| 142 | node := find(t, tree, "internal") |
| 143 | node.Expand() |
| 144 | |
| 145 | node.Collapse() |
| 146 | wantRows(t, tree, "internal/") |
| 147 | |
| 148 | if len(node.Children()) == 0 { |
| 149 | t.Error("collapsing threw away what had been read") |
| 150 | } |
| 151 | node.Expand() |
| 152 | wantRows(t, tree, "internal/", " app.go") |
| 153 | } |
| 154 | |
| 155 | func TestToggleOpensThenCloses(t *testing.T) { |
| 156 | tree := open(t, makeTree(t, "internal/app.go")) |
| 157 | node := find(t, tree, "internal") |
| 158 | |
| 159 | node.Toggle() |
| 160 | if !node.Expanded() { |
| 161 | t.Error("Toggle() did not open a closed directory") |
| 162 | } |
| 163 | node.Toggle() |
| 164 | if node.Expanded() { |
| 165 | t.Error("Toggle() did not close an open directory") |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestExpandingAFileDoesNothing(t *testing.T) { |
| 170 | tree := open(t, makeTree(t, "main.go")) |
| 171 | node := find(t, tree, "main.go") |
| 172 | |
| 173 | node.Expand() |
| 174 | |
| 175 | if node.Expanded() { |
| 176 | t.Error("a file reports itself as expanded") |
| 177 | } |
| 178 | wantRows(t, tree, "main.go") |
| 179 | } |
| 180 | |
| 181 | func TestANodeCarriesItsAbsolutePath(t *testing.T) { |
| 182 | root := makeTree(t, "internal/app.go") |
| 183 | tree := open(t, root) |
| 184 | find(t, tree, "internal").Expand() |
| 185 | |
| 186 | if got, want := find(t, tree, "app.go").Path(), filepath.Join(root, "internal", "app.go"); got != want { |
| 187 | t.Errorf("Path() = %q, want %q", got, want) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestTheRootIsNamedAfterItsDirectory(t *testing.T) { |
| 192 | root := makeTree(t, "main.go") |
| 193 | |
| 194 | tree := open(t, root) |
| 195 | |
| 196 | if got := tree.Root().Name(); got != filepath.Base(root) { |
| 197 | t.Errorf("Root().Name() = %q, want %q", got, filepath.Base(root)) |
| 198 | } |
| 199 | if !tree.Root().IsDir() { |
| 200 | t.Error("the root does not call itself a directory") |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestNewRefusesAFileAndAMissingPath(t *testing.T) { |
| 205 | root := makeTree(t, "main.go") |
| 206 | |
| 207 | if _, err := New(filepath.Join(root, "main.go")); err == nil { |
| 208 | t.Error("New() accepted a file as the root of a tree") |
| 209 | } |
| 210 | if _, err := New(filepath.Join(root, "nowhere")); err == nil { |
| 211 | t.Error("New() accepted a path that does not exist") |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestAnUnreadableDirectoryShowsAsEmptyRatherThanFailing(t *testing.T) { |
| 216 | if os.Geteuid() == 0 { |
| 217 | t.Skip("running as root, which can read a directory with no permissions") |
| 218 | } |
| 219 | root := makeTree(t, "locked/secret.go", "main.go") |
| 220 | locked := filepath.Join(root, "locked") |
| 221 | if err := os.Chmod(locked, 0o000); err != nil { |
| 222 | t.Fatalf("removing the permissions on %s: %v", locked, err) |
| 223 | } |
| 224 | t.Cleanup(func() { os.Chmod(locked, 0o755) }) |
| 225 | |
| 226 | tree := open(t, root) |
| 227 | find(t, tree, "locked").Expand() |
| 228 | |
| 229 | // One unreadable directory is not a reason to refuse the rest of the tree. |
| 230 | wantRows(t, tree, "locked/", "main.go") |
| 231 | } |
| 232 | |
| 233 | func TestRefreshShowsAFileMadeAfterTheTreeWasRead(t *testing.T) { |
| 234 | root := makeTree(t, "main.go") |
| 235 | tree := open(t, root) |
| 236 | |
| 237 | if err := os.WriteFile(filepath.Join(root, "other.go"), nil, 0o644); err != nil { |
| 238 | t.Fatalf("creating the new file: %v", err) |
| 239 | } |
| 240 | wantRows(t, tree, "main.go") // not yet |
| 241 | |
| 242 | tree.Refresh() |
| 243 | |
| 244 | wantRows(t, tree, "main.go", "other.go") |
| 245 | } |
| 246 | |
| 247 | func TestRefreshDropsAFileThatHasGone(t *testing.T) { |
| 248 | root := makeTree(t, "main.go", "other.go") |
| 249 | tree := open(t, root) |
| 250 | |
| 251 | if err := os.Remove(filepath.Join(root, "other.go")); err != nil { |
| 252 | t.Fatalf("removing the file: %v", err) |
| 253 | } |
| 254 | tree.Refresh() |
| 255 | |
| 256 | wantRows(t, tree, "main.go") |
| 257 | } |
| 258 | |
| 259 | func TestRefreshKeepsWhatWasOpenOpen(t *testing.T) { |
| 260 | root := makeTree(t, "internal/app/app.go", "main.go") |
| 261 | tree := open(t, root) |
| 262 | find(t, tree, "internal").Expand() |
| 263 | find(t, tree, "app").Expand() |
| 264 | |
| 265 | if err := os.WriteFile(filepath.Join(root, "internal", "app", "new.go"), nil, 0o644); err != nil { |
| 266 | t.Fatalf("creating the new file: %v", err) |
| 267 | } |
| 268 | tree.Refresh() |
| 269 | |
| 270 | // The whole shape survives, and the new file appears in the right branch. |
| 271 | wantRows(t, tree, "internal/", " app/", " app.go", " new.go", "main.go") |
| 272 | } |
| 273 | |
| 274 | func TestRefreshLeavesUnopenedDirectoriesUnread(t *testing.T) { |
| 275 | // Refreshing a large project must cost what is on screen, not a walk of |
| 276 | // everything under it. |
| 277 | root := makeTree(t, "internal/app/app.go") |
| 278 | tree := open(t, root) |
| 279 | |
| 280 | tree.Refresh() |
| 281 | |
| 282 | if got := find(t, tree, "internal").Children(); len(got) != 0 { |
| 283 | t.Errorf("refreshing read a directory nobody opened: %d children", len(got)) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestRefreshTakesAwayTheBranchOfADeletedDirectory(t *testing.T) { |
| 288 | root := makeTree(t, "internal/app/app.go", "main.go") |
| 289 | tree := open(t, root) |
| 290 | find(t, tree, "internal").Expand() |
| 291 | |
| 292 | if err := os.RemoveAll(filepath.Join(root, "internal")); err != nil { |
| 293 | t.Fatalf("removing the directory: %v", err) |
| 294 | } |
| 295 | tree.Refresh() |
| 296 | |
| 297 | wantRows(t, tree, "main.go") |
| 298 | } |
| 299 | |
| 300 | func TestAnEmptyProjectHasNoRows(t *testing.T) { |
| 301 | tree := open(t, t.TempDir()) |
| 302 | |
| 303 | if got := len(tree.Rows()); got != 0 { |
| 304 | t.Errorf("an empty project shows %d rows", got) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // writeFile creates an empty file, failing the test if it cannot. |
| 309 | func writeFile(t *testing.T, path string) { |
| 310 | t.Helper() |
| 311 | |
| 312 | if err := os.WriteFile(path, nil, 0o644); err != nil { |
| 313 | t.Fatalf("creating %s: %v", path, err) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // removeFile deletes a file, failing the test if it cannot. |
| 318 | func removeFile(t *testing.T, path string) { |
| 319 | t.Helper() |
| 320 | |
| 321 | if err := os.Remove(path); err != nil { |
| 322 | t.Fatalf("removing %s: %v", path, err) |
| 323 | } |
| 324 | } |