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 b205576759 Hard restore with X asks before dropping staged pins
X dropped staged pins without a question, including the selected
submodule's own pin, which the loss check didn't count. A staged pin is
a decision, and X sits one Shift away from x, so dropped staged pins now
count as a loss and make X ask (D31, amends D30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:26:32 +02:00

242 lines
6.5 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 TestHardRestoreAsksToDropStagedPins(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
// not which pin, but y/n: staged pins alone are a reason to ask
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); got != "2 staged pins" {
t.Errorf("question lists %q, want the staged pins", got)
}
m, cmd = press(m, "y")
feed(m, cmd)
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)
}
}