Files
diple/tui_test.go

2179 lines
77 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"
"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 != "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)
}
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(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.KeyShiftTab})
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()
editor := m.prEditEditors[prEditBodyField]
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
t.Fatalf("standard description editor = %#v", editor)
}
}
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()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
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.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.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(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 TestLongThreadCommentRemainsReachableByScrolling(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
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.viewThreads()); !strings.Contains(view, "FINALMARKER") {
t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view)
}
}
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, 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")
}
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})
if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got)
}
}
func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.writeMode, m.replyDraft = writeReply, "reply"
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"
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 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")
}
}