2601 lines
91 KiB
Go
2601 lines
91 KiB
Go
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 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 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")
|
||
}
|
||
}
|