package main import ( "context" "errors" "fmt" "os" "slices" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/muesli/termenv" ) type recordingService struct { owner string repo string number int writeThreadID string writeBody string writeResolved bool } type recordingPRService struct { recordingService updateID string update PullRequestMetadata updateErr error branches []RepositoryBranch branchErr error users []RepositoryUser userErr error people PullRequestPeopleUpdate peopleErr error } type recordingMergeService struct { recordingService id, head, method string enabled bool } func (s *recordingMergeService) SetPullRequestAutoMerge( _ context.Context, id, head, method string, enabled bool, ) (*AutoMergeRequest, error) { s.id, s.head, s.method, s.enabled = id, head, method, enabled if !enabled { return nil, nil } return &AutoMergeRequest{MergeMethod: method, EnabledBy: "me", EnabledAt: time.Now()}, nil } func (s *recordingMergeService) MergePullRequest( _ context.Context, id, head, method string, ) (PullRequestMergeResult, error) { s.id, s.head, s.method = id, head, method return PullRequestMergeResult{Merged: true, MergedAt: time.Now()}, nil } func (s *recordingPRService) UpdatePullRequest( _ context.Context, id string, update PullRequestMetadata, ) (PullRequestMetadata, error) { s.updateID, s.update = id, update if s.updateErr != nil { return PullRequestMetadata{}, s.updateErr } update.Mergeable = "UNKNOWN" update.MergeState = "UNKNOWN" update.UpdatedAt = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) return update, nil } func (s *recordingPRService) ListBranches( _ context.Context, _, _ string, ) ([]RepositoryBranch, error) { return append([]RepositoryBranch(nil), s.branches...), s.branchErr } func (s *recordingPRService) ListRepositoryUsers( _ context.Context, _, _ string, ) ([]RepositoryUser, error) { return append([]RepositoryUser(nil), s.users...), s.userErr } func (s *recordingPRService) UpdatePullRequestPeople( _ context.Context, _, _ string, _ int, update PullRequestPeopleUpdate, ) (PullRequestPeople, error) { s.people = update if s.peopleErr != nil { return PullRequestPeople{}, s.peopleErr } return PullRequestPeople{ Reviewers: append([]string(nil), update.Reviewers...), Assignees: append([]string(nil), update.Assignees...), }, nil } func (s *recordingService) SetThreadResolved( _ context.Context, threadID string, resolved bool, ) (ReviewThread, error) { s.writeThreadID, s.writeResolved = threadID, resolved return ReviewThread{ ID: threadID, IsResolved: resolved, ViewerCanResolve: !resolved, ViewerCanUnresolve: resolved, ViewerCanReply: true, }, nil } func (s *recordingService) ReplyToThread( _ context.Context, threadID, body string, ) (ReviewComment, error) { s.writeThreadID, s.writeBody = threadID, body return ReviewComment{ID: "new-comment", Author: "me", Body: body}, nil } func (s *recordingService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) { return nil, nil } func (s *recordingService) GetPullRequest(_ context.Context, owner, repo string, number int) (PRDetails, error) { s.owner, s.repo, s.number = owner, repo, number return PRDetails{PullRequest: PullRequest{ Owner: owner, Repository: repo, RepoWithOwner: owner + "/" + repo, Number: number, }}, nil } func TestResolvedThreadsStartFolded(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10) m.screen = threadScreen m.details = PRDetails{PullRequest: PullRequest{Number: 7}} updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{ PullRequest: PullRequest{Number: 7}, Threads: []ReviewThread{{ID: "open"}, {ID: "done", IsResolved: true}}, }}) got := updated.(App) if got.folded["open"] { t.Fatal("open thread was folded") } if !got.folded["done"] { t.Fatal("resolved thread was not folded") } } func TestResolvedThreadFoldingCanBeDisabled(t *testing.T) { settings := defaultAppSettings() settings.FoldResolved = false m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.screen = threadScreen m.details = PRDetails{PullRequest: PullRequest{Number: 7}} updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{ PullRequest: PullRequest{Number: 7}, Threads: []ReviewThread{{ID: "done", IsResolved: true}}, }}) if updated.(App).folded["done"] { t.Fatal("resolved thread was folded despite configuration") } } func TestResolvedThreadIconTakesPrecedenceOverOutdated(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.details = PRDetails{Threads: []ReviewThread{{ ID: "done", Path: "main.go", Line: 12, IsResolved: true, IsOutdated: true, }}} rendered := ansi.Strip(m.threadList(48, 10)) if !strings.Contains(rendered, "✓ main.go") { t.Fatalf("resolved and outdated thread did not use check mark:\n%s", rendered) } if strings.Contains(rendered, "○ main.go") { t.Fatalf("outdated icon took precedence over resolved:\n%s", rendered) } } func TestSelectionSurvivesRefresh(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10) m.screen = threadScreen m.details = PRDetails{ PullRequest: PullRequest{Number: 7}, Threads: []ReviewThread{{ID: "a"}, {ID: "b"}}, } m.threadIndex = 1 updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{ PullRequest: PullRequest{Number: 7}, Threads: []ReviewThread{{ID: "new"}, {ID: "a"}, {ID: "b"}}, }}) if got := updated.(App).threadIndex; got != 2 { t.Fatalf("thread index = %d, want 2", got) } } func TestDefaultThreadOrderingUsesStatusThenFile(t *testing.T) { threads := []ReviewThread{ {ID: "resolved-outdated", Path: "a.go", IsResolved: true, IsOutdated: true}, {ID: "outdated", Path: "a.go", IsOutdated: true}, {ID: "current-z", Path: "z.go"}, {ID: "resolved", Path: "z.go", IsResolved: true}, {ID: "current-a", Path: "a.go"}, } settings := defaultAppSettings() sortReviewThreads(threads, settings.ThreadStatusOrder, settings.ThreadWithinStatus) got := make([]string, len(threads)) for i, thread := range threads { got[i] = thread.ID } want := []string{"current-a", "current-z", "outdated", "resolved-outdated", "resolved"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("thread order = %v, want %v", got, want) } } func TestCustomThreadOrderingUsesOpeningTimestamp(t *testing.T) { base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) threads := []ReviewThread{ {ID: "current", Path: "a.go", Comments: []ReviewComment{{CreatedAt: base}}}, {ID: "resolved-new", IsResolved: true, Path: "a.go", Comments: []ReviewComment{{CreatedAt: base.Add(2 * time.Hour)}}}, {ID: "outdated", IsOutdated: true, Path: "a.go", Comments: []ReviewComment{{CreatedAt: base.Add(time.Hour)}}}, {ID: "resolved-old", IsResolved: true, Path: "z.go", Comments: []ReviewComment{ {CreatedAt: base.Add(3 * time.Hour)}, {CreatedAt: base.Add(-time.Hour)}, }}, } sortReviewThreads( threads, []string{"resolved", "outdated", "unresolved"}, "timestamp", ) got := make([]string, len(threads)) for i, thread := range threads { got[i] = thread.ID } want := []string{"resolved-old", "resolved-new", "outdated", "current"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("thread order = %v, want %v", got, want) } } func TestPaneFocusAndNavigation(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.width, m.height = 100, 12 m.details = PRDetails{ PullRequest: PullRequest{Number: 7}, Threads: []ReviewThread{ {ID: "a", Path: "a.go", Comments: []ReviewComment{{Body: strings.Repeat("word ", 100)}}}, {ID: "b", Path: "b.go"}, }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) m = updated.(App) if m.threadIndex != 1 { t.Fatalf("thread index = %d", m.threadIndex) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) m = updated.(App) if m.screen != threadScreen || m.focus != threadListPane { t.Fatal("h should focus the list without leaving the thread screen") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) m = updated.(App) if m.focus != threadDetailPane { t.Fatal("l did not focus the detail pane") } } func TestOpeningPullRequestShowsDashboardBeforeThreads(t *testing.T) { settings := defaultAppSettings() settings.DashboardMode = "intermediate" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.prs = []PullRequest{{ ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, }} updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(App) if m.screen != dashboardScreen || m.details.Number != 9 || command == nil { t.Fatalf("opening PR produced screen=%d number=%d command=%v", m.screen, m.details.Number, command) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(App) if m.screen != threadScreen { t.Fatal("enter did not open review threads from dashboard") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) m = updated.(App) if m.screen != dashboardScreen { t.Fatal("b did not return from threads to dashboard") } } func TestDashboardHotkeyModeIsDefault(t *testing.T) { if got := defaultAppSettings().DashboardMode; got != "hotkey" { t.Fatalf("default dashboard mode = %q, want hotkey", got) } if got := defaultConfig().Display.DashboardMode; got != "hotkey" { t.Fatalf("default config dashboard mode = %q, want hotkey", got) } } func TestHotkeyDashboardModeOpensThreadsDirectly(t *testing.T) { settings := defaultAppSettings() settings.DashboardMode = "hotkey" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.prs = []PullRequest{{ ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, }} updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(App) if m.screen != threadScreen || command == nil { t.Fatalf("hotkey mode opened screen=%d command=%v", m.screen, command) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) m = updated.(App) if m.screen != dashboardScreen || m.dashboardReturn != threadScreen { t.Fatal("d did not open a dashboard that returns to threads") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) m = updated.(App) if m.screen != threadScreen { t.Fatal("dashboard did not return to the invoking thread screen") } } func TestDashboardHotkeyWorksFromPicker(t *testing.T) { settings := defaultAppSettings() settings.DashboardMode = "hotkey" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.prs = []PullRequest{{ ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, }} updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) m = updated.(App) if m.screen != dashboardScreen || m.dashboardReturn != prScreen || command == nil { t.Fatalf("picker dashboard hotkey produced screen=%d return=%d", m.screen, m.dashboardReturn) } } func TestDashboardRendersDescriptionAndMetadata(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = dashboardScreen m.width, m.height = 100, 40 m.details = PRDetails{ PullRequest: PullRequest{ RepoWithOwner: "owner/repo", Number: 9, Title: "Improve dashboard", Author: "alice", URL: "https://github.com/owner/repo/pull/9", UpdatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), }, Body: "> Existing behavior\n\nUse `new_behavior` instead.", CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), BaseRef: "main", HeadRef: "feature", ReviewDecision: "REVIEW_REQUIRED", CheckState: "SUCCESS", MergeState: "CLEAN", Assignees: []string{"bob"}, Reviewers: []Reviewer{{Login: "carol", State: "APPROVED"}}, Labels: []string{"ui", "review"}, Milestone: "v2", Additions: 20, Deletions: 4, ChangedFiles: 3, CommitCount: 2, CommentCount: 5, HeadOID: "0123456789abcdef", Checks: []Check{{Name: "unit tests", State: "SUCCESS", URL: "https://checks/1"}}, Reviews: []ReviewSummary{{ ID: "review", Author: "dave", State: "APPROVED", Body: "Looks **good**.", SubmittedAt: time.Date(2026, 7, 1, 11, 0, 0, 0, time.UTC), }}, Conversation: []PRComment{{ ID: "comment", Author: "erin", Body: "Please retain `compatibility`.", CreatedAt: time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC), }}, Permissions: ViewerPermissions{Repository: "WRITE", CanUpdatePR: true, CanResolveAny: true}, Requirements: MergeRequirements{ ApprovalsRequired: 1, RequiresApprovals: true, RequiresStatusChecks: true, RequiresConversation: true, }, Threads: []ReviewThread{ {ID: "open"}, {ID: "old", IsOutdated: true}, {ID: "done", IsResolved: true, IsOutdated: true}, }, } plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n")) for _, wanted := range []string{ "Improve dashboard", "@alice", "feature → main", "@bob", "@carol", "ui, review", "v2", "+20", "-4", "3 files", "2 commits", "1 open", "1 outdated", "1 resolved", "Description", "Existing behavior", "new_behavior", "unit tests", "Submitted reviews", "@dave", "Looks good", "Conversation", "@erin", "compatibility", "1 approval", "status checks", "resolved conversations", "write, update, resolve", "0123456", } { if !strings.Contains(plain, wanted) { t.Fatalf("dashboard is missing %q:\n%s", wanted, plain) } } } func TestDashboardShowsConflictStateAndFiles(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = dashboardScreen, 100, 40 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Conflict"}, BaseRef: "main", HeadRef: "feature", Mergeable: "CONFLICTING", ConflictFiles: []string{"src/one.go", "docs/two.md"}, } plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n")) for _, wanted := range []string{ "conflicts", "2 conflicting files", "Conflicting files (2)", "src/one.go", "docs/two.md", } { if !strings.Contains(plain, wanted) { t.Fatalf("conflict dashboard is missing %q:\n%s", wanted, plain) } } m.details.Mergeable = "MERGEABLE" m.details.ConflictFiles = nil plain = ansi.Strip(strings.Join(m.dashboardLines(), "\n")) if !strings.Contains(plain, "conflicts") || !strings.Contains(plain, "none") || strings.Contains(plain, "Conflicting files (") { t.Fatalf("clean dashboard conflict state is unclear:\n%s", plain) } } func TestDashboardWrapsConflictScanWarning(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = dashboardScreen, 48, 30 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Conflict"}, BaseRef: "main", HeadRef: "feature", Mergeable: "CONFLICTING", ConflictFileError: "fetch merge inputs: fatal: could not read Username for 'https://github.com': terminal prompts disabled", } lines := dashboardMetadataLines("conflicts", conflictStateText(m.details), m.width-2) plain := ansi.Strip(strings.Join(lines, "\n")) normalized := strings.Join(strings.Fields(plain), " ") if !strings.Contains(normalized, "file scan unavailable") || !strings.Contains(normalized, "terminal prompts disabled") { t.Fatalf("wrapped conflict warning lost information:\n%s", plain) } for index, line := range lines { if width := ansi.StringWidth(line); width > m.width-2 { t.Fatalf("dashboard line %d width = %d:\n%s", index, width, plain) } } } func TestDashboardCompactsSubmittedReviewsByDefault(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 100 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"}, BaseRef: "main", Reviews: []ReviewSummary{ { Author: "alice", State: "COMMENTED", Body: "First line\n\nSecond **line**", SubmittedAt: time.Date(2099, 1, 2, 3, 4, 0, 0, time.UTC), CommitOID: "abcdef0123456789", }, {Author: "bob", State: "APPROVED", Body: "Ship it."}, }, } lines := m.dashboardLines() plain := make([]string, len(lines)) for index, line := range lines { plain[index] = ansi.Strip(line) } alice := slices.IndexFunc(plain, func(line string) bool { return strings.Contains(line, "@alice") && strings.Contains(line, "First line Second line") }) bob := slices.IndexFunc(plain, func(line string) bool { return strings.Contains(line, "@bob") && strings.Contains(line, "Ship it.") }) if alice < 0 || bob != alice+1 { t.Fatalf("compact reviews are not adjacent one-line entries:\n%s", strings.Join(plain, "\n")) } joined := strings.Join(plain, "\n") if strings.Contains(joined, "2099-01-02") || strings.Contains(joined, "abcdef0") { t.Fatalf("compact reviews include timestamp or commit SHA:\n%s", joined) } } func TestDashboardAggregatesBodylessSubmittedReviews(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 100 reviews := make([]ReviewSummary, 0, 48) for range 40 { reviews = append(reviews, ReviewSummary{Author: "mhoff", State: "COMMENTED"}) } for range 8 { reviews = append(reviews, ReviewSummary{Author: "Pablu23", State: "COMMENTED"}) } m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"}, BaseRef: "main", Reviews: reviews, } plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n")) for _, wanted := range []string{"Submitted reviews (48)", "COMMENTED ×48", "@mhoff ×40", "@Pablu23 ×8"} { if !strings.Contains(plain, wanted) { t.Fatalf("compact review aggregate is missing %q:\n%s", wanted, plain) } } if strings.Count(plain, "@mhoff") != 1 || strings.Count(plain, "@Pablu23") != 1 || strings.Count(plain, "COMMENTED") != 1 { t.Fatalf("body-less reviews were rendered individually:\n%s", plain) } } func TestDashboardCanExpandSubmittedReviews(t *testing.T) { settings := defaultAppSettings() settings.CompactReviews = false m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.width = 100 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"}, BaseRef: "main", Reviews: []ReviewSummary{{ Author: "alice", State: "COMMENTED", Body: "First line\n\nSecond line", SubmittedAt: time.Date(2099, 1, 2, 3, 4, 0, 0, time.UTC), CommitOID: "abcdef0123456789", }}, } plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n")) if !strings.Contains(plain, "2099-01-02") || !strings.Contains(plain, "abcdef0") || !strings.Contains(plain, "First line") || !strings.Contains(plain, "Second line") { t.Fatalf("expanded review metadata or body missing:\n%s", plain) } } func TestThreadFilterCombinesPathStatusAuthorAndUpdates(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.details.Threads = []ReviewThread{ {ID: "match", Path: "src/generic_adder/rule.py", Comments: []ReviewComment{{Author: "Alice"}}}, {ID: "wrong-status", Path: "src/generic_adder/rule.py", IsResolved: true, Comments: []ReviewComment{{Author: "Alice"}}}, {ID: "wrong-author", Path: "src/generic_adder/rule.py", Comments: []ReviewComment{{Author: "Bob"}}}, } m.updatedThreads["match"] = true m.searchQuery = "generic rule status:open author:ali updated:true" if got := m.matchingThreadIndices(); !slices.Equal(got, []int{0}) { t.Fatalf("combined filter matches = %v", got) } } func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.screen, m.width, m.height = threadScreen, 80, 6 m.listHidden = true m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{ {ID: "first", Author: "a", Body: "one"}, {ID: "anchor", Author: "b", Body: "two"}, }}}, } for index, line := range m.detailLines(m.width) { if line.anchor == "comment:anchor:header" { m.scroll = index break } } refreshed := m.details refreshed.Threads = append([]ReviewThread(nil), m.details.Threads...) refreshed.Threads[0].Comments = append([]ReviewComment{{ID: "inserted", Author: "c", Body: "new"}}, refreshed.Threads[0].Comments...) updated, _ := m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: refreshed}) m = updated.(App) if got := m.detailScrollAnchor(); got != "comment:anchor:header" { t.Fatalf("detail anchor after refresh = %q", got) } } func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) { cached := writeCapabilities(PRDetails{FromCache: true}, nil) if cached[0].reason != "saved snapshot did not grant this thread permission" || cached[0].enabled { t.Fatalf("cached capability = %#v", cached[0]) } cachedAllowed := writeCapabilities(PRDetails{ PullRequest: PullRequest{FromCache: true}, Permissions: ViewerPermissions{CanUpdatePR: true}, }, &ReviewThread{ViewerCanReply: true, ViewerCanResolve: true}) if !cachedAllowed[0].enabled || !cachedAllowed[1].enabled || !cachedAllowed[3].enabled || cachedAllowed[4].enabled || cachedAllowed[5].enabled { t.Fatalf("cached saved gates = %#v", cachedAllowed) } thread := &ReviewThread{ViewerCanReply: true} live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread) if !live[0].enabled || live[1].enabled || live[2].enabled { t.Fatalf("live capabilities = %#v", live) } updatable := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanUpdatePR: true}}, thread) if !updatable[3].enabled || updatable[3].name != "update pull request" { t.Fatalf("pull request update capability = %#v", updatable[3]) } } func TestDashboardCanToggleAutoMergeAndMergeReadyPR(t *testing.T) { service := &recordingMergeService{} m := NewApp(service, "o", "r", false, 50, time.Minute) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30 m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Ready", }, HeadOID: "head", BaseRef: "main", Mergeable: "MERGEABLE", ReviewDecision: "APPROVED", CheckState: "SUCCESS", AllowedMergeMethods: []string{"SQUASH", "MERGE"}, Permissions: ViewerPermissions{CanEnableMerge: true}, } updated, _ := m.Update(runeKey("a")) m = updated.(App) if m.writeMode != writeAutoMergeConfirm || m.mergeMethod != "SQUASH" { t.Fatalf("auto-merge confirmation = mode %d method %q", m.writeMode, m.mergeMethod) } updated, command := m.Update(runeKey("y")) m = updated.(App) if command == nil || m.writeMode != writeAutoMergeBusy { t.Fatalf("auto-merge submit = mode %d command %v", m.writeMode, command) } updated, _ = m.Update(command()) m = updated.(App) if m.details.AutoMerge == nil || !service.enabled || service.id != "pr" || service.head != "head" || service.method != "SQUASH" { t.Fatalf("auto-merge result = details %#v service %#v", m.details.AutoMerge, service) } updated, _ = m.Update(runeKey("M")) m = updated.(App) if m.writeMode != writeMergeNowConfirm { t.Fatalf("merge confirmation mode = %d", m.writeMode) } updated, command = m.Update(runeKey("y")) m = updated.(App) updated, _ = m.Update(command()) m = updated.(App) if !m.details.Merged || m.details.State != "MERGED" { t.Fatalf("merged details = %#v", m.details) } } func TestMergeNowIsGatedByCurrentRequirements(t *testing.T) { service := &recordingMergeService{} m := NewApp(service, "o", "r", false, 50, time.Minute) m.screen, m.loading = dashboardScreen, false m.details = PRDetails{ PullRequest: PullRequest{ID: "pr"}, HeadOID: "head", Mergeable: "MERGEABLE", CheckState: "FAILURE", ReviewDecision: "APPROVED", AllowedMergeMethods: []string{"SQUASH"}, Requirements: MergeRequirements{RequiresStatusChecks: true}, } updated, command := m.Update(runeKey("M")) m = updated.(App) if command != nil || m.writeMode != writeNone || m.err == nil || !strings.Contains(m.err.Error(), "status checks") { t.Fatalf("failed-check merge gate = mode %d command %v error %v", m.writeMode, command, m.err) } } func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) { service := &recordingPRService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Old title", }, Body: "- [ ] first\n- [ ] second", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } send := func(key tea.KeyMsg) tea.Cmd { updated, command := m.Update(key) m = updated.(App) return command } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) if m.writeMode != writePREdit || m.prEditField != prEditBodyField { t.Fatalf("edit key produced mode=%d field=%d", m.writeMode, m.prEditField) } editor := ansi.Strip(strings.Join(m.dashboardLines(), "\n")) if !strings.Contains(editor, "EDITING") || !strings.Contains(editor, "- [ ] first") { t.Fatalf("dashboard editor does not show the raw description:\n%s", editor) } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) send(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";")}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) send(tea.KeyMsg{Type: tea.KeyDelete}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) send(tea.KeyMsg{Type: tea.KeyEsc}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(runeKey("i")) send(tea.KeyMsg{Type: tea.KeyHome}) for range len("main") { send(tea.KeyMsg{Type: tea.KeyDelete}) } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")}) send(tea.KeyMsg{Type: tea.KeyEsc}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(runeKey("i")) send(tea.KeyMsg{Type: tea.KeyHome}) for range len("Old title") { send(tea.KeyMsg{Type: tea.KeyDelete}) } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New title")}) send(tea.KeyMsg{Type: tea.KeyCtrlS}) if m.writeMode != writePREditConfirm { t.Fatalf("ctrl-s produced mode=%d, error=%v", m.writeMode, m.err) } command := send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) if command == nil || m.writeMode != writePREditBusy { t.Fatalf("confirmation produced mode=%d command=%v", m.writeMode, command) } updated, refresh := m.Update(command()) m = updated.(App) if refresh == nil || service.updateID != "pr" { t.Fatalf("update did not submit and refresh: id=%q refresh=%v", service.updateID, refresh) } if service.update.Title != "New title" || service.update.BaseRef != "release" || service.update.Body != "- [x] first\n- [ ] second" { t.Fatalf("submitted metadata = %#v", service.update) } if m.writeMode != writeNone || m.details.Title != "New title" || m.details.BaseRef != "release" || m.details.Body != service.update.Body { t.Fatalf("local metadata was not updated: mode=%d details=%#v", m.writeMode, m.details) } } func TestDashboardEditorPreservesDraftAfterMutationFailure(t *testing.T) { service := &recordingPRService{updateErr: errors.New("base branch does not exist")} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Title", }, Body: "description", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() m.prEditEditors[prEditBodyField].Text = "changed description" m.prEditEditors[prEditBodyField].Cursor = len([]rune("changed description")) m.writeMode = writePREditBusy message := m.submitPREdit()() updated, _ := m.Update(message) m = updated.(App) if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Text != "changed description" || m.err == nil || !strings.Contains(m.err.Error(), "base branch does not exist") { t.Fatalf("failed mutation lost editor state: mode=%d body=%q err=%v", m.writeMode, m.prEditEditors[prEditBodyField].Text, m.err) } } func TestDashboardEditorRejectsStaleMetadata(t *testing.T) { service := &recordingPRService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "original", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() m.prEditEditors[prEditBodyField].Text = "my edit" m.details.Body = "remote edit" updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlS}) m = updated.(App) if command != nil || m.writeMode != writePREdit || m.err == nil || !strings.Contains(m.err.Error(), "changed while editing") { t.Fatalf("stale metadata was not blocked: mode=%d command=%v err=%v", m.writeMode, command, m.err) } } func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) { settings := defaultAppSettings() settings.EditorMode = "standard" m := NewAppWithSettings(&recordingPRService{}, "o", "r", false, 50, time.Second, settings) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "body", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() for field, editor := range m.prEditEditors { if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune(editor.Text)) { t.Fatalf("standard editor field %d = %#v", field, editor) } } } func TestVimModeAppliesToEveryTextInput(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.loading, m.width, m.height = false, 80, 24 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "body", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() for field, editor := range m.prEditEditors { if !editor.Modal || editor.Mode != textEditorNormal { t.Fatalf("Vim PR editor field %d = %#v", field, editor) } } m.writeMode, m.replyDraft = writeReply, "reply" m.resetInputEditor(&m.replyEditor, m.replyDraft) updated, _ := m.updateWriteInput(runeKey("i")) m = updated.(App) updated, _ = m.updateWriteInput(runeKey("!")) m = updated.(App) updated, _ = m.updateWriteInput(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.replyDraft != "!reply" || m.replyEditor.Mode != textEditorNormal { t.Fatalf("Vim reply = %q mode=%s", m.replyDraft, m.replyEditor.Mode) } m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "ask" m.resetInputEditor(&m.aiInputEditor, m.aiInput) updated, _, handled := m.updateAI(runeKey("A")) m = updated.(App) if !handled || m.aiInputEditor.Mode != textEditorInsert || m.aiInputEditor.Cursor != len([]rune("ask")) { t.Fatalf("Vim AI append handled=%v editor=%#v", handled, m.aiInputEditor) } m.aiMode = aiNone m.screen, m.loading = threadScreen, false m.details.Threads = []ReviewThread{{ID: "thread", Path: "main.go"}} updated, _ = m.Update(runeKey("/")) m = updated.(App) if !m.searching || m.searchEditor.Modal || m.searchEditor.Mode != textEditorInsert { t.Fatalf("insert-only search editor = %#v searching=%v", m.searchEditor, m.searching) } updated, _ = m.Update(runeKey("main")) m = updated.(App) if m.searchQuery != "main" || m.searchEditor.Mode != textEditorInsert { t.Fatalf("Vim search query=%q editor=%#v", m.searchQuery, m.searchEditor) } } func TestVimAIDiscussionSubmitDuringRefreshPreservesDraft(t *testing.T) { config := defaultAIConfig() config.Enabled = true settings := defaultAppSettings() settings.AI = &AIController{config: config} m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) m.screen, m.loading, m.width, m.height = threadScreen, true, 80, 24 m.details = PRDetails{ HeadOID: "head", Threads: []ReviewThread{{ID: "thread", Path: "main.go"}}, } m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread", "Keep this question" m.resetInputEditor(&m.aiInputEditor, m.aiInput) updated, command, handled := m.updateAI(tea.KeyMsg{Type: tea.KeyCtrlS}) m = updated.(App) if !handled || command == nil || m.aiMode != aiPreparing { t.Fatalf("submit handled=%v command=%v mode=%v", handled, command, m.aiMode) } if m.aiInput != "Keep this question" || m.aiInputEditor.Text != m.aiInput { t.Fatalf("AI discussion draft changed during submit: input=%q editor=%q", m.aiInput, m.aiInputEditor.Text) } } func TestAIDiscussionPreparationFailurePreservesDraft(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.aiMode, m.writeThreadID, m.aiInput = aiPreparing, "thread", "Keep this question" m.resetInputEditor(&m.aiInputEditor, m.aiInput) updated, command, handled := m.updateAI(aiPreparedMsg{ threadID: "thread", err: errors.New("prepare failed"), }) m = updated.(App) if !handled || command != nil || m.aiMode != aiDiscussion || m.err == nil { t.Fatalf("failure handled=%v command=%v mode=%v err=%v", handled, command, m.aiMode, m.err) } if m.aiInput != "Keep this question" || m.aiInputEditor.Text != m.aiInput { t.Fatalf("AI discussion draft lost after failure: input=%q editor=%q", m.aiInput, m.aiInputEditor.Text) } } func TestPullRequestAIPreparationFailureIgnoresOldDiscussionThread(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.aiMode, m.writeThreadID = aiPreparing, "old-thread" updated, _, _ := m.updateAI(aiPreparedMsg{err: errors.New("prepare failed")}) m = updated.(App) if m.aiMode != aiMenu { t.Fatalf("pull-request preparation failure returned to mode %v, want AI menu", m.aiMode) } } func TestAIStaleOperationMessagesAreIgnored(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.aiGeneration = 2 m.aiMode = aiBusy m.aiProgress = AIRunProgress{Stage: "current run"} updated, command, handled := m.updateAI(aiProgressMsg{ generation: 1, progress: AIRunProgress{Stage: "stale run"}, }) m = updated.(App) if !handled || command != nil || m.aiProgress.Stage != "current run" { t.Fatalf("stale progress handled=%v command=%v progress=%q", handled, command, m.aiProgress.Stage) } updated, command, handled = m.updateAI(aiCompletedMsg{generation: 1}) m = updated.(App) if !handled || command != nil || m.aiMode != aiBusy { t.Fatalf("stale completion handled=%v command=%v mode=%v", handled, command, m.aiMode) } m.aiMode = aiPreparing updated, command, handled = m.updateAI(aiPreparedMsg{generation: 1}) m = updated.(App) if !handled || command != nil || m.aiMode != aiPreparing { t.Fatalf("stale preparation handled=%v command=%v mode=%v", handled, command, m.aiMode) } } func TestPREditStaleRecommendationMessagesAreIgnored(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.details = PRDetails{PullRequest: PullRequest{Owner: "o", Repository: "r"}} m.writeMode = writePREdit m.prEditGeneration = 2 m.prEditBranches = []RepositoryBranch{{Name: "current"}} updated, command := m.Update(branchesLoadedMsg{ generation: 1, owner: "o", repo: "r", branches: []RepositoryBranch{{Name: "stale"}}, }) m = updated.(App) if command != nil || len(m.prEditBranches) != 1 || m.prEditBranches[0].Name != "current" { t.Fatalf("stale branches command=%v branches=%#v", command, m.prEditBranches) } updated, command = m.Update(branchesLoadedMsg{ generation: 2, owner: "o", repo: "r", branches: []RepositoryBranch{{Name: "accepted"}}, }) m = updated.(App) if command != nil || len(m.prEditBranches) != 1 || m.prEditBranches[0].Name != "accepted" { t.Fatalf("current branches command=%v branches=%#v", command, m.prEditBranches) } } func TestVimModeUsesGlobalFooterBar(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.width, m.height = 80, 10 m.writeMode, m.replyDraft = writeReply, "reply" m.resetInputEditor(&m.replyEditor, m.replyDraft) footer := ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width)) if !strings.Contains(footer, "NORMAL") || !strings.Contains(footer, "REPLY") { t.Fatalf("Normal reply footer = %q", footer) } m.replyEditor.Mode = textEditorInsert footer = ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width)) if !strings.Contains(footer, "INSERT") || strings.Contains(footer, "NORMAL") { t.Fatalf("Insert reply footer = %q", footer) } } func TestDashboardVimModeBarStaysOnBottomRow(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 24 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "short", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() lines := strings.Split(ansi.Strip(m.viewDashboard()), "\n") if len(lines) != m.height || !strings.Contains(lines[len(lines)-1], "NORMAL") || !strings.Contains(lines[len(lines)-1], "EDIT DESCRIPTION") { t.Fatalf("dashboard mode bar is not on bottom row:\n%s", strings.Join(lines, "\n")) } } func TestDashboardEditorHelpIsPopupOnlyAndAvailableFromNormalMode(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24 m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Title", }, Body: "description", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() editorContent := ansi.Strip(strings.Join(m.dashboardEditLines(), "\n")) if strings.Contains(editorContent, "normal/cancel") || strings.Contains(editorContent, "copy") || strings.Contains(editorContent, "paste") { t.Fatalf("key guidance leaked into scrollable editor content:\n%s", editorContent) } updated, _ := m.Update(runeKey("?")) m = updated.(App) if !m.helpVisible { t.Fatal("? did not open help from Vim Normal mode") } help := ansi.Strip(m.View()) allHelpRows := ansi.Strip(strings.Join(m.helpRows(m.helpContentWidth()), "\n")) if !strings.Contains(help, "Pull request editor keys") || !strings.Contains(allHelpRows, "system clipboard") { t.Fatalf("editor help is missing contextual bindings:\n%s", help) } editorBindings := m.helpBindings() if len(editorBindings) < 3 || editorBindings[2].key != "esc" { t.Fatalf("editor cancel binding = %#v, want esc", editorBindings) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.helpVisible || m.writeMode != writePREdit { t.Fatalf("closing help left visible=%v writeMode=%d", m.helpVisible, m.writeMode) } } func TestDashboardEditorQuestionMarkRemainsTextInInsertModeAndF1OpensHelp(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24 m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Title", }, Body: "description", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() updated, _ := m.updatePREditInput(runeKey("i")) m = updated.(App) before := m.prEditEditors[prEditBodyField].Text updated, _ = m.Update(runeKey("?")) m = updated.(App) if m.helpVisible || m.prEditEditors[prEditBodyField].Text == before { t.Fatalf( "insert-mode ? help=%v text=%q", m.helpVisible, m.prEditEditors[prEditBodyField].Text, ) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyF1}) m = updated.(App) if !m.helpVisible { t.Fatal("f1 did not open editor help from Insert mode") } } func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "body", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() updated, _ := m.updatePREditInput(runeKey("i")) m = updated.(App) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal { t.Fatalf("insert escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode) } updated, _ = m.updatePREditInput(runeKey("v")) m = updated.(App) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal { t.Fatalf("visual escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode) } updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.writeMode != writeNone { t.Fatalf("normal escape did not close editor: write=%d", m.writeMode) } } func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "cursor-output") if err != nil { t.Fatal(err) } defer file.Close() m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.cursorOutput = newTerminalCursorOutput(file) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "abcdefghij", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() m.prEditEditors[prEditBodyField].Cursor = 4 m.prEditEditors[prEditBodyField].Mode = textEditorInsert m.positionPREditHardwareCursor(m.scroll, m.dashboardViewportHeight()) m.cursorOutput.mu.Lock() visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row m.cursorOutput.mu.Unlock() if !visible || column != 7 || row <= 0 { t.Fatalf("hardware cursor visible=%v column=%d row=%d", visible, column, row) } } func TestDashboardReturningToTitleRestoresTopAndFieldLabel(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: strings.Repeat("description line\n", 30), BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() m.scroll = 30 updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab}) m = updated.(App) if m.prEditField != prEditTitleField || m.scroll != 0 { t.Fatalf("title navigation field=%d scroll=%d", m.prEditField, m.scroll) } view := ansi.Strip(m.viewDashboard()) if !strings.Contains(view, "Edit pull request") || !strings.Contains(view, "title") { t.Fatalf("title context is not visible:\n%s", view) } } func TestDashboardEditorHalfPageMotionsMoveCursorAndViewport(t *testing.T) { var body strings.Builder for index := range 30 { fmt.Fprintf(&body, "line %02d\n", index) } m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: body.String(), BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() startCursor := m.prEditEditors[prEditBodyField].Cursor startScroll := m.scroll updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD}) m = updated.(App) if m.prEditEditors[prEditBodyField].Cursor <= startCursor || m.scroll <= startScroll { t.Fatalf("ctrl-d cursor=%d scroll=%d", m.prEditEditors[prEditBodyField].Cursor, m.scroll) } updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlU}) m = updated.(App) if m.prEditEditors[prEditBodyField].Cursor != startCursor || m.scroll != startScroll { t.Fatalf( "ctrl-u cursor=%d want=%d scroll=%d want=%d", m.prEditEditors[prEditBodyField].Cursor, startCursor, m.scroll, startScroll, ) } } func TestDashboardEditorHalfPageMotionExtendsVisualSelection(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 30, 12 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: strings.Repeat("abcdefghij", 20), BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() updated, _ := m.updatePREditInput(runeKey("v")) m = updated.(App) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD}) m = updated.(App) editor := m.prEditEditors[prEditBodyField] start, end, selected := editor.selectionBounds(m.prEditEditorWidth()) if editor.Mode != textEditorVisual || !selected || end-start <= 1 { t.Fatalf("visual ctrl-d mode=%s selection=%d:%d selected=%v", editor.Mode, start, end, selected) } } func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "cursor-output") if err != nil { t.Fatal(err) } defer file.Close() m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.cursorOutput = newTerminalCursorOutput(file) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: "abcdef", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() m.prEditEditors[prEditBodyField].Mode = textEditorInsert first := m.viewDashboard() m.prEditEditors[prEditBodyField].Cursor++ second := m.viewDashboard() if first == second { t.Fatal("hardware-cursor-only movement produced an identical frame") } if ansi.Strip(first) != ansi.Strip(second) { t.Fatal("hardware cursor marker changed visible frame content") } } func TestThreadInputsUseHardwareCursor(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "cursor-output") if err != nil { t.Fatal(err) } defer file.Close() settings := defaultAppSettings() settings.EditorMode = "standard" m := NewAppWithSettings( &recordingPRService{}, "o", "r", false, 50, time.Second, settings, ) m.cursorOutput = newTerminalCursorOutput(file) m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, Threads: []ReviewThread{{ ID: "thread-1", Path: "main.go", Line: 1, Comments: []ReviewComment{{ID: "comment-1", Author: "alice", Body: "Review"}}, }}, } m.searching, m.searchQuery = true, "main" m.resetInputEditor(&m.searchEditor, m.searchQuery) _ = m.viewThreads() m.cursorOutput.mu.Lock() searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column m.cursorOutput.mu.Unlock() if !searchVisible || searchColumn <= 0 { t.Fatalf("search cursor visible=%v column=%d", searchVisible, searchColumn) } m.searching = false m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question" m.resetInputEditor(&m.aiInputEditor, m.aiInput) m.focus = threadDetailPane m.scroll = m.detailMaxScroll() _ = m.viewThreads() m.cursorOutput.mu.Lock() discussionVisible, beforeSpaceColumn := m.cursorOutput.visible, m.cursorOutput.column m.cursorOutput.mu.Unlock() if !discussionVisible || beforeSpaceColumn <= searchColumn { t.Fatalf( "discussion cursor visible=%v column=%d search column=%d", discussionVisible, beforeSpaceColumn, searchColumn, ) } updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) m = updated.(App) view := m.viewThreads() m.cursorOutput.mu.Lock() afterSpaceColumn := m.cursorOutput.column m.cursorOutput.mu.Unlock() if !handled || m.aiInput != "Question " || afterSpaceColumn != beforeSpaceColumn+1 { t.Fatalf( "handled=%v input=%q cursor before=%d after=%d", handled, m.aiInput, beforeSpaceColumn, afterSpaceColumn, ) } if strings.Contains(ansi.Strip(view), "█") || strings.Contains(ansi.Strip(view), "Question│") { t.Fatalf("thread input still contains a painted cursor: %q", ansi.Strip(view)) } } func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) { const remoteBody = "first\nsecond\r\nthird\r" m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Title: "Title"}, Body: remoteBody, BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() if got := m.prEditEditors[prEditBodyField].Text; got != "first\nsecond\nthird\n" { t.Fatalf("editor body = %q", got) } if got := m.prEditMetadata().Body; got != remoteBody { t.Fatalf("unchanged payload body = %q, want original %q", got, remoteBody) } if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "unchanged") { t.Fatalf("line-ending normalization counted as an edit: %v", err) } m.prEditEditors[prEditBodyField].Text += "changed" if got := m.prEditMetadata().Body; strings.ContainsRune(got, '\r') { t.Fatalf("edited payload retained carriage returns: %q", got) } } func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading = threadScreen, false m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ ID: "thread", ViewerCanReply: true, Comments: []ReviewComment{{ID: "old"}}, }}, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) m = updated.(App) if m.writeMode != writeReply { t.Fatalf("reply key opened mode %d", m.writeMode) } updated, _ = m.Update(runeKey("i")) m = updated.(App) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}) m = updated.(App) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) m = updated.(App) if m.writeMode != writeReplyConfirm { t.Fatalf("ctrl-s opened mode %d", m.writeMode) } updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) m = updated.(App) if command == nil || m.writeMode != writeReplyBusy { t.Fatalf("reply confirmation produced mode=%d command=%v", m.writeMode, command) } updated, _ = m.Update(command()) m = updated.(App) if service.writeThreadID != "thread" || service.writeBody != "hello" || len(m.details.Threads[0].Comments) != 2 || m.details.Threads[0].Comments[1].ID != "new-comment" { t.Fatalf("reply was not applied: service=%#v model=%#v", service, m.details.Threads[0]) } } func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Line: 12, ViewerCanReply: true, Comments: []ReviewComment{{ ID: "existing", Author: "reviewer", Body: "Existing review context", }}, }}, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) m = updated.(App) plain := ansi.Strip(m.View()) for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl+s review"} { if !strings.Contains(plain, wanted) { t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain) } } inline := m.inlineReplyLines(80) for _, line := range inline { if strings.Contains(ansi.Strip(line.text), "newline") || strings.Contains(ansi.Strip(line.text), "review") { t.Fatalf("Vim reply contains redundant inline key help: %#v", inline) } } if m.focus != threadDetailPane { t.Fatal("inline reply did not focus the thread detail pane") } } func TestLongThreadCommentRemainsReachableByScrolling(t *testing.T) { for _, mascot := range []bool{false, true} { t.Run(fmt.Sprintf("mascot=%t", mascot), func(t *testing.T) { settings := defaultAppSettings() settings.Mascot = mascot m := NewAppWithSettings( nil, "o", "r", false, 50, time.Second, settings, ) m.screen, m.loading = threadScreen, false m.listHidden, m.focus = true, threadDetailPane updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 12}) m = updated.(App) body := strings.Repeat("abcdefghij", 40) + "FINALMARKER" m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Line: 12, Comments: []ReviewComment{{ ID: "comment", Author: "reviewer", Body: body, }}, }}, } width, _ := m.detailPaneSize() lines := m.renderedDetailLines(width) for _, line := range lines { available := max(1, width-2-ansi.StringWidth(line.rail)) if got := ansi.StringWidth(line.fixed + line.text); got > available { t.Fatalf("detail line width=%d available=%d text=%q", got, available, ansi.Strip(line.text)) } } if m.detailMaxScroll() == 0 { t.Fatal("long comment did not produce scrollable detail rows") } m.scroll = m.detailMaxScroll() if view := ansi.Strip(m.View()); !strings.Contains(view, "FINALMARKER") { t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view) } }) } } func TestLongThreadReplyComposerIsVisibleWithDifflet(t *testing.T) { settings := defaultAppSettings() settings.Mascot = true m := NewAppWithSettings( &recordingService{}, "o", "r", false, 50, time.Second, settings, ) m.screen, m.loading = threadScreen, false m.listHidden, m.focus = true, threadDetailPane updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 12}) m = updated.(App) m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Title", }, Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Line: 12, ViewerCanReply: true, Comments: []ReviewComment{{ ID: "comment", Author: "reviewer", Body: strings.Repeat("A long review comment. ", 40), }}, }}, } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) m = updated.(App) view := ansi.Strip(m.View()) for _, wanted := range []string{"Reply draft", "ctrl+s review"} { if !strings.Contains(view, wanted) { t.Fatalf("long-thread reply view is missing %q:\n%s", wanted, view) } } } func TestMouseWheelScrollsFocusedPaneByThree(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = threadScreen, false, 60, 12 m.listHidden, m.focus = true, threadDetailPane m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Comments: []ReviewComment{{ ID: "comment", Author: "reviewer", Body: strings.Repeat("A long discussion line. ", 80), }}, }}, } updated, _ := m.Update(tea.MouseMsg{ Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, }) m = updated.(App) if m.scroll != mouseWheelScrollStep { t.Fatalf("wheel down scrolled %d lines, want %d", m.scroll, mouseWheelScrollStep) } updated, _ = m.Update(tea.MouseMsg{ Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress, }) m = updated.(App) if m.scroll != 0 { t.Fatalf("wheel up did not return to the top: %d", m.scroll) } m.writeMode, m.writeThreadID = writeReply, "thread" updated, _ = m.Update(tea.MouseMsg{ Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, }) m = updated.(App) if m.scroll != mouseWheelScrollStep { t.Fatalf("reply-mode wheel down scrolled %d lines, want %d", m.scroll, mouseWheelScrollStep) } m.scroll = max(0, m.detailMaxScroll()-1) updated, _ = m.Update(tea.MouseMsg{ Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, }) m = updated.(App) if m.scroll != m.detailMaxScroll() { t.Fatalf("wheel scrolling exceeded or missed the lower bound: %d/%d", m.scroll, m.detailMaxScroll()) } m.writeMode, m.writeThreadID = writeNone, "" m.focus, m.threadIndex = threadListPane, 0 for index := 1; index < 8; index++ { m.details.Threads = append(m.details.Threads, ReviewThread{ ID: fmt.Sprintf("thread-%d", index), }) } updated, _ = m.Update(tea.MouseMsg{ Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, }) m = updated.(App) if m.threadIndex != mouseWheelScrollStep { t.Fatalf("thread-list wheel moved %d items, want %d", m.threadIndex, mouseWheelScrollStep) } } func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading = threadScreen, false m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ ID: "thread", ViewerCanResolve: true, }}, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) m = updated.(App) if m.writeMode != writeResolveConfirm || !m.resolveTarget { t.Fatalf("resolve key produced mode=%d target=%t", m.writeMode, m.resolveTarget) } updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) m = updated.(App) updated, _ = m.Update(command()) m = updated.(App) if !service.writeResolved || !m.details.Threads[0].IsResolved || !m.details.Threads[0].ViewerCanUnresolve { t.Fatalf("thread was not resolved: service=%#v thread=%#v", service, m.details.Threads[0]) } } func TestResolvingSelectedThreadKeepsSelectionNearItsPreviousPosition(t *testing.T) { tests := []struct { name string selected int wantID string }{ {name: "next thread", selected: 1, wantID: "c"}, {name: "previous thread at end", selected: 2, wantID: "b"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.screen, m.loading, m.focus = threadScreen, false, threadDetailPane m.details.Threads = []ReviewThread{ {ID: "a", Path: "a.go"}, {ID: "b", Path: "b.go"}, {ID: "c", Path: "c.go"}, } m.threadIndex = test.selected resolvedID := m.details.Threads[test.selected].ID updated, _ := m.Update(threadResolvedMsg{ threadID: resolvedID, thread: ReviewThread{ ID: resolvedID, IsResolved: true, ViewerCanUnresolve: true, }, }) m = updated.(App) if got := m.details.Threads[m.threadIndex].ID; got != test.wantID { t.Fatalf("selected thread = %q, want nearest thread %q", got, test.wantID) } if m.focus != threadDetailPane { t.Fatalf("focus = %d, want detail pane", m.focus) } }) } } func TestQueuedResolutionOptimisticallyMovesToNeighborWithoutPendingLabel(t *testing.T) { store := loadMutationQueue("") operation := mutationOperation{ ID: "resolve", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 1, ThreadID: "b", Resolved: true, EnqueuedAt: time.Now(), } if err := store.add(operation); err != nil { t.Fatal(err) } settings := defaultAppSettings() settings.Mutations = store m := NewAppWithSettings(nil, "o", "r", false, 50, time.Minute, settings) m.screen, m.loading, m.focus = threadScreen, false, threadDetailPane m.details = PRDetails{ PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{ {ID: "a", Path: "a.go"}, {ID: "b", Path: "b.go"}, {ID: "c", Path: "c.go"}, }, } m.threadIndex = 1 updated, _ := m.Update(mutationQueuedMsg{operation: operation}) m = updated.(App) if got := m.details.Threads[m.threadIndex].ID; got != "c" { t.Fatalf("selected thread = %q, want c", got) } thread := m.threadByID("b") if thread == nil || !thread.IsResolved || thread.Pending { t.Fatalf("optimistic resolved thread = %#v", thread) } } func TestMutationQueueAllowsResolvingNextThreadDuringVerificationRefresh(t *testing.T) { store := loadMutationQueue("") settings := defaultAppSettings() settings.Mutations = store m := NewAppWithSettings(&recordingService{}, "o", "r", false, 50, time.Minute, settings) m.screen, m.loading = threadScreen, true m.details = PRDetails{ PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ ID: "next", ViewerCanResolve: true, }}, } m.startResolveToggle() if m.err != nil || m.writeMode != writeResolveConfirm || !m.resolveTarget { t.Fatalf("resolve during verification mode=%d target=%t err=%v", m.writeMode, m.resolveTarget, m.err) } } func TestPollingMarksNewThreadCommentsUnread(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = threadScreen, 80, 8 initial := PRDetails{ PullRequest: PullRequest{ID: "pr", Number: 1}, Threads: []ReviewThread{{ ID: "thread", Path: "a.go", Comments: []ReviewComment{{ID: "comment-1", Author: "alice", Body: "Original"}}, }}, } updated, _ := m.Update(detailsLoadedMsg{number: 1, details: initial}) m = updated.(App) if m.unreadThreads["thread"] { t.Fatal("initial data was marked unread") } refreshed := initial refreshed.Threads = []ReviewThread{{ ID: "thread", Path: "a.go", Comments: []ReviewComment{ {ID: "comment-1", Author: "alice", Body: "Original"}, {ID: "comment-2", Author: "bob", Body: "New reply"}, }, }} updated, _ = m.Update(detailsLoadedMsg{number: 1, details: refreshed}) m = updated.(App) if !m.unreadThreads["thread"] || !m.unreadComments["comment-2"] || m.newThreads["thread"] { t.Fatal("new review comment was not marked unread") } plain := ansi.Strip(m.threadList(48, 10)) if !strings.Contains(plain, "NEW") { t.Fatalf("thread list does not indicate unread update:\n%s", plain) } detail := m.detailLines(60) dividerFound, emphasizedRail := false, false for _, line := range detail { if strings.Contains(ansi.Strip(line.text), "NEW MESSAGES") { dividerFound = true } if line.anchor == "comment:comment-2:header" && ansi.Strip(line.rail) == "┃ " { emphasizedRail = true } } if !dividerFound || !emphasizedRail { t.Fatalf("unread detail treatment missing: divider=%t rail=%t", dividerFound, emphasizedRail) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) m = updated.(App) if !m.unreadThreads["thread"] { t.Fatal("moving the thread-list cursor marked the thread read") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) m = updated.(App) if m.unreadThreads["thread"] || m.unreadComments["comment-2"] { t.Fatal("focusing the thread detail did not mark the thread read") } m.unreadThreads["thread"] = true m.unreadComments["comment-2"] = true updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) m = updated.(App) if m.unreadThreads["thread"] || m.unreadComments["comment-2"] || m.focus != threadDetailPane { t.Fatal("next-unread did not open and mark the thread discussion read") } } func TestPollingDoesNotMarkViewerReplyUnread(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen initial := PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, ViewerLogin: "me", Threads: []ReviewThread{{ ID: "thread", Comments: []ReviewComment{{ID: "original", Author: "reviewer"}}, }}, } updated, _ := m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: initial}) m = updated.(App) refreshed := initial refreshed.Threads = []ReviewThread{{ ID: "thread", Comments: []ReviewComment{ {ID: "original", Author: "reviewer"}, {ID: "own-reply", Author: "ME", Body: "sent by me"}, }, }} updated, _ = m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: refreshed}) m = updated.(App) if m.unreadThreads["thread"] || m.unreadComments["own-reply"] || len(m.updatedThreads) != 0 { t.Fatalf("viewer reply was marked new: threads=%v comments=%v updated=%v", m.unreadThreads, m.unreadComments, m.updatedThreads) } state := m.readState.Data["pr"] if !state.Comments["own-reply"] { t.Fatal("viewer reply was not persisted as read") } } func TestResolvingThreadMarksItRead(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) m.screen, m.loading = threadScreen, false m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, Threads: []ReviewThread{{ ID: "thread", ViewerCanResolve: true, Comments: []ReviewComment{{ID: "comment"}}, }}, } m.unreadThreads["thread"] = true m.unreadComments["comment"] = true updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) m = updated.(App) updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) m = updated.(App) updated, _ = m.Update(command()) m = updated.(App) if m.unreadThreads["thread"] || m.unreadComments["comment"] { t.Fatal("resolved thread remained unread") } state := m.readState.Data["pr"] if !state.Threads["thread"] || !state.Comments["comment"] { t.Fatalf("resolved thread read state was not persisted: %#v", state) } } func TestUnreadThreadClearsOnlyWhenLastUnreadCommentIsVisible(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.focus, m.listHidden = threadScreen, threadDetailPane, true m.width, m.height = 60, 8 m.details = PRDetails{ PullRequest: PullRequest{ID: "pr", Number: 1}, Threads: []ReviewThread{{ ID: "thread", Path: "a.go", Comments: []ReviewComment{ {ID: "old", Author: "alice", Body: "Old"}, {ID: "new-1", Author: "bob", Body: strings.Repeat("First new message ", 8)}, {ID: "new-2", Author: "carol", Body: "Last new message"}, }, }}, } m.unreadThreads["thread"] = true m.unreadComments["new-1"] = true m.unreadComments["new-2"] = true lines := m.renderedDetailLines(m.width) firstHeader, lastHeader := -1, -1 for index, line := range lines { switch line.anchor { case "comment:new-1:header": firstHeader = index case "comment:new-2:header": lastHeader = index } } if firstHeader < 0 || lastHeader <= firstHeader { t.Fatalf("unread comment anchors = %d, %d", firstHeader, lastHeader) } m.scroll = firstHeader m.acknowledgeVisibleUnread() if !m.unreadThreads["thread"] { t.Fatal("thread was read before the last unread comment became visible") } m.scroll = max(0, lastHeader-m.detailViewportHeight()+1) m.acknowledgeVisibleUnread() if m.unreadThreads["thread"] { t.Fatal("thread remained unread after the last unread comment became visible") } } func TestManualMarkReadIsFallbackForNewThread(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = threadScreen, 80, 20 initial := PRDetails{ PullRequest: PullRequest{ID: "pr", Number: 1}, Threads: []ReviewThread{{ ID: "existing", Path: "a.go", Comments: []ReviewComment{{ID: "existing-comment"}}, }}, } m.trackThreadUpdates(initial) updatedDetails := initial updatedDetails.Threads = append(updatedDetails.Threads, ReviewThread{ ID: "new-thread", Path: "b.go", Comments: []ReviewComment{{ID: "new-root", Author: "alice", Body: "New thread"}}, }) m.trackThreadUpdates(updatedDetails) m.details = updatedDetails m.threadIndex = 1 if !m.newThreads["new-thread"] || !m.unreadComments["new-root"] || !strings.Contains(ansi.Strip(m.threadList(60, 10)), "NEW THREAD") { t.Fatal("new thread did not receive the distinct unread treatment") } for _, line := range m.detailLines(60) { if strings.Contains(ansi.Strip(line.text), "NEW MESSAGES") { t.Fatal("completely new thread received a partial-update divider") } } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")}) m = updated.(App) if m.unreadThreads["new-thread"] || m.unreadComments["new-root"] || m.newThreads["new-thread"] { t.Fatal("manual mark-read fallback did not clear the selected thread") } } func TestDashboardDescriptionScrolls(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = dashboardScreen m.width, m.height = 60, 10 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "owner/repo", Number: 1, Title: "Long", Author: "alice"}, BaseRef: "main", HeadRef: "feature", Body: strings.Repeat("A long description line.\n\n", 20), } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) m = updated.(App) if m.scroll == 0 || m.scroll != m.dashboardMaxScroll() { t.Fatalf("dashboard scroll = %d, max = %d", m.scroll, m.dashboardMaxScroll()) } } func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.width, m.height = 70, 24 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) m = updated.(App) if !m.helpVisible || m.screen != threadScreen { t.Fatal("? did not open thread help") } plain := ansi.Strip(m.View()) if !strings.Contains(plain, "Review thread keys") || !strings.Contains(plain, "Fuzzy-search file paths") || strings.Contains(plain, "Next pull request") { t.Fatalf("help was not contextual:\n%s", plain) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.helpVisible || m.screen != threadScreen { t.Fatal("escape did not close help in place") } } func TestConfiguredNavigationReplacesDefaultsAcrossScreensAndHelp(t *testing.T) { settings := defaultAppSettings() settings.KeyBindings.Navigation.Down = []string{"ctrl+j"} settings.KeyBindings.Navigation.Up = []string{"ctrl+k"} m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) m.screen, m.loading, m.width, m.height = prScreen, false, 80, 24 m.prs = []PullRequest{{ID: "1"}, {ID: "2"}} updated, _ := m.Update(runeKey("j")) m = updated.(App) if m.prIndex != 0 { t.Fatalf("removed default j still moved selection: %d", m.prIndex) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) m = updated.(App) if m.prIndex != 1 { t.Fatalf("configured ctrl+j did not move selection: %d", m.prIndex) } m.helpVisible = true plain := ansi.Strip(m.viewHelp()) if !strings.Contains(plain, "ctrl+j") || strings.Contains(plain, "j / down") { t.Fatalf("help did not use configured navigation:\n%s", plain) } } func TestHelpCanScrollInShortTerminal(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.helpVisible = threadScreen, true m.width, m.height = 46, 9 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) m = updated.(App) if m.helpScroll != m.helpMaxScroll() || m.helpScroll == 0 { t.Fatalf("help scroll = %d, max = %d", m.helpScroll, m.helpMaxScroll()) } plain := ansi.Strip(m.View()) if !strings.Contains(plain, "Quit") { t.Fatalf("last help bindings are not reachable:\n%s", plain) } for i, line := range strings.Split(m.View(), "\n") { if got := ansi.StringWidth(line); got > m.width { t.Fatalf("help line %d width = %d, terminal width = %d", i, got, m.width) } } } func TestHelpWrapsLongActionsWithoutCapabilityStatus(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = threadScreen, 46, 20 rows := m.helpRows(m.helpContentWidth()) plain := strings.Join(strings.Fields(ansi.Strip(strings.Join(rows, "\n"))), " ") if !strings.Contains(plain, "Clear the active thread filter and show every thread") { t.Fatalf("F binding is not explained clearly:\n%s", plain) } if strings.Contains(plain, "write action") || strings.Contains(plain, "GitHub did not grant") || strings.Contains(plain, "available") { t.Fatalf("write capability status leaked into keybinding help:\n%s", plain) } for index, row := range rows { if width := ansi.StringWidth(row); width > m.helpContentWidth() { t.Fatalf("wrapped help row %d width = %d, content width = %d", index, width, m.helpContentWidth()) } } } func TestHelpWrapsLongKeyGroupsAndSeparatesBindings(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = threadScreen, 46, 30 rows := m.helpRows(m.helpContentWidth()) plain := ansi.Strip(strings.Join(rows, "\n")) for _, key := range []string{"h", "left", "l", "right"} { if !strings.Contains(plain, key) { t.Fatalf("wrapped focus binding lost %q:\n%s", key, plain) } } if !strings.Contains(plain, strings.Repeat("─", m.helpContentWidth())) { t.Fatalf("help bindings are not visually separated:\n%s", plain) } for index, row := range rows { if width := ansi.StringWidth(row); width > m.helpContentWidth() { t.Fatalf( "wrapped help row %d width = %d, content width = %d", index, width, m.helpContentWidth(), ) } } } func TestHelpTitleIsSeparatedFromFirstBinding(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.helpVisible = threadScreen, true m.width, m.height = 70, 24 lines := strings.Split(ansi.Strip(m.viewHelp()), "\n") titleLine := -1 for index, line := range lines { if strings.Contains(line, "Review thread keys") { titleLine = index break } } if titleLine < 0 || titleLine+2 >= len(lines) { t.Fatalf("help title was not rendered:\n%s", strings.Join(lines, "\n")) } if !strings.Contains(lines[titleLine+1], "├") || !strings.Contains(lines[titleLine+1], "┤") { t.Fatalf("help title divider is not joined to the box:\n%s", strings.Join(lines, "\n")) } if strings.Contains(lines[titleLine+1], "Focus thread") { t.Fatalf("first binding shares the title divider row:\n%s", strings.Join(lines, "\n")) } } func TestHelpFooterUsesOnlyPrimaryConfiguredKeys(t *testing.T) { settings := defaultAppSettings() settings.KeyBindings.Navigation.Down = []string{"j", "down", "ctrl+j"} settings.KeyBindings.Navigation.Up = []string{"k", "up", "ctrl+k"} settings.KeyBindings.General.Help = []string{"?", "f1"} settings.KeyBindings.General.Back = []string{"b", "esc"} m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) m.screen, m.helpVisible = threadScreen, true m.width, m.height = 70, 9 lines := strings.Split(ansi.Strip(m.viewHelp()), "\n") footer := "" for _, line := range lines { if strings.Contains(line, "scroll") && strings.Contains(line, "close") { footer = line break } } if footer == "" { t.Fatalf("help footer was not found:\n%s", strings.Join(lines, "\n")) } if !strings.Contains(footer, "j/k scroll") || !strings.Contains(footer, "?/b close") { t.Fatalf("help footer does not show primary keys:\n%s", footer) } for _, secondary := range []string{"down", "up", "ctrl+j", "ctrl+k", "f1", "esc"} { if strings.Contains(footer, secondary) { t.Fatalf("help footer contains secondary key %q:\n%s", secondary, footer) } } } func TestEveryCompactScreenFooterUsesOnlyPrimaryKeys(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, time.Second) m.width, m.height, m.loading = 100, 24, false m.screen = prScreen prFooter := compactFooterLine(ansi.Strip(m.viewPRs())) if !strings.Contains(prFooter, "j / k move") || strings.Contains(prFooter, "down") || strings.Contains(prFooter, "up") { t.Fatalf("picker footer is not compact:\n%s", prFooter) } m.screen = dashboardScreen m.details = PRDetails{PullRequest: PullRequest{ Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "PR", }} dashboardFooter := compactFooterLine(ansi.Strip(m.viewDashboard())) if !strings.Contains(dashboardFooter, "j / k scroll") || strings.Contains(dashboardFooter, "down") || strings.Contains(dashboardFooter, "up") { t.Fatalf("dashboard footer is not compact:\n%s", dashboardFooter) } m.screen = threadScreen threadFooter := compactFooterLine(ansi.Strip(m.viewThreads())) if !strings.Contains(threadFooter, "h / l focus") || !strings.Contains(threadFooter, "j / k move/scroll") || strings.Contains(threadFooter, "left") || strings.Contains(threadFooter, "right") { t.Fatalf("thread footer is not compact:\n%s", threadFooter) } } func compactFooterLine(view string) string { for _, line := range strings.Split(view, "\n") { if strings.Contains(line, " keys ") { return strings.TrimSpace(line) } } return "" } func TestUppercaseFClearsAppliedThreadFilter(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.searchQuery = "status:resolved author:alice" updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("F")}) m = updated.(App) if m.searchQuery != "" { t.Fatalf("F left filter active: %q", m.searchQuery) } } func TestViewNeverExceedsTerminalWidth(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.width, m.height = 90, 18 m.details = PRDetails{ PullRequest: PullRequest{Number: 7, Title: strings.Repeat("title", 50)}, Threads: []ReviewThread{{ ID: "a", Path: strings.Repeat("very-long-directory/", 8) + "important_filename.go", Comments: []ReviewComment{{ DiffHunk: "@@ -1 +1 @@\n+" + strings.Repeat("reallyLongIdentifier", 20), Body: strings.Repeat("comment ", 100), }}, }}, } for i, line := range strings.Split(m.View(), "\n") { if got := ansi.StringWidth(line); got > m.width { t.Fatalf("line %d width = %d, terminal width = %d", i, got, m.width) } } } func TestTruncatePathPreservesFilename(t *testing.T) { got := truncatePath("a/very/long/directory/important_filename.go", 22) if !strings.HasSuffix(got, "important_filename.go") { t.Fatalf("truncated path %q lost filename", got) } } func TestConfiguredPathScrollingAdvances(t *testing.T) { settings := defaultAppSettings() settings.PathScroll = true settings.PathScrollInterval = 100 * time.Millisecond m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.screen = threadScreen updated, _ := m.Update(pathTickMsg(time.Now())) if got := updated.(App).pathScrollStep; got != 1 { t.Fatalf("path scroll step = %d, want 1", got) } path := "internal/review/threads/important_filename.go" if beginning := scrollingPath(path, 18, 0); !strings.HasPrefix(beginning, "internal/") { t.Fatalf("scroll beginning = %q", beginning) } if end := scrollingPath(path, 18, 1000); ansi.StringWidth(end) != 18 { t.Fatalf("scroll output has width %d", ansi.StringWidth(end)) } } func TestConfiguredThreadListWidth(t *testing.T) { settings := defaultAppSettings() settings.ThreadListWidthPercent = 50 m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.width = 120 if got := m.threadListWidth(); got != 60 { t.Fatalf("thread list width = %d, want 60", got) } } func TestFuzzyFileSearchRanksAndFiltersPaths(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.searchQuery = "svcusr" m.details = PRDetails{Threads: []ReviewThread{ {Path: "docs/review-user.md"}, {Path: "internal/service/user.go"}, {Path: "internal/service/team.go"}, }} matches := m.matchingThreadIndices() if len(matches) != 1 { t.Fatalf("matches = %v, want one fuzzy match", matches) } if matches[0] != 1 { t.Fatalf("best match = %q, want internal/service/user.go", m.details.Threads[matches[0]].Path) } } func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) { path := "logprep/ng/processor/generic_adder/rule.py" if _, ok := fuzzyPathScore(path, "ng generic_adder rule"); !ok { t.Fatalf("space-separated query did not match %q", path) } if _, ok := fuzzyPathScore(path, "ng generic_adder missing"); ok { t.Fatalf("query matched despite a missing term") } settings := defaultAppSettings() settings.EditorMode = "standard" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.screen, m.searching, m.searchQuery = threadScreen, true, "ng" m.resetInputEditor(&m.searchEditor, m.searchQuery) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) if got := updated.(App).searchQuery; got != "ng " { t.Fatalf("space key produced search query %q", got) } } func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) { settings := defaultAppSettings() settings.EditorMode = "standard" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.writeMode, m.replyDraft = writeReply, "reply" m.resetInputEditor(&m.replyEditor, m.replyDraft) updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace}) m = updated.(App) if m.replyDraft != "reply " { t.Fatalf("space key produced reply draft %q", m.replyDraft) } m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question" m.resetInputEditor(&m.aiInputEditor, m.aiInput) updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) m = updated.(App) if !handled || m.aiInput != "question " { t.Fatalf("handled=%v space key produced AI input %q", handled, m.aiInput) } } func TestReplyAndAIDiscussionArrowKeysMoveInputCursor(t *testing.T) { settings := defaultAppSettings() settings.EditorMode = "standard" m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) m.width, m.height = 80, 24 m.writeMode, m.replyDraft = writeReply, "mistke" m.resetInputEditor(&m.replyEditor, m.replyDraft) for range 2 { updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyLeft}) m = updated.(App) } updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) m = updated.(App) if m.replyDraft != "mistake" || m.replyEditor.Cursor != 5 { t.Fatalf("reply after cursor edit = %q at %d", m.replyDraft, m.replyEditor.Cursor) } m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "abc\ndef" m.resetInputEditor(&m.aiInputEditor, m.aiInput) m.aiInputEditor.Cursor = 1 updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeyDown}) m = updated.(App) if !handled || m.aiInputEditor.Cursor != 5 { t.Fatalf("AI down movement handled=%v cursor=%d, want 5", handled, m.aiInputEditor.Cursor) } updated, _, handled = m.updateAI(tea.KeyMsg{Type: tea.KeyRight}) m = updated.(App) if !handled || m.aiInputEditor.Cursor != 6 { t.Fatalf("AI right movement handled=%v cursor=%d, want 6", handled, m.aiInputEditor.Cursor) } } func TestFileSearchCanJumpOrCancel(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.threadIndex = 1 m.details = PRDetails{Threads: []ReviewThread{ {Path: "cmd/main.go"}, {Path: "internal/api/client.go"}, }} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) m = updated.(App) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")}) m = updated.(App) if !m.searching || m.threadIndex != 0 { t.Fatalf("search did not select cmd/main.go: searching=%v index=%d", m.searching, m.threadIndex) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.searching || m.threadIndex != 1 { t.Fatalf("cancel did not restore original selection: searching=%v index=%d", m.searching, m.threadIndex) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) m = updated.(App) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")}) m = updated.(App) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(App) if m.searching || m.threadIndex != 0 { t.Fatalf("enter did not keep search jump: searching=%v index=%d", m.searching, m.threadIndex) } } func TestMergeReadyRequiresApproval(t *testing.T) { notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"}) if strings.Contains(notApproved, "merge: ready") { t.Fatalf("unapproved PR shown as ready: %q", notApproved) } approved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "APPROVED"}) if !strings.Contains(approved, "merge: ready") { t.Fatalf("approved PR not shown as ready: %q", approved) } unresolved := reviewAndMergeState(PRDetails{ Mergeable: "MERGEABLE", ReviewDecision: "APPROVED", Requirements: MergeRequirements{RequiresConversation: true}, Threads: []ReviewThread{{ID: "open"}}, }) if !strings.Contains(unresolved, "unresolved conversations") { t.Fatalf("required unresolved conversation did not block merge: %q", unresolved) } failing := reviewAndMergeState(PRDetails{ Mergeable: "MERGEABLE", ReviewDecision: "APPROVED", CheckState: "FAILURE", Requirements: MergeRequirements{RequiresStatusChecks: true}, }) if !strings.Contains(failing, "checks failing") { t.Fatalf("required failing checks did not block merge: %q", failing) } } func TestThreadCommentCountsUseSameColumn(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width, m.height = 100, 20 m.details = PRDetails{Threads: []ReviewThread{ {ID: "short", Path: "a.go", Line: 12, Comments: make([]ReviewComment, 2)}, {ID: "long", Path: "a/very/long/directory/with/a/descriptive/important_filename.go", Line: 12, Comments: make([]ReviewComment, 2)}, }} plain := ansi.Strip(m.threadList(48, 10)) var columns []int for _, line := range strings.Split(plain, "\n") { if column := strings.Index(line, "· 2"); column >= 0 { columns = append(columns, ansi.StringWidth(line[:column])) } } if len(columns) != 2 || columns[0] != columns[1] { t.Fatalf("comment columns = %v\n%s", columns, plain) } } func TestReviewAnchorUsesCommentSnapshotCoordinates(t *testing.T) { thread := ReviewThread{ Line: 150, StartLine: 148, Comments: []ReviewComment{{ Line: 150, StartLine: 148, OriginalLine: 42, OriginalStartLine: 40, Outdated: true, }}, } start, end := reviewAnchor(thread) if start != 40 || end != 42 { t.Fatalf("anchor = %d-%d, want original snapshot range 40-42", start, end) } } func TestThreadDetailShowsCommentReactions(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 80 m.details = PRDetails{Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Comments: []ReviewComment{{ ID: "comment", Author: "reviewer", Body: "Please change this.", Reactions: []ReactionSummary{ {Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}, {Content: "EYES", Count: 1}, {Content: "HEART", Count: 2}, }, }}, }}} var rendered strings.Builder reactionLines := 0 for _, line := range m.detailLines(50) { rendered.WriteString(ansi.Strip(line.rail + line.text)) rendered.WriteByte('\n') if strings.Contains(line.anchor, ":reactions:") { reactionLines++ if ansi.StringWidth(line.text) > 46 { t.Fatalf("reaction line exceeds detail width: %q", ansi.Strip(line.text)) } } } plain := rendered.String() for _, wanted := range []string{"Please change this.", "👍 3", "👀 1", "❤️ 2"} { if !strings.Contains(plain, wanted) { t.Fatalf("thread detail is missing %q:\n%s", wanted, plain) } } if reactionLines == 0 { t.Fatalf("reactions were not attached to their comment:\n%s", plain) } } func TestThreadDetailHighlightsContributorMentions(t *testing.T) { defer applyTheme("dark") if err := applyTheme("dark"); err != nil { t.Fatal(err) } previousProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.TrueColor) t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) }) m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 80 m.details = PRDetails{Threads: []ReviewThread{{ ID: "thread", Path: "main.go", Comments: []ReviewComment{{ ID: "comment", Author: "reviewer", Body: "Could @pablu and @other-contributor check this?", }}, }}} var body strings.Builder for _, line := range m.detailLines(70) { if strings.Contains(line.anchor, ":body:") { body.WriteString(line.text) } } for _, login := range []string{"pablu", "other-contributor"} { mention := "@" + login if !strings.Contains(body.String(), authorStyle(login).Render(mention)) { t.Fatalf("thread mention %s is not author-styled: %q", mention, body.String()) } } } func TestReactionSummaryWrapsBetweenBadges(t *testing.T) { reactions := []ReactionSummary{ {Content: "THUMBS_UP", Count: 10}, {Content: "THUMBS_DOWN", Count: 2}, {Content: "LAUGH", Count: 4}, {Content: "HOORAY", Count: 7}, } lines := renderReactionSummary(reactions, 12) if len(lines) < 2 { t.Fatalf("reaction badges did not wrap: %#v", lines) } for _, line := range lines { if width := ansi.StringWidth(line); width > 12 { t.Fatalf("reaction line width = %d: %q", width, ansi.Strip(line)) } } } func TestOutdatedDetailHighlightsOriginalCodeRange(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 100 m.details = PRDetails{Threads: []ReviewThread{{ Path: "main.go", Line: 150, StartLine: 148, DiffSide: "RIGHT", IsOutdated: true, Comments: []ReviewComment{{ DiffHunk: "@@ -38,7 +38,7 @@\n old 38\n old 39\n reviewed 40\n reviewed 41\n reviewed 42\n old 43\n old 44", OriginalLine: 42, OriginalStartLine: 40, OriginalCommitOID: "0123456789abcdef", }}, }}} var rendered strings.Builder selected := 0 for _, line := range m.detailLines(80) { rendered.WriteString(ansi.Strip(line.fixed + line.text)) rendered.WriteByte('\n') if line.selected { selected++ } } output := rendered.String() if selected != 3 || !strings.Contains(output, "reviewed 40") || !strings.Contains(output, "snapshot 0123456") { t.Fatalf("detail was not anchored to the original snapshot:\n%s", output) } } func TestTabHidesListAndHRestoresIt(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.width, m.height = 100, 20 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) m = updated.(App) if !m.listHidden || m.focus != threadDetailPane { t.Fatal("tab did not hide the list and focus detail") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) m = updated.(App) if m.listHidden || m.focus != threadListPane { t.Fatal("h did not reveal and focus the list") } } func TestSelectedBackgroundFillsLine(t *testing.T) { rendered := selectedBackground("code", 12) if !strings.Contains(rendered, selectedLineBackground) || ansi.StringWidth(rendered) != 12 { t.Fatalf("selected background was not full width: %q", rendered) } } func TestWrappedDiffContinuationKeepsIndentWithoutLineNumber(t *testing.T) { original := " some_really_long_function_call(argument)" lines := wrapDiffLine(highlightedDiffLine{ gutter: " 10 + ", code: original, selected: true, }, 22) if len(lines) < 2 { t.Fatalf("long code was not wrapped: %#v", lines) } for i, line := range lines { if ansi.StringWidth(line.fixed+line.text) > 22 { t.Fatalf("wrapped line %d exceeds width: %q", i, line.fixed+line.text) } if !line.selected { t.Fatalf("continuation line %d lost selection state", i) } if i == 0 { if !strings.Contains(line.fixed, "10") { t.Fatalf("first line lost its line number: %q", line.fixed) } continue } if strings.TrimSpace(line.fixed) != "" { t.Fatalf("continuation repeated a line number: %q", line.fixed) } plain := ansi.Strip(line.text) if !strings.HasPrefix(plain, " ↳ ") { t.Fatalf("continuation lacks extra indent and marker: %q", plain) } } } func TestCodeWrapPrefersSyntaxBoundaries(t *testing.T) { code := " result = client.fetch(first_argument, second_argument)" lines := wrapCodeWithIndent(code, 38) if len(lines) < 2 { t.Fatalf("code was not wrapped: %#v", lines) } plain := make([]string, len(lines)) for i, line := range lines { plain[i] = ansi.Strip(line) } rendered := strings.Join(plain, "\n") if !strings.Contains(rendered, "first_argument") || !strings.Contains(rendered, "second_argument") { t.Fatalf("wrapper split identifiers despite syntax boundaries:\n%s", rendered) } if !strings.Contains(rendered, "↳ ") { t.Fatalf("continuation marker missing:\n%s", rendered) } } func TestAuthorColorsAreVariedAndDeterministic(t *testing.T) { if authorColor("Alice") != authorColor("alice") { t.Fatal("same login received different colors") } colors := map[lipgloss.Color]bool{} for _, login := range []string{"alice", "bob", "carol", "dave", "eve", "frank"} { colors[authorColor(login)] = true } if len(colors) < 2 { t.Fatalf("author palette did not vary: %#v", colors) } } func TestGroupedPRRowsAddsRepositoryHeadings(t *testing.T) { prs := []PullRequest{ {RepoWithOwner: "alpha/one", Number: 1}, {RepoWithOwner: "alpha/one", Number: 2}, {RepoWithOwner: "beta/two", Number: 1}, } rows, selectedRow := groupedPRRows(prs, 2) if len(rows) != 5 { t.Fatalf("row count = %d, want 5: %#v", len(rows), rows) } if !rows[0].header || rows[0].repository != "alpha/one" || !rows[3].header || rows[3].repository != "beta/two" { t.Fatalf("repository headings are incorrect: %#v", rows) } if selectedRow != 4 || rows[selectedRow].prIndex != 2 { t.Fatalf("selected row = %d, want PR row 4", selectedRow) } } func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) { service := &recordingService{} m := NewApp(service, "", "", false, 50, 10*time.Second) pr := PullRequest{Owner: "other-owner", Repository: "other-repo", Number: 17} msg := m.loadDetails(pr, false)().(detailsLoadedMsg) if service.owner != pr.Owner || service.repo != pr.Repository || service.number != pr.Number { t.Fatalf("detail request used %s/%s#%d", service.owner, service.repo, service.number) } if msg.owner != pr.Owner || msg.repo != pr.Repository || msg.number != pr.Number { t.Fatalf("detail response identity = %s/%s#%d", msg.owner, msg.repo, msg.number) } } func TestPeopleMetadataUsesColoredHandles(t *testing.T) { m := App{} assignees := m.handlesText([]string{"alice"}) reviewers := m.reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}}) if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" { t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers)) } if authorStyle("alice").GetForeground() != authorColor("alice") || authorStyle("bob").GetForeground() != authorColor("bob") { t.Fatal("people handles do not use the deterministic author color") } } func TestViewerLabelCanReplaceGitHubLoginWithYou(t *testing.T) { m := App{ viewerLabel: "you", details: PRDetails{ ViewerLogin: "pablu", }, } if got := ansi.Strip(m.authorText("pablu")); got != "@you" { t.Fatalf("viewer author = %q", got) } if got := ansi.Strip(m.authorText("reviewer")); got != "@reviewer" { t.Fatalf("other author = %q", got) } m.viewerLabel = "login" localUser := ReviewComment{ Author: "you", Origin: reviewOriginLocalAIUser, } if got := ansi.Strip(m.commentAuthorText(localUser)); got != "@pablu" { t.Fatalf("local AI user author = %q", got) } } func TestDashboardFrameDoesNotChangeWhenBackgroundRefreshStarts(t *testing.T) { m := NewApp(&recordingService{}, "", "", false, 50, time.Minute) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30 m.lastRefresh = time.Date(2026, 7, 28, 12, 0, 0, 0, time.Local) m.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "Stable dashboard", Author: "alice", }, BaseRef: "main", HeadRef: "feature", } before := m.View() updated, _ := m.Update(tickMsg(time.Now())) m = updated.(App) after := m.View() if !m.loading { t.Fatal("background refresh did not start") } if after != before { t.Fatal("dashboard frame changed solely because a background refresh started") } }