468 lines
15 KiB
Go
468 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
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.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 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.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 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 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, "Open 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 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 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)
|
|
}
|
|
}
|
|
|
|
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)().(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")
|
|
}
|
|
}
|