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 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, Threads: []ReviewThread{ {ID: "open"}, {ID: "old", IsOutdated: true}, {ID: "done", IsResolved: true, IsOutdated: true}, }, } plain := ansi.Strip(m.View()) 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", } { if !strings.Contains(plain, wanted) { t.Fatalf("dashboard is missing %q:\n%s", wanted, plain) } } } func TestDashboardDescriptionScrolls(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = dashboardScreen m.width, m.height = 60, 10 m.details = PRDetails{ PullRequest: PullRequest{RepoWithOwner: "owner/repo", Number: 1, Title: "Long", Author: "alice"}, BaseRef: "main", HeadRef: "feature", Body: strings.Repeat("A long description line.\n\n", 20), } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) m = updated.(App) if m.scroll == 0 || m.scroll != m.dashboardMaxScroll() { t.Fatalf("dashboard scroll = %d, max = %d", m.scroll, m.dashboardMaxScroll()) } } func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen m.width, m.height = 70, 24 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) m = updated.(App) if !m.helpVisible || m.screen != threadScreen { t.Fatal("? did not open thread help") } plain := ansi.Strip(m.View()) if !strings.Contains(plain, "Review thread keys") || !strings.Contains(plain, "Fuzzy-search file paths") || strings.Contains(plain, "Next pull request") { t.Fatalf("help was not contextual:\n%s", plain) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(App) if m.helpVisible || m.screen != threadScreen { t.Fatal("escape did not close help in place") } } func TestHelpCanScrollInShortTerminal(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.helpVisible = threadScreen, true m.width, m.height = 46, 9 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) m = updated.(App) if m.helpScroll != m.helpMaxScroll() || m.helpScroll == 0 { t.Fatalf("help scroll = %d, max = %d", m.helpScroll, m.helpMaxScroll()) } plain := ansi.Strip(m.View()) if !strings.Contains(plain, "Quit") { t.Fatalf("last help bindings are not reachable:\n%s", plain) } for i, line := range strings.Split(m.View(), "\n") { if got := ansi.StringWidth(line); got > m.width { t.Fatalf("help line %d width = %d, terminal width = %d", i, got, m.width) } } } func 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") } }