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) } }