8e86b7eadb
Show live repository progress, elapsed time, active work, and recent outcomes during long syncs. Add a persistent post-run summary that prioritizes failures, reports protected and divergent branches, focuses the first issue, and lets users cycle through attention items with f.
157 lines
5.6 KiB
Go
157 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
func testTUIRepos() []repo {
|
|
return []repo{
|
|
{Path: "/workspace/clean", RelativePath: "clean", Branch: "main", Eligible: true},
|
|
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", Dirty: []string{" M file.txt"}, Eligible: true},
|
|
{Path: "/workspace/blocked", RelativePath: "blocked", BlockReason: "detached HEAD"},
|
|
}
|
|
}
|
|
|
|
func TestTUIOnlySelectsEligibleRepositories(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
if got := len(m.selectedRepos()); got != 2 {
|
|
t.Fatalf("selected repositories = %d, want 2", got)
|
|
}
|
|
|
|
m.cursor = 2
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
m = updated.(tuiModel)
|
|
if got := len(m.selectedRepos()); got != 2 {
|
|
t.Fatalf("blocked repository became selectable; selected = %d", got)
|
|
}
|
|
}
|
|
|
|
func TestTUIRequiresExplicitConfirmation(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
m = updated.(tuiModel)
|
|
if m.mode != modeConfirm || cmd != nil {
|
|
t.Fatalf("sync should enter confirmation without starting command: mode=%v cmd=%v", m.mode, cmd)
|
|
}
|
|
|
|
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
|
m = updated.(tuiModel)
|
|
if m.mode != modeReady || cmd != nil || !strings.Contains(m.status, "cancelled") {
|
|
t.Fatalf("cancel should return to ready state: %+v", m)
|
|
}
|
|
}
|
|
|
|
func TestTUIViewExplainsBlockedRepository(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.width, m.height, m.cursor = 100, 30, 2
|
|
view := m.View()
|
|
for _, want := range []string{"STACK SYNC", "detached HEAD", "1 blocked"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTUIViewExplainsDirtyBranchProtection(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.width, m.height, m.cursor = 100, 30, 1
|
|
view := m.View()
|
|
for _, want := range []string{"ready (dirty)", "CHECKED-OUT BRANCH PROTECTED", "file.txt"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTUIViewShowsLastSyncOutputForFocusedRepository(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.width, m.height = 100, 30
|
|
m.lastResult = []syncResult{{Path: "clean", Success: true, Message: "Updated branch main", Duration: "120ms"}}
|
|
view := m.View()
|
|
for _, want := range []string{"LAST SYNC", "120ms", "Updated branch main"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTUISyncProgressShowsLiveCountsAndLatestResult(t *testing.T) {
|
|
events := make(chan tea.Msg, 4)
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.width, m.height = 100, 30
|
|
m.mode = modeSyncing
|
|
m.syncEvents = events
|
|
m.syncTotal = 3
|
|
m.syncActive = make(map[string]bool)
|
|
m.syncStarted = time.Now().Add(-5 * time.Second)
|
|
|
|
updated, wait := m.Update(syncStartedMsg{path: "clean"})
|
|
m = updated.(tuiModel)
|
|
if wait == nil || !m.syncActive["clean"] {
|
|
t.Fatalf("start event did not register an active repository: %+v", m.syncActive)
|
|
}
|
|
updated, wait = m.Update(syncFinishedMsg{result: syncResult{Path: "clean", Success: true, Message: "Already up to date."}})
|
|
m = updated.(tuiModel)
|
|
if wait == nil || m.syncDone != 1 || m.syncOK != 1 || m.syncActive["clean"] {
|
|
t.Fatalf("finish event did not update progress: done=%d ok=%d active=%+v", m.syncDone, m.syncOK, m.syncActive)
|
|
}
|
|
view := m.View()
|
|
for _, want := range []string{"1/3", "1 synced", "0 failed", "Latest", "clean", "up to date"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("progress view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTUICompletionSummarizesAndFocusesFirstIssue(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.width, m.height = 100, 30
|
|
m.mode = modeSyncing
|
|
results := []syncResult{
|
|
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
|
|
{Path: "dirty", Message: "fetch origin: authentication failed"},
|
|
}
|
|
|
|
updated, _ := m.Update(syncDoneMsg{results: results, repos: testTUIRepos()})
|
|
m = updated.(tuiModel)
|
|
if m.mode != modeReady || m.cursor != 1 || !strings.Contains(m.status, "focused first issue") {
|
|
t.Fatalf("completion did not focus the first issue: mode=%v cursor=%d status=%q", m.mode, m.cursor, m.status)
|
|
}
|
|
view := m.View()
|
|
for _, want := range []string{"LAST RUN", "1 synced", "1 failed", "dirty", "authentication failed", "f next"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("completion view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTUIFocusNextIssueCyclesThroughAttentionResults(t *testing.T) {
|
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
m.lastResult = []syncResult{
|
|
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
|
|
{Path: "dirty", Message: "fetch failed"},
|
|
}
|
|
m.cursor = 0
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
|
|
m = updated.(tuiModel)
|
|
if got := m.repos[m.cursor].RelativePath; got != "dirty" {
|
|
t.Fatalf("focused repository = %q, want dirty", got)
|
|
}
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
|
|
m = updated.(tuiModel)
|
|
if got := m.repos[m.cursor].RelativePath; got != "clean" {
|
|
t.Fatalf("focused repository = %q, want clean after cycling", got)
|
|
}
|
|
}
|
|
|
|
func TestTruncateMiddle(t *testing.T) {
|
|
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
|
|
t.Fatalf("truncateMiddle() = %q", got)
|
|
}
|
|
}
|