package app import ( "os" "path/filepath" "strings" "testing" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/ui" ) // testDirectory builds a directory holding the given entries; a name ending in // a slash becomes a subdirectory. func testDirectory(t *testing.T, names ...string) string { t.Helper() root := t.TempDir() for _, name := range names { path := filepath.Join(root, strings.TrimSuffix(name, "/")) var err error if strings.HasSuffix(name, "/") { err = os.Mkdir(path, 0o755) } else { err = os.WriteFile(path, nil, 0o644) } if err != nil { t.Fatalf("creating %s: %v", name, err) } } return root } func TestTheFileDialogListsDirectoriesFirst(t *testing.T) { root := testDirectory(t, "zeta.go", "alpha/", "beta.go", ".hidden") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) want := []string{".." + string(filepath.Separator), "alpha" + string(filepath.Separator), "beta.go", "zeta.go"} got := dialog.list.Items() if len(got) != len(want) { t.Fatalf("the list holds %v, want %v", got, want) } for i, name := range want { if got[i] != name { t.Errorf("entry %d is %q, want %q", i, got[i], name) } } } func TestTheFileDialogHidesDotFiles(t *testing.T) { root := testDirectory(t, ".git/", ".env", "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) for _, entry := range dialog.list.Items() { if strings.HasPrefix(entry, ".") && entry != ".."+string(filepath.Separator) { t.Errorf("the list shows %q, want dot entries hidden", entry) } } } func TestTheFileDialogOpensBesideTheGivenFile(t *testing.T) { root := testDirectory(t, "main.go") path := filepath.Join(root, "main.go") dialog := NewFileDialog("Save as", path, ui.Rect{W: 80, H: 24}) if dialog.directory != root { t.Errorf("the dialog opened in %q, want %q", dialog.directory, root) } if got := dialog.name.Text(); got != "main.go" { t.Errorf("the name field holds %q, want the file's name", got) } if got := dialog.Path(); got != path { t.Errorf("Path() = %q, want %q", got, path) } } func TestTheFileDialogOpensInADirectoryGivenDirectly(t *testing.T) { root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) if dialog.directory != root { t.Errorf("the dialog opened in %q, want %q", dialog.directory, root) } if got := dialog.name.Text(); got != "" { t.Errorf("the name field holds %q, want it empty", got) } } func TestTheFileDialogPathJoinsTheDirectoryAndTheName(t *testing.T) { root := testDirectory(t) dialog := NewFileDialog("Save as", root, ui.Rect{W: 80, H: 24}) dialog.name.SetText("new.go") if got := dialog.Path(); got != filepath.Join(root, "new.go") { t.Errorf("Path() = %q", got) } } func TestTheFileDialogAcceptsAnAbsolutePath(t *testing.T) { dialog := NewFileDialog("Open", testDirectory(t), ui.Rect{W: 80, H: 24}) dialog.name.SetText("/etc/hosts") if got := dialog.Path(); got != filepath.Clean("/etc/hosts") { t.Errorf("Path() = %q, want the absolute path used as it is", got) } } func TestAnEmptyNameGivesNoPath(t *testing.T) { dialog := NewFileDialog("Open", testDirectory(t), ui.Rect{W: 80, H: 24}) dialog.name.SetText(" ") if got := dialog.Path(); got != "" { t.Errorf("Path() = %q, want nothing for a blank name", got) } } func TestChoosingADirectoryBrowsesIntoIt(t *testing.T) { root := testDirectory(t, "sub/") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "sub/" dialog.choose() if dialog.directory != filepath.Join(root, "sub") { t.Errorf("the dialog is in %q, want it inside sub", dialog.directory) } if dialog.Dialog().Done() { t.Error("browsing into a directory closed the dialog") } } func TestChoosingAFileClosesTheDialog(t *testing.T) { root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "main.go" dialog.choose() if !dialog.Dialog().Done() { t.Fatal("choosing a file did not close the dialog") } if got := dialog.Path(); got != filepath.Join(root, "main.go") { t.Errorf("Path() = %q", got) } } func TestConfirmingADirectoryNameBrowsesRatherThanClosing(t *testing.T) { root := testDirectory(t, "sub/") dialog := NewFileDialog("Save as", root, ui.Rect{W: 80, H: 24}) dialog.name.SetText("sub") dialog.confirm() if dialog.Dialog().Done() { t.Error("the dialog closed on a directory name") } if dialog.directory != filepath.Join(root, "sub") { t.Errorf("the dialog is in %q, want it inside sub", dialog.directory) } } func TestAnUnreadableDirectoryStillOffersTheParent(t *testing.T) { dialog := NewFileDialog("Open", filepath.Join(t.TempDir(), "does-not-exist"), ui.Rect{W: 80, H: 24}) items := dialog.list.Items() if len(items) != 1 || items[0] != ".."+string(filepath.Separator) { t.Errorf("the list holds %v, want just the parent entry", items) } } func TestTheDialogTitleShowsTheDirectory(t *testing.T) { root := testDirectory(t) dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) if !strings.HasPrefix(dialog.Dialog().Title(), "Open") { t.Errorf("the title is %q, want it to start with the dialog's name", dialog.Dialog().Title()) } if !strings.Contains(dialog.Dialog().Title(), filepath.Base(root)) { t.Errorf("the title is %q, want the directory in it", dialog.Dialog().Title()) } } func TestShortenPath(t *testing.T) { tests := []struct { name string path string width int want string }{ {"already short enough", "/tmp/x", 20, "/tmp/x"}, {"trimmed from the left", "/a/very/long/path/indeed", 10, "…th/indeed"}, {"a width too small to trim to", "/a/b/c", 2, "/a/b/c"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := shortenPath(tc.path, tc.width) if len([]rune(got)) > max(tc.width, len([]rune(tc.path))) { t.Errorf("shortenPath() = %q, which is longer than %d", got, tc.width) } if tc.want != "" && got != tc.want { t.Errorf("shortenPath() = %q, want %q", got, tc.want) } }) } } func TestTheConfirmDialogOffersThreeAnswers(t *testing.T) { dialog := NewConfirmDialog("Close", "Save changes?", ui.Rect{W: 80, H: 24}) if got := len(dialog.Controls()); got != 4 { t.Errorf("the dialog holds %d controls, want a label and three buttons", got) } dialog.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'n', tcell.ModAlt)) if got := dialog.Result(); got != ui.ResultNo { t.Errorf("Result() = %v, want ResultNo", got) } } func TestTheMessageDialogShowsEveryLine(t *testing.T) { dialog := NewMessageDialog("Help", "first\nsecond\nthird", ui.Rect{W: 80, H: 24}) if got := len(dialog.Controls()); got != 4 { t.Errorf("the dialog holds %d controls, want three labels and a button", got) } if got := dialog.Bounds().H; got != 9 { t.Errorf("the dialog is %d rows tall, want it to grow with the message", got) } } func TestThePromptDialogReturnsWhatWasTyped(t *testing.T) { dialog, field := NewPromptDialog("Go to line", "Line:", "12", ui.Rect{W: 80, H: 24}) dialog.HandleKey(tcell.NewEventKey(tcell.KeyRune, '3', tcell.ModNone)) if got := field.Text(); got != "123" { t.Errorf("the field holds %q, want %q", got, "123") } } func TestTheFindDialogCarriesItsOptions(t *testing.T) { dialog := NewFindDialog("func", true, ui.Rect{W: 80, H: 24}) if got := dialog.Needle(); got != "func" { t.Errorf("Needle() = %q", got) } if !dialog.MatchCase() { t.Error("MatchCase() = false, want the option carried over") } } func TestTheChoiceDialogStartsOnTheCurrentItem(t *testing.T) { _, list := NewChoiceDialog("Theme", []string{"a", "b", "c"}, 2, ui.Rect{W: 80, H: 24}) if got := list.Selected(); got != 2 { t.Errorf("Selected() = %d, want 2", got) } } func TestChoosingFromAChoiceDialogClosesIt(t *testing.T) { dialog, list := NewChoiceDialog("Theme", []string{"a", "b"}, 0, ui.Rect{W: 80, H: 24}) list.Choose() if !dialog.Done() || dialog.Result() != ui.ResultOK { t.Errorf("Result() = %v, want the dialog accepted", dialog.Result()) } } func TestSelectingAFileInTheListFillsTheNameField(t *testing.T) { root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "main.go" if got := dialog.name.Text(); got != "main.go" { t.Errorf("the name field holds %q, want the highlighted entry", got) } } func TestSelectingADirectoryFillsTheFieldWithoutItsSlash(t *testing.T) { root := testDirectory(t, "sub/") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "sub/" if got := dialog.name.Text(); got != "sub" { t.Errorf("the name field holds %q, want sub without the separator", got) } } func TestSelectingTheParentEntryClearsTheNameField(t *testing.T) { // ".." is not a name anyone would type into a field labelled "Name:". root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) dialog.list.Select(0) // "../" if got := dialog.name.Text(); got != "" { t.Errorf("the name field holds %q, want it cleared", got) } } func TestOKActsOnTheHighlightedFile(t *testing.T) { // The bug this covers: OK did nothing at all on a freshly opened dialog, // because highlighting a file never reached the name field and Path() was // therefore empty. root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "main.go" dialog.confirm() // what the OK button does if !dialog.Dialog().Done() { t.Fatal("OK did nothing with a file highlighted in the list") } if got := dialog.Path(); got != filepath.Join(root, "main.go") { t.Errorf("Path() = %q, want the highlighted file", got) } } func TestOKOnAnUntouchedDialogBrowsesUpRatherThanDoingNothing(t *testing.T) { // Nothing has been highlighted, so the field is empty and the highlight is // still on the parent entry. OK must act on that rather than appear dead. root := testDirectory(t, "main.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.confirm() if dialog.Dialog().Done() { t.Fatal("OK closed the dialog with no file chosen") } if dialog.directory != filepath.Dir(root) { t.Errorf("the dialog is in %q, want the parent of %q", dialog.directory, root) } } func TestOKOnAHighlightedDirectoryBrowsesIntoIt(t *testing.T) { root := testDirectory(t, "sub/") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "sub/" dialog.confirm() if dialog.Dialog().Done() { t.Error("OK on a directory closed the dialog instead of browsing into it") } if dialog.directory != filepath.Join(root, "sub") { t.Errorf("the dialog is in %q, want it inside sub", dialog.directory) } } func TestBrowsingIntoADirectoryClearsTheNameField(t *testing.T) { // Refilling the list moves the highlight back to the parent entry, and the // field follows it — the name from the directory just left must not linger. root := testDirectory(t, "sub/") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) dialog.confirm() // browses into sub if got := dialog.name.Text(); got != "" { t.Errorf("the name field still holds %q after entering a directory", got) } } func TestATypedNameWinsOverTheHighlight(t *testing.T) { // Someone who typed a name meant that name, whatever the list is showing. root := testDirectory(t, "main.go", "other.go") dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.list.Select(1) // "main.go", which fills the field dialog.name.SetText("typed.go") dialog.confirm() if got := dialog.Path(); got != filepath.Join(root, "typed.go") { t.Errorf("Path() = %q, want the name that was typed", got) } } func TestSaveAsKeepsTheNameItOpenedWith(t *testing.T) { // Building the dialog must not let the list's initial highlight overwrite // the file name Save As put in the field. root := testDirectory(t, "other.go") dialog := NewFileDialog("Save as", filepath.Join(root, "main.go"), ui.Rect{W: 80, H: 24}) if got := dialog.name.Text(); got != "main.go" { t.Errorf("the name field holds %q, want main.go", got) } } func TestOKWithAnEmptyDirectoryListingDoesNotCrash(t *testing.T) { root := t.TempDir() dialog := NewFileDialog("Open", root, ui.Rect{W: 80, H: 24}) dialog.confirm() // only "../" is listed if dialog.directory != filepath.Dir(root) { t.Errorf("the dialog is in %q, want the parent", dialog.directory) } } // findOnScreen returns the screen position of a piece of text, so a test can // click it the way a user would rather than calling the action behind it. // // The column is counted in runes. strings.Index would give a byte offset, and // a drawn row is full of ░ and ║ at three bytes each — clicking a byte offset // lands about thirty columns to the right of the thing you meant. func findOnScreen(t *testing.T, lines []string, text string) (x, y int) { t.Helper() for row, line := range lines { at := strings.Index(line, text) if at < 0 { continue } return len([]rune(line[:at])), row } t.Fatalf("%q is not on the screen:\n%s", text, strings.Join(lines, "\n")) return 0, 0 } func TestClickingOKInTheOpenDialogOpensTheHighlightedFile(t *testing.T) { a, screen := newTestApp(t) root := testDirectory(t, "main.go") t.Chdir(root) a.OpenFile() // The dialog opens with the focus on the Name field, so the first Down // moves it to the list and the second highlights the first real entry. press(a, tcell.KeyDown, 0, tcell.ModNone) press(a, tcell.KeyDown, 0, tcell.ModNone) lines := render(t, a, screen) x, y := findOnScreen(t, lines, "OK") click(a, x, y) if a.Modals() != 0 { t.Fatalf("Modals() = %d; clicking OK left the dialog up", a.Modals()) } if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the file opened", a.Desktop().Count()) } if got := a.Desktop().Active().Title(); got != "main.go" { t.Errorf("the window is called %q, want main.go", got) } } func TestTabbingToOKAndPressingEnterOpensTheHighlightedFile(t *testing.T) { a, _ := newTestApp(t) root := testDirectory(t, "main.go") t.Chdir(root) a.OpenFile() press(a, tcell.KeyDown, 0, tcell.ModNone) // focus the list press(a, tcell.KeyDown, 0, tcell.ModNone) // highlight main.go press(a, tcell.KeyTab, 0, tcell.ModNone) // move the focus off the list press(a, tcell.KeyEnter, 0, tcell.ModNone) if a.Modals() != 0 { t.Fatalf("Modals() = %d; the dialog is still up", a.Modals()) } if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the file opened", a.Desktop().Count()) } if got := a.Desktop().Active().Title(); got != "main.go" { t.Errorf("the window is called %q, want main.go", got) } } func TestPressingAltOInTheOpenDialogOpensTheHighlightedFile(t *testing.T) { a, _ := newTestApp(t) root := testDirectory(t, "main.go") t.Chdir(root) a.OpenFile() press(a, tcell.KeyDown, 0, tcell.ModNone) // focus the list press(a, tcell.KeyDown, 0, tcell.ModNone) // highlight main.go press(a, tcell.KeyRune, 'o', tcell.ModAlt) // the OK button's hot key if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the file opened", a.Desktop().Count()) } }