Archived
A click on a branch switches to it, the wheel moves the selection, a click outside closes the popup. The popup is hit-tested by its own position, as it is drawn after zone.Scan. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
171 lines
4.9 KiB
Go
171 lines
4.9 KiB
Go
package branches
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"submarine/internal/submodule"
|
|
"submarine/internal/tui/theme"
|
|
)
|
|
|
|
func list() []submodule.Branch {
|
|
return []submodule.Branch{
|
|
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaaaaa"},
|
|
{Ref: "refs/heads/dev", Name: "dev", Local: "dev", SHA: "bbbbbbbbbb"},
|
|
{Ref: "refs/remotes/origin/main", Name: "origin/main", Local: "main", Remote: true, SHA: "aaaaaaaaaa"},
|
|
{Ref: "refs/remotes/origin/feat/search", Name: "origin/feat/search", Local: "feat/search", Remote: true, SHA: "cccccccccc"},
|
|
}
|
|
}
|
|
|
|
func typed(m *Model, s string) {
|
|
for _, r := range s {
|
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
|
}
|
|
}
|
|
|
|
func name(m *Model) string {
|
|
if b := m.Selected(); b != nil {
|
|
return b.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func TestSelectionMovesAndClamps(t *testing.T) {
|
|
m := New(theme.Default(), "src/app")
|
|
m.SetBranches(list())
|
|
if name(m) != "main" {
|
|
t.Fatalf("first = %q", name(m))
|
|
}
|
|
m.Up()
|
|
if name(m) != "main" {
|
|
t.Errorf("up at the top = %q", name(m))
|
|
}
|
|
m.Down()
|
|
m.Down()
|
|
if name(m) != "origin/main" {
|
|
t.Errorf("down twice = %q", name(m))
|
|
}
|
|
m.PageDown()
|
|
if name(m) != "origin/feat/search" {
|
|
t.Errorf("page down = %q, want the last", name(m))
|
|
}
|
|
m.Down()
|
|
if name(m) != "origin/feat/search" {
|
|
t.Errorf("down at the end = %q", name(m))
|
|
}
|
|
}
|
|
|
|
func TestSearchFiltersAndAllWordsMustMatch(t *testing.T) {
|
|
m := New(theme.Default(), "src/app")
|
|
m.SetBranches(list())
|
|
typed(m, "ORIGIN")
|
|
if len(m.shown) != 2 {
|
|
t.Errorf("origin matches %d, want 2", len(m.shown))
|
|
}
|
|
typed(m, " sea")
|
|
if len(m.shown) != 1 || name(m) != "origin/feat/search" {
|
|
t.Errorf("shown = %d, selected %q", len(m.shown), name(m))
|
|
}
|
|
typed(m, "zzz")
|
|
if m.Selected() != nil {
|
|
t.Errorf("no match must select nothing, got %q", name(m))
|
|
}
|
|
if !strings.Contains(m.View(100, 30), "no branch matches") {
|
|
t.Error("view does not say that nothing matches")
|
|
}
|
|
}
|
|
|
|
func TestSelectionStaysOnTheSameBranchWhenItStillMatches(t *testing.T) {
|
|
m := New(theme.Default(), "src/app")
|
|
m.SetBranches(list())
|
|
m.Down()
|
|
m.Down() // origin/main
|
|
typed(m, "main")
|
|
if name(m) != "origin/main" {
|
|
t.Errorf("selected %q, want origin/main kept", name(m))
|
|
}
|
|
}
|
|
|
|
func TestViewStates(t *testing.T) {
|
|
m := New(theme.Default(), "src/app")
|
|
if v := m.View(100, 30); !strings.Contains(v, "reading branches") || !strings.Contains(v, "Switch src/app") {
|
|
t.Errorf("loading view:\n%s", v)
|
|
}
|
|
m.SetErr(errors.New("not initialized; restore it first"))
|
|
if v := m.View(100, 30); !strings.Contains(v, "not initialized") {
|
|
t.Errorf("error view:\n%s", v)
|
|
}
|
|
m.SetBranches(nil)
|
|
if v := m.View(100, 30); !strings.Contains(v, "no branches") {
|
|
t.Errorf("empty view:\n%s", v)
|
|
}
|
|
m.SetBranches(list())
|
|
v := m.View(100, 30)
|
|
for _, want := range []string{"main", "origin/", "feat/search", "aaaaaaa", "enter switch"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("view lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListScrollsInASmallTerminal(t *testing.T) {
|
|
var many []submodule.Branch
|
|
for i := 0; i < 30; i++ {
|
|
n := string(rune('a'+i%26)) + string(rune('a'+i/26))
|
|
many = append(many, submodule.Branch{Ref: "refs/heads/" + n, Name: n, Local: n, SHA: "abcdef0123"})
|
|
}
|
|
m := New(theme.Default(), "x")
|
|
m.SetBranches(many)
|
|
m.View(80, 14) // fewer rows than branches
|
|
for i := 0; i < 20; i++ {
|
|
m.Down()
|
|
}
|
|
if m.cursor < m.offset || m.cursor >= m.offset+m.rows {
|
|
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
|
|
}
|
|
}
|
|
|
|
func TestClickSelectsTheBranchUnderTheMouse(t *testing.T) {
|
|
m := New(theme.Default(), "src/app")
|
|
m.SetBranches(list())
|
|
m.View(100, 30)
|
|
x, y := m.Origin(100, 30)
|
|
|
|
if inside, hit := m.Click(x+3, y+listTop+1, 100, 30); !inside || !hit || name(m) != "dev" {
|
|
t.Errorf("click on the 2nd row: inside=%v hit=%v selected %q", inside, hit, name(m))
|
|
}
|
|
if inside, hit := m.Click(x+3, y+listTop+3, 100, 30); !inside || !hit || name(m) != "origin/feat/search" {
|
|
t.Errorf("click on the 4th row: inside=%v hit=%v selected %q", inside, hit, name(m))
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
x, y int
|
|
}{
|
|
{"title", x + 3, y + 1},
|
|
{"search line", x + 3, y + 2},
|
|
{"hint line", x + 3, y + m.h - 2},
|
|
{"left border", x, y + listTop},
|
|
} {
|
|
before := name(m)
|
|
if inside, hit := m.Click(c.x, c.y, 100, 30); !inside || hit || name(m) != before {
|
|
t.Errorf("click on the %s: inside=%v hit=%v selected %q", c.name, inside, hit, name(m))
|
|
}
|
|
}
|
|
if inside, hit := m.Click(0, 0, 100, 30); inside || hit {
|
|
t.Errorf("click outside: inside=%v hit=%v", inside, hit)
|
|
}
|
|
}
|
|
|
|
func TestClickBelowTheLastBranchHitsNothing(t *testing.T) {
|
|
m := New(theme.Default(), "x")
|
|
m.SetBranches(list()[:1]) // 1 branch, the list is padded to 3 rows
|
|
m.View(100, 30)
|
|
x, y := m.Origin(100, 30)
|
|
if inside, hit := m.Click(x+3, y+listTop+2, 100, 30); !inside || hit {
|
|
t.Errorf("inside=%v hit=%v on an empty row", inside, hit)
|
|
}
|
|
}
|