Files
diple/tui_test.go
2026-07-28 09:57:48 +02:00

558 lines
19 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 TestResolvedThreadFoldingCanBeDisabled(t *testing.T) {
settings := defaultAppSettings()
settings.FoldResolved = false
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
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.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 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 TestConfiguredPathScrollingAdvances(t *testing.T) {
settings := defaultAppSettings()
settings.PathScroll = true
settings.PathScrollInterval = 100 * time.Millisecond
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.screen = threadScreen
updated, _ := m.Update(pathTickMsg(time.Now()))
if got := updated.(App).pathScrollStep; got != 1 {
t.Fatalf("path scroll step = %d, want 1", got)
}
path := "internal/review/threads/important_filename.go"
if beginning := scrollingPath(path, 18, 0); !strings.HasPrefix(beginning, "internal/") {
t.Fatalf("scroll beginning = %q", beginning)
}
if end := scrollingPath(path, 18, 1000); ansi.StringWidth(end) != 18 {
t.Fatalf("scroll output has width %d", ansi.StringWidth(end))
}
}
func TestConfiguredThreadListWidth(t *testing.T) {
settings := defaultAppSettings()
settings.ThreadListWidthPercent = 50
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.width = 120
if got := m.threadListWidth(); got != 60 {
t.Fatalf("thread list width = %d, want 60", got)
}
}
func TestFuzzyFileSearchRanksAndFiltersPaths(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.searchQuery = "svcusr"
m.details = PRDetails{Threads: []ReviewThread{
{Path: "docs/review-user.md"},
{Path: "internal/service/user.go"},
{Path: "internal/service/team.go"},
}}
matches := m.matchingThreadIndices()
if len(matches) != 1 {
t.Fatalf("matches = %v, want one fuzzy match", matches)
}
if matches[0] != 1 {
t.Fatalf("best match = %q, want internal/service/user.go", m.details.Threads[matches[0]].Path)
}
}
func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
path := "logprep/ng/processor/generic_adder/rule.py"
if _, ok := fuzzyPathScore(path, "ng generic_adder rule"); !ok {
t.Fatalf("space-separated query did not match %q", path)
}
if _, ok := fuzzyPathScore(path, "ng generic_adder missing"); ok {
t.Fatalf("query matched despite a missing term")
}
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got)
}
}
func TestFileSearchCanJumpOrCancel(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.threadIndex = 1
m.details = PRDetails{Threads: []ReviewThread{
{Path: "cmd/main.go"},
{Path: "internal/api/client.go"},
}}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")})
m = updated.(App)
if !m.searching || m.threadIndex != 0 {
t.Fatalf("search did not select cmd/main.go: searching=%v index=%d", m.searching, m.threadIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.searching || m.threadIndex != 1 {
t.Fatalf("cancel did not restore original selection: searching=%v index=%d", m.searching, m.threadIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.searching || m.threadIndex != 0 {
t.Fatalf("enter did not keep search jump: searching=%v index=%d", m.searching, m.threadIndex)
}
}
func TestMergeReadyRequiresApproval(t *testing.T) {
notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"})
if strings.Contains(notApproved, "merge: ready") {
t.Fatalf("unapproved PR shown as ready: %q", notApproved)
}
approved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "APPROVED"})
if !strings.Contains(approved, "merge: ready") {
t.Fatalf("approved PR not shown as ready: %q", approved)
}
}
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")
}
}