This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/tui/restore_test.go
T
simonfrankenandClaude Opus 5.5 72500353cd Hard restore with X drops staged pins at every depth
X restores the selection to the committed pins without the staged or
committed question, and resets every restored repository's index to
HEAD, so staged pins below are dropped too. Dropped staged pins alone
don't make it ask; they are listed if something else is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:58:08 +02:00

235 lines
6.3 KiB
Go

package tui
import (
"context"
"os"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
"submarine/internal/submodule"
"submarine/internal/submodule/mock"
)
func TestMain(m *testing.M) {
zone.NewGlobal()
os.Exit(m.Run())
}
func TestLossText(t *testing.T) {
cases := []struct {
in submodule.Loss
want string
}{
{submodule.Loss{Files: 1, Modules: 1}, "1 changed file"},
{submodule.Loss{Files: 7, Modules: 3, Commits: 2, Repositories: 1}, "7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository"},
{submodule.Loss{Operations: 1, Modules: 1, Unknown: 2}, "1 operation in progress, 2 repositories of unknown state"},
{submodule.Loss{StagedPins: 2, Modules: 1}, "2 staged pins"},
}
for _, c := range cases {
if got := lossText(c.in); got != c.want {
t.Errorf("lossText(%+v) = %q, want %q", c.in, got, c.want)
}
}
}
// fakeRestorer is the mock data with a restore that records its calls.
type fakeRestorer struct {
submodule.Provider
loss submodule.Loss
restored []string
to []submodule.RestoreTarget
}
func (f *fakeRestorer) RestoreLoss(context.Context, string, submodule.RestoreTarget) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeRestorer) Restore(_ context.Context, path string, to submodule.RestoreTarget) error {
f.restored = append(f.restored, path)
f.to = append(f.to, to)
return nil
}
// run executes a command, flattening batches, and returns the messages.
func run(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if b, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range b {
if c != nil {
out = append(out, run(c)...)
}
}
return out
}
return []tea.Msg{msg}
}
func press(m tea.Model, k string) (tea.Model, tea.Cmd) {
return m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
}
// newRestoreModel returns a model with the mock tree loaded.
func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestorer) {
t.Helper()
f := &fakeRestorer{Provider: mock.New(), loss: loss}
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
}
// feed delivers the messages of cmd to m, once.
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
for _, msg := range run(cmd) {
switch msg.(type) {
case lossMsg, restoreDoneMsg, branchesMsg:
m, _ = m.Update(msg)
}
}
return m
}
func TestRestoreWithoutLossRunsAtOnce(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{})
m, cmd := press(m, "x")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
m = feed(m, next)
if len(f.restored) != 1 || f.restored[0] != "" {
t.Fatalf("restored %q, want the superproject once", f.restored)
}
if m.(Model).restore != nil {
t.Error("restore still in progress")
}
}
func TestRestoreAsksWhenSomethingIsLost(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{Files: 3, Modules: 1})
m, cmd := press(m, "x")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
if next != nil || m.(Model).restore == nil || m.(Model).restore.phase != restoreAsking {
t.Fatalf("want a question, got %+v", m.(Model).restore)
}
// any other key cancels
c, _ := press(m, "n")
if c.(Model).restore != nil || len(f.restored) != 0 {
t.Fatal("n must cancel without restoring")
}
// y restores
m, cmd = press(m, "y")
m = feed(m, cmd)
if len(f.restored) != 1 {
t.Fatalf("restored %q", f.restored)
}
}
func TestRestoreWithoutRestorerSaysSo(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, "x")
if m.(Model).restore != nil {
t.Fatal("the mock can't restore")
}
}
func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{})
mm := m.(Model)
var staged *submodule.Submodule
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
if staged == nil && s.Children == nil {
staged = s
}
})
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
mm.tree.Select(staged.Path)
if mm.tree.Selected() != staged {
t.Fatalf("setup: selected %v", mm.tree.Selected())
}
m = mm
m, _ = press(m, "x")
if m.(Model).restore == nil || m.(Model).restore.phase != restoreChoosing {
t.Fatalf("want the pin question, got %+v", m.(Model).restore)
}
m, cmd := press(m, "c")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
feed(m, next)
if len(f.to) != 1 || f.to[0] != submodule.RestoreCommitted || f.restored[0] != staged.Path {
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
}
}
func TestHardRestoreOfAStagedPinDoesNotAsk(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{StagedPins: 2, Modules: 2})
mm := m.(Model)
var staged *submodule.Submodule
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
if staged == nil && s.Children == nil {
staged = s
}
})
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
mm.tree.Select(staged.Path)
m = mm
// neither which pin, nor y/n for staged pins alone
m, cmd := press(m, "X")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
feed(m, next)
if len(f.to) != 1 || f.to[0] != submodule.RestoreHard || f.restored[0] != staged.Path {
t.Fatalf("restored %q to %v, want %s hard", f.restored, f.to, staged.Path)
}
}
func TestHardRestoreAsksWhenWorkIsLost(t *testing.T) {
m, _ := newRestoreModel(t, submodule.Loss{Files: 1, StagedPins: 1, Modules: 1})
m, cmd := press(m, "X")
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, _ = m.Update(l)
}
}
r := m.(Model).restore
if r == nil || r.phase != restoreAsking {
t.Fatalf("want a question, got %+v", r)
}
if got := lossText(r.loss); !strings.Contains(got, "1 staged pin") {
t.Errorf("question %q doesn't list the staged pin", got)
}
}