Files
diple/tui_test.go
2026-07-28 11:46:07 +02:00

1049 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"slices"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
type recordingService struct {
owner string
repo string
number int
writeThreadID string
writeBody string
writeResolved bool
}
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 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 != "offline cached snapshot" || cached[0].enabled {
t.Fatalf("cached capability = %#v", cached[0])
}
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)
}
}
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(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)
}
}
if m.focus != threadDetailPane {
t.Fatal("inline reply did not focus the thread detail pane")
}
}
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 TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
initial := PRDetails{
PullRequest: PullRequest{ID: "pr", Number: 1},
Threads: []ReviewThread{{
ID: "thread", Path: "a.go", Comments: []ReviewComment{{ID: "comment-1"}},
}},
}
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"}, {ID: "comment-2"}},
}}
updated, _ = m.Update(detailsLoadedMsg{number: 1, details: refreshed})
m = updated.(App)
if !m.unreadThreads["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)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
m = updated.(App)
if m.unreadThreads["thread"] {
t.Fatal("visiting unread thread did not mark it read")
}
}
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 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 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")
}
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got)
}
}
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 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, "\x1b[48;5;24m") || 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) {
assignees := handlesText([]string{"alice"})
reviewers := 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")
}
}