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>
225 lines
6.6 KiB
Go
225 lines
6.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"submarine/internal/submodule"
|
|
"submarine/internal/submodule/mock"
|
|
)
|
|
|
|
// fakeSwitcher is the mock data with a branch switch that records its calls.
|
|
type fakeSwitcher struct {
|
|
submodule.Provider
|
|
loss submodule.Loss
|
|
branches []submodule.Branch
|
|
switched []string // path + " " + ref
|
|
ends string // the branch Switch reports
|
|
}
|
|
|
|
func (f *fakeSwitcher) Branches(context.Context, string) ([]submodule.Branch, error) {
|
|
return f.branches, nil
|
|
}
|
|
|
|
func (f *fakeSwitcher) SwitchLoss(context.Context, string, submodule.Branch) (submodule.Loss, error) {
|
|
return f.loss, nil
|
|
}
|
|
|
|
func (f *fakeSwitcher) Switch(_ context.Context, path string, b submodule.Branch) (string, error) {
|
|
f.switched = append(f.switched, path+" "+b.Ref)
|
|
return f.ends, nil
|
|
}
|
|
|
|
func newSwitchModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeSwitcher) {
|
|
t.Helper()
|
|
f := &fakeSwitcher{
|
|
Provider: mock.New(),
|
|
loss: loss,
|
|
ends: "feat",
|
|
branches: []submodule.Branch{
|
|
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaa"},
|
|
{Ref: "refs/remotes/origin/feat", Name: "origin/feat", Local: "feat", Remote: true, SHA: "bbbbbbb"},
|
|
},
|
|
}
|
|
var m tea.Model = New(context.Background(), f)
|
|
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
|
repo, err := f.Tree(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
|
return m, f
|
|
}
|
|
|
|
// openPicker presses b and delivers the branch list.
|
|
func openPicker(t *testing.T, m tea.Model) tea.Model {
|
|
t.Helper()
|
|
m, cmd := press(m, "b")
|
|
m = feed(m, cmd)
|
|
if m.(Model).picker == nil {
|
|
t.Fatal("the picker did not open")
|
|
}
|
|
return m
|
|
}
|
|
|
|
func pressKey(m tea.Model, t tea.KeyType) (tea.Model, tea.Cmd) {
|
|
return m.Update(tea.KeyMsg{Type: t})
|
|
}
|
|
|
|
// settle delivers the loss check and, if it ran at once, the switch.
|
|
func settle(m tea.Model, cmd tea.Cmd) tea.Model {
|
|
var next tea.Cmd
|
|
for _, msg := range run(cmd) {
|
|
if l, ok := msg.(lossMsg); ok {
|
|
m, next = m.Update(l)
|
|
}
|
|
}
|
|
return feed(m, next)
|
|
}
|
|
|
|
func TestSwitchOpensThePickerWithTheBranches(t *testing.T) {
|
|
m, _ := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
v := m.View()
|
|
for _, want := range []string{"Switch the superproject", "main", "origin/", "feat"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("view lacks %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSwitchTypingFiltersAndEnterSwitchesWithoutLoss(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
m, _ = press(m, "fe") // j and k would be text, not navigation
|
|
m, cmd := pressKey(m, tea.KeyEnter)
|
|
if m.(Model).picker != nil {
|
|
t.Error("enter must close the picker")
|
|
}
|
|
m = settle(m, cmd)
|
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
|
t.Fatalf("switched %q", f.switched)
|
|
}
|
|
if m.(Model).restore != nil {
|
|
t.Error("switch still in progress")
|
|
}
|
|
}
|
|
|
|
func TestSwitchArrowsSelect(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
m, _ = pressKey(m, tea.KeyDown)
|
|
m, cmd := pressKey(m, tea.KeyEnter)
|
|
settle(m, cmd)
|
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
|
t.Fatalf("switched %q, want the second branch", f.switched)
|
|
}
|
|
}
|
|
|
|
func TestSwitchAsksWhenSomethingIsLost(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{Files: 2, Modules: 1})
|
|
m = openPicker(t, m)
|
|
m, cmd := pressKey(m, tea.KeyEnter)
|
|
var next tea.Cmd
|
|
for _, msg := range run(cmd) {
|
|
if l, ok := msg.(lossMsg); ok {
|
|
m, next = m.Update(l)
|
|
}
|
|
}
|
|
r := m.(Model).restore
|
|
if next != nil || r == nil || r.phase != restoreAsking {
|
|
t.Fatalf("want a question, got %+v", r)
|
|
}
|
|
if v := m.View(); !strings.Contains(v, "Switch the superproject to main?") || !strings.Contains(v, "y switch") {
|
|
t.Errorf("question not shown:\n%s", v)
|
|
}
|
|
if c, _ := press(m, "n"); c.(Model).restore != nil || len(f.switched) != 0 {
|
|
t.Fatal("n must cancel")
|
|
}
|
|
m, cmd = press(m, "y")
|
|
feed(m, cmd)
|
|
if len(f.switched) != 1 {
|
|
t.Fatalf("switched %q", f.switched)
|
|
}
|
|
}
|
|
|
|
func TestSwitchEscClosesThePicker(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
m, _ = pressKey(m, tea.KeyEsc)
|
|
if m.(Model).picker != nil || len(f.switched) != 0 {
|
|
t.Fatal("esc must close without switching")
|
|
}
|
|
}
|
|
|
|
func TestSwitchWithoutSwitcherSaysSo(t *testing.T) {
|
|
var m tea.Model = New(context.Background(), mock.New())
|
|
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
|
repo, _ := mock.New().Tree(context.Background())
|
|
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
|
m, _ = press(m, "b")
|
|
if m.(Model).picker != nil {
|
|
t.Fatal("the mock can't switch")
|
|
}
|
|
if !strings.Contains(m.View(), "can't switch branches") {
|
|
t.Error("no flash")
|
|
}
|
|
}
|
|
|
|
func TestSwitchReportsADetachedHead(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
f.ends = ""
|
|
m = openPicker(t, m)
|
|
m, _ = pressKey(m, tea.KeyDown)
|
|
m, cmd := pressKey(m, tea.KeyEnter)
|
|
m = settle(m, cmd)
|
|
if v := m.View(); !strings.Contains(v, "detached") {
|
|
t.Errorf("flash lacks the detached note:\n%s", v)
|
|
}
|
|
}
|
|
|
|
// clickRow clicks the i-th visible branch of the open picker.
|
|
func clickRow(m tea.Model, i int) (tea.Model, tea.Cmd) {
|
|
mm := m.(Model)
|
|
x, y := mm.picker.Origin(mm.width, mm.height)
|
|
return m.Update(tea.MouseMsg{X: x + 3, Y: y + 4 + i, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
|
}
|
|
|
|
func TestSwitchClickOnABranchSwitches(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
m.View() // the popup learns where it is drawn
|
|
m, cmd := clickRow(m, 1)
|
|
if m.(Model).picker != nil {
|
|
t.Error("the click must close the picker")
|
|
}
|
|
settle(m, cmd)
|
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
|
t.Fatalf("switched %q, want the clicked branch", f.switched)
|
|
}
|
|
}
|
|
|
|
func TestSwitchClickOutsideClosesAndWheelMoves(t *testing.T) {
|
|
m, f := newSwitchModel(t, submodule.Loss{})
|
|
m = openPicker(t, m)
|
|
m.View()
|
|
m, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
|
|
if b := m.(Model).picker.Selected(); b == nil || b.Name != "origin/feat" {
|
|
t.Errorf("wheel down selected %v", b)
|
|
}
|
|
// a click that releases or sits inside the popup's frame does nothing
|
|
mm := m.(Model)
|
|
x, y := mm.picker.Origin(mm.width, mm.height)
|
|
m, _ = m.Update(tea.MouseMsg{X: x + 3, Y: y + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
|
if m.(Model).picker == nil {
|
|
t.Fatal("a click on the title must keep the popup open")
|
|
}
|
|
m, _ = m.Update(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
|
if m.(Model).picker != nil || len(f.switched) != 0 {
|
|
t.Fatal("a click outside must close without switching")
|
|
}
|
|
}
|