Improve highlighting and add QoL changes

This commit is contained in:
2026-07-27 14:28:32 +02:00
parent 6bfe248388
commit 547cd123da
11 changed files with 989 additions and 109 deletions

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -10,6 +11,23 @@ import (
"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}}
@@ -26,6 +44,21 @@ func TestResolvedThreadsStartFolded(t *testing.T) {
}
}
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{
@@ -73,6 +106,51 @@ func TestPaneFocusAndNavigation(t *testing.T) {
}
}
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
@@ -103,6 +181,74 @@ func TestTruncatePathPreservesFilename(t *testing.T) {
}
}
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") {
@@ -274,3 +420,48 @@ func TestAuthorColorsAreVariedAndDeterministic(t *testing.T) {
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")
}
}