initial working read only state

This commit is contained in:
2026-07-27 14:03:54 +02:00
commit b0ac2f3e9a
13 changed files with 2286 additions and 0 deletions

276
tui_test.go Normal file
View File

@@ -0,0 +1,276 @@
package main
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
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 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 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 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)
}
}