26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
449 lines
14 KiB
Go
449 lines
14 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/muesli/termenv"
|
|
)
|
|
|
|
func TestRenderDiff_BlankLineBetweenFiles(t *testing.T) {
|
|
// Multi-file diff should have a blank line before the second file header.
|
|
raw := `diff --git a/foo.go b/foo.go
|
|
--- a/foo.go
|
|
+++ b/foo.go
|
|
@@ -1,2 +1,3 @@
|
|
package foo
|
|
+import "fmt"
|
|
func main() {}
|
|
diff --git a/bar.go b/bar.go
|
|
--- a/bar.go
|
|
+++ b/bar.go
|
|
@@ -1,2 +1,2 @@
|
|
package bar
|
|
-old
|
|
+new
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
|
|
// Find the last line of first file and first line of second file.
|
|
lines := strings.Split(plain, "\n")
|
|
secondFileHeaderIdx := -1
|
|
seenFirstFile := false
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
trimmed = strings.TrimLeft(trimmed, "│")
|
|
trimmed = strings.TrimRight(trimmed, "│")
|
|
trimmed = strings.TrimSpace(trimmed)
|
|
if strings.Contains(trimmed, "diff --git a/foo.go") {
|
|
seenFirstFile = true
|
|
}
|
|
if seenFirstFile && strings.Contains(trimmed, "diff --git a/bar.go") {
|
|
secondFileHeaderIdx = i
|
|
break
|
|
}
|
|
}
|
|
if secondFileHeaderIdx < 0 {
|
|
t.Fatal("second file header not found in output")
|
|
}
|
|
if secondFileHeaderIdx < 1 {
|
|
t.Fatal("no line before second file header")
|
|
}
|
|
// The line before the second file header should be blank (inside box).
|
|
prevLine := strings.TrimSpace(lines[secondFileHeaderIdx-1])
|
|
prevLine = strings.TrimLeft(prevLine, "│")
|
|
prevLine = strings.TrimRight(prevLine, "│")
|
|
prevLine = strings.TrimSpace(prevLine)
|
|
if prevLine != "" {
|
|
t.Errorf("expected blank line between files, got %q", lines[secondFileHeaderIdx-1])
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_NoExtraBlankBeforeFirstFile(t *testing.T) {
|
|
// First file header should NOT have an extra blank line before it.
|
|
raw := `diff --git a/foo.go b/foo.go
|
|
--- a/foo.go
|
|
+++ b/foo.go
|
|
@@ -1 +1,2 @@
|
|
package foo
|
|
+import "fmt"
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
lines := strings.Split(plain, "\n")
|
|
|
|
// Find the "diff --git" line.
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
trimmed = strings.TrimLeft(trimmed, "│")
|
|
trimmed = strings.TrimRight(trimmed, "│")
|
|
trimmed = strings.TrimSpace(trimmed)
|
|
if strings.HasPrefix(trimmed, "diff --git") {
|
|
// Line before should NOT be blank (it should be stats or box border).
|
|
if i > 0 {
|
|
prev := strings.TrimSpace(lines[i-1])
|
|
prev = strings.TrimLeft(prev, "│")
|
|
prev = strings.TrimRight(prev, "│")
|
|
prev = strings.TrimSpace(prev)
|
|
// The line before the first diff header is the stats line or empty stats separator.
|
|
// It should NOT be an extra blank line inserted by file separation logic.
|
|
// We can't easily distinguish, but we verify the diff renders correctly.
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("diff --git line not found")
|
|
}
|
|
|
|
func TestRenderDiff_BlankLineBetweenFiles_ThreeFiles(t *testing.T) {
|
|
// Three-file diff: each file boundary should have a blank line separator.
|
|
raw := `diff --git a/a.go b/a.go
|
|
--- a/a.go
|
|
+++ b/a.go
|
|
@@ -1 +1,2 @@
|
|
package a
|
|
+import "fmt"
|
|
diff --git a/b.go b/b.go
|
|
--- a/b.go
|
|
+++ b/b.go
|
|
@@ -1 +1,2 @@
|
|
package b
|
|
+import "os"
|
|
diff --git a/c.go b/c.go
|
|
--- a/c.go
|
|
+++ b/c.go
|
|
@@ -1 +1,2 @@
|
|
package c
|
|
+import "io"
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
lines := strings.Split(plain, "\n")
|
|
|
|
// Count blank lines immediately before file headers (inside box borders).
|
|
// Skip the first file header since its preceding blank is the stats gap, not a file boundary.
|
|
blankBeforeFile := 0
|
|
fileHeaders := 0
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
trimmed = strings.TrimLeft(trimmed, "│")
|
|
trimmed = strings.TrimRight(trimmed, "│")
|
|
trimmed = strings.TrimSpace(trimmed)
|
|
if strings.HasPrefix(trimmed, "diff --git") {
|
|
fileHeaders++
|
|
// Only count blank lines before 2nd+ file headers (file boundary separators).
|
|
if fileHeaders > 1 && i > 0 {
|
|
prev := strings.TrimSpace(lines[i-1])
|
|
prev = strings.TrimLeft(prev, "│")
|
|
prev = strings.TrimRight(prev, "│")
|
|
prev = strings.TrimSpace(prev)
|
|
if prev == "" {
|
|
blankBeforeFile++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if fileHeaders != 3 {
|
|
t.Fatalf("expected 3 file headers, got %d", fileHeaders)
|
|
}
|
|
// Blank line before 2nd and 3rd file headers (not 1st).
|
|
if blankBeforeFile != 2 {
|
|
t.Errorf("expected 2 blank lines before file boundaries, got %d", blankBeforeFile)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_LineNumbersShown(t *testing.T) {
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -10,3 +10,4 @@
|
|
context line
|
|
+added line
|
|
another context
|
|
+second add
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
// Context line at new-file line 10 should show "10" in the gutter.
|
|
if !strings.Contains(plain, " 10 ") {
|
|
t.Errorf("expected line number 10 in diff gutter, got:\n%s", plain)
|
|
}
|
|
// Added line at new-file line 11 should show "11".
|
|
if !strings.Contains(plain, " 11 ") {
|
|
t.Errorf("expected line number 11 in diff gutter, got:\n%s", plain)
|
|
}
|
|
// Context line at 12 and addition at 13.
|
|
if !strings.Contains(plain, " 12 ") {
|
|
t.Errorf("expected line number 12 in diff gutter, got:\n%s", plain)
|
|
}
|
|
if !strings.Contains(plain, " 13 ") {
|
|
t.Errorf("expected line number 13 in diff gutter, got:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_DeletionLinesNoLineNumber(t *testing.T) {
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -5,3 +5,2 @@
|
|
context
|
|
-deleted line
|
|
after delete
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
lines := strings.Split(plain, "\n")
|
|
// Find the line containing "deleted line" and verify it has no line number.
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "deleted line") {
|
|
// The line should NOT have a number before the deletion marker.
|
|
// It should have blank space in the gutter area.
|
|
trimmed := strings.TrimSpace(line)
|
|
trimmed = strings.TrimLeft(trimmed, "│")
|
|
trimmed = strings.TrimSpace(trimmed)
|
|
if trimmed[0] >= '0' && trimmed[0] <= '9' {
|
|
t.Errorf("deletion line should not have a line number, got: %q", line)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_FocusedDescriptionNotDim(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
// Focused finding's description should NOT be dim, keeping default style
|
|
// so it visually pops against the dim unfocused descriptions.
|
|
raw := `{"findings":[
|
|
{"id":"f1","severity":"error","file":"a.go","line":1,"description":"focused text"},
|
|
{"id":"f2","severity":"warning","file":"b.go","line":2,"description":"other text"}
|
|
],"summary":"2 issues"}`
|
|
|
|
selected := map[string]bool{"f1": true, "f2": true}
|
|
content, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0) // cursor=0, f1 focused
|
|
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
|
|
// Focused description should NOT be dim-styled.
|
|
if strings.Contains(content, dimStyle.Render(" focused text")) {
|
|
t.Error("focused finding description should not be dim-styled")
|
|
}
|
|
// But it should still appear (in default style).
|
|
if !strings.Contains(stripANSI(content), "focused text") {
|
|
t.Error("focused finding description should appear in output")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_UnfocusedDescriptionDim(t *testing.T) {
|
|
// Unfocused findings' descriptions should be dim (bright black) to create
|
|
// visual contrast with the focused finding.
|
|
raw := `{"findings":[
|
|
{"id":"f1","severity":"error","file":"a.go","line":1,"description":"first issue"},
|
|
{"id":"f2","severity":"warning","file":"b.go","line":2,"description":"second issue"}
|
|
],"summary":"2 issues"}`
|
|
|
|
selected := map[string]bool{"f1": true, "f2": true}
|
|
content, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0) // cursor=0, f2 unfocused
|
|
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
// wrapIndentedText produces " second issue" (8-char indent + text).
|
|
dimSecond := dimStyle.Render(" second issue")
|
|
|
|
// Unfocused description should be dim-styled (including its indent).
|
|
if !strings.Contains(content, dimSecond) {
|
|
t.Error("unfocused finding description should be dim-styled")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_FocusChangesDescriptionStyle(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
// Moving cursor from f1 to f2 should swap which description is dim vs default.
|
|
raw := `{"findings":[
|
|
{"id":"f1","severity":"error","file":"a.go","line":1,"description":"first issue"},
|
|
{"id":"f2","severity":"warning","file":"b.go","line":2,"description":"second issue"}
|
|
],"summary":"2 issues"}`
|
|
|
|
selected := map[string]bool{"f1": true, "f2": true}
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
|
|
// Cursor at 0: f1 focused, f2 unfocused (dim).
|
|
content0, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0)
|
|
if !strings.Contains(content0, dimStyle.Render(" second issue")) {
|
|
t.Error("with cursor=0, second issue description should be dim")
|
|
}
|
|
if strings.Contains(content0, dimStyle.Render(" first issue")) {
|
|
t.Error("with cursor=0, first issue description should NOT be dim")
|
|
}
|
|
|
|
// Cursor at 1: f2 focused, f1 unfocused (dim).
|
|
content1, _ := renderFindingsWithSelection(raw, 80, 1, selected, 0)
|
|
if !strings.Contains(content1, dimStyle.Render(" first issue")) {
|
|
t.Error("with cursor=1, first issue description should be dim")
|
|
}
|
|
if strings.Contains(content1, dimStyle.Render(" second issue")) {
|
|
t.Error("with cursor=1, second issue description should NOT be dim")
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_LineNumbersStyledDim(t *testing.T) {
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -1,2 +1,2 @@
|
|
context
|
|
+added
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
// Line number "1" should be styled dim (bright black).
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
styledOne := dimStyle.Render("1 ")
|
|
if !strings.Contains(got, styledOne) {
|
|
t.Error("expected line numbers to be styled dim (bright black)")
|
|
}
|
|
}
|
|
|
|
// --- Iteration 48: Blank line between stats and diff content ---
|
|
|
|
func TestRenderDiff_BlankLineBetweenStatsAndContent(t *testing.T) {
|
|
// DESIGN.md Diff View shows a blank line between the stats header and diff content:
|
|
// 3 files +42 -17
|
|
// <-- blank line here
|
|
// diff --git a/foo.go b/foo.go
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -1 +1,2 @@
|
|
package main
|
|
+import "fmt"
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
|
|
// Find the stats line and the first diff line inside the box.
|
|
lines := strings.Split(plain, "\n")
|
|
statsIdx := -1
|
|
firstDiffIdx := -1
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
// Stats line contains file count and +/- counts.
|
|
if strings.Contains(trimmed, "file") && strings.Contains(trimmed, "+") {
|
|
statsIdx = i
|
|
}
|
|
// First diff content line is the "diff --git" header.
|
|
if strings.Contains(trimmed, "diff --git") && firstDiffIdx == -1 {
|
|
firstDiffIdx = i
|
|
}
|
|
}
|
|
|
|
if statsIdx == -1 {
|
|
t.Fatal("could not find stats line in diff output")
|
|
}
|
|
if firstDiffIdx == -1 {
|
|
t.Fatal("could not find diff --git line in diff output")
|
|
}
|
|
|
|
// There should be at least one blank line between the stats and the diff content.
|
|
// gap = 2 means: stats at N, blank at N+1, diff at N+2.
|
|
gap := firstDiffIdx - statsIdx
|
|
if gap < 2 {
|
|
t.Errorf("expected blank line between stats and diff content, but gap is %d lines (stats at %d, diff at %d)", gap, statsIdx, firstDiffIdx)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_StatsBlankLineNotDoubled(t *testing.T) {
|
|
// Verify there is exactly one blank line between stats and content, not two or more.
|
|
raw := `diff --git a/main.go b/main.go
|
|
--- a/main.go
|
|
+++ b/main.go
|
|
@@ -1 +1,2 @@
|
|
package main
|
|
+import "fmt"
|
|
`
|
|
got := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(got)
|
|
|
|
lines := strings.Split(plain, "\n")
|
|
statsIdx := -1
|
|
firstDiffIdx := -1
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.Contains(trimmed, "file") && strings.Contains(trimmed, "+") {
|
|
statsIdx = i
|
|
}
|
|
if strings.Contains(trimmed, "diff --git") && firstDiffIdx == -1 {
|
|
firstDiffIdx = i
|
|
}
|
|
}
|
|
|
|
if statsIdx == -1 || firstDiffIdx == -1 {
|
|
t.Fatal("could not find stats or diff line")
|
|
}
|
|
|
|
// Count blank lines between stats and diff content (inside box, so check trimmed content).
|
|
blankCount := 0
|
|
for i := statsIdx + 1; i < firstDiffIdx; i++ {
|
|
// Inside the box, a blank line looks like "│ │" or similar - trim border chars.
|
|
inner := strings.TrimSpace(lines[i])
|
|
inner = strings.TrimLeft(inner, "│")
|
|
inner = strings.TrimRight(inner, "│")
|
|
inner = strings.TrimSpace(inner)
|
|
if inner == "" {
|
|
blankCount++
|
|
}
|
|
}
|
|
|
|
if blankCount != 1 {
|
|
t.Errorf("expected exactly 1 blank line between stats and diff content, got %d", blankCount)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_ScrolledViewPreservesStatsGap(t *testing.T) {
|
|
// When scrolled down, the stats header still has a blank line before the visible diff content.
|
|
var b strings.Builder
|
|
b.WriteString("diff --git a/main.go b/main.go\n")
|
|
b.WriteString("--- a/main.go\n")
|
|
b.WriteString("+++ b/main.go\n")
|
|
b.WriteString("@@ -1,20 +1,20 @@\n")
|
|
for i := 0; i < 20; i++ {
|
|
b.WriteString(fmt.Sprintf("+line %d\n", i))
|
|
}
|
|
raw := b.String()
|
|
|
|
// Render scrolled down by 5 lines.
|
|
got := renderDiff(raw, 80, 10, 5, "", "")
|
|
plain := stripANSI(got)
|
|
|
|
lines := strings.Split(plain, "\n")
|
|
statsIdx := -1
|
|
firstContentIdx := -1
|
|
for i, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.Contains(trimmed, "file") && strings.Contains(trimmed, "+") {
|
|
statsIdx = i
|
|
}
|
|
// First non-blank content line after stats inside the box.
|
|
if statsIdx >= 0 && i > statsIdx && firstContentIdx == -1 {
|
|
inner := strings.TrimLeft(trimmed, "│")
|
|
inner = strings.TrimRight(inner, "│")
|
|
inner = strings.TrimSpace(inner)
|
|
if inner != "" {
|
|
firstContentIdx = i
|
|
}
|
|
}
|
|
}
|
|
|
|
if statsIdx == -1 {
|
|
t.Fatal("could not find stats line in scrolled diff output")
|
|
}
|
|
if firstContentIdx == -1 {
|
|
t.Fatal("could not find content line after stats in scrolled diff output")
|
|
}
|
|
|
|
gap := firstContentIdx - statsIdx
|
|
if gap < 2 {
|
|
t.Errorf("expected blank line between stats and scrolled diff content, but gap is %d lines", gap)
|
|
}
|
|
}
|