4f6e9c9991
- emit start and finish events from the concurrent sync worker pool - show elapsed time, a progress bar, active repositories, result counts, and the latest result - retain failures, skips, protected branches, and divergence warnings after the run - focus the first failed repository and add f to cycle through repositories requiring attention - add progress and summary tests, document the new controls, and set version 0.3.1
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)
|
|
}
|
|
}
|