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
433 lines
14 KiB
Go
433 lines
14 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
"github.com/muesli/termenv"
|
|
)
|
|
|
|
func TestAbortConfirmation_FirstPressShowsConfirm(t *testing.T) {
|
|
// First 'x' press should NOT send abort - should set confirmAbort flag
|
|
// and show a confirmation prompt in the action bar.
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusAwaitingApproval
|
|
run.Steps[0].FindingsJSON = ptr(`{"summary":"test","items":[{"id":"f1","severity":"error","file":"a.go","line":1,"description":"bug"}]}`)
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
|
|
// Press 'x' once.
|
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
updated := result.(Model)
|
|
|
|
// confirmAbort should be set.
|
|
if !updated.confirmAbort {
|
|
t.Error("expected confirmAbort to be true after first x press")
|
|
}
|
|
|
|
// The action bar should show a confirmation hint.
|
|
view := updated.View()
|
|
stripped := stripANSI(view)
|
|
if !strings.Contains(stripped, "x again to abort") {
|
|
t.Errorf("expected 'x again to abort' in view, got:\n%s", stripped)
|
|
}
|
|
}
|
|
|
|
func TestAbortConfirmation_SecondPressSendsAbort(t *testing.T) {
|
|
// Second 'x' press should actually send the abort command.
|
|
run := testRun()
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.confirmAbort = true // simulate first press already happened
|
|
|
|
// Press 'x' again - this should produce a command (the respond cmd).
|
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
|
|
|
// Should produce a non-nil command (the abort RPC call).
|
|
if cmd == nil {
|
|
t.Error("expected a non-nil command from second x press (abort should be sent)")
|
|
}
|
|
}
|
|
|
|
func TestModel_View_HelpOverlay_ShowsAbortWhenRunning(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
run := testRun()
|
|
m := NewModel("", nil, run)
|
|
m.width = 80
|
|
m.height = 40
|
|
m.showHelp = true
|
|
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
|
|
if !strings.Contains(plain, "x x") || !strings.Contains(plain, "abort pipeline") {
|
|
t.Errorf("help should show top-level abort while pipeline is running, got:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestFindDiffOffset_MatchesFileAndHunk(t *testing.T) {
|
|
// findDiffOffset should return the index of the hunk header
|
|
// that contains the target file and line number.
|
|
raw := "diff --git a/foo.go b/foo.go\n" +
|
|
"--- a/foo.go\n" +
|
|
"+++ b/foo.go\n" +
|
|
"@@ -10,5 +10,7 @@ func foo() {\n" +
|
|
" context\n" +
|
|
"+added\n" +
|
|
" context\n" +
|
|
"@@ -30,3 +32,4 @@ func bar() {\n" +
|
|
" context\n" +
|
|
"+another\n"
|
|
lines := parseDiffLines(raw)
|
|
|
|
// Line 12 is in the first hunk (+10,7 covers lines 10-16).
|
|
offset := findDiffOffset(lines, "foo.go", 12)
|
|
if offset != 3 { // index of "@@ -10,5 +10,7 @@" line
|
|
t.Errorf("expected offset=3 for foo.go:12, got %d", offset)
|
|
}
|
|
|
|
// Line 33 is in the second hunk (+32,4 covers lines 32-35).
|
|
offset = findDiffOffset(lines, "foo.go", 33)
|
|
if offset != 7 { // index of "@@ -30,3 +32,4 @@" line
|
|
t.Errorf("expected offset=7 for foo.go:33, got %d", offset)
|
|
}
|
|
}
|
|
|
|
func TestFindDiffOffset_FileNotFound(t *testing.T) {
|
|
// Should return 0 when the file doesn't exist in the diff.
|
|
raw := "diff --git a/foo.go b/foo.go\n" +
|
|
"+++ b/foo.go\n" +
|
|
"@@ -1,3 +1,4 @@\n" +
|
|
"+added\n"
|
|
lines := parseDiffLines(raw)
|
|
|
|
offset := findDiffOffset(lines, "bar.go", 1)
|
|
if offset != 0 {
|
|
t.Errorf("expected offset=0 for non-existent file, got %d", offset)
|
|
}
|
|
}
|
|
|
|
func TestFindDiffOffset_ScrollsToFileHeader(t *testing.T) {
|
|
// When line=0 or line not in any hunk, should scroll to the file header.
|
|
raw := "diff --git a/foo.go b/foo.go\n" +
|
|
"--- a/foo.go\n" +
|
|
"+++ b/foo.go\n" +
|
|
"@@ -10,3 +10,4 @@\n" +
|
|
"+added\n"
|
|
lines := parseDiffLines(raw)
|
|
|
|
// Line 0 means "just show me the file".
|
|
offset := findDiffOffset(lines, "foo.go", 0)
|
|
if offset != 0 { // index of "diff --git a/foo.go" line
|
|
t.Errorf("expected offset=0 for foo.go:0, got %d", offset)
|
|
}
|
|
|
|
// Line 99 is beyond any hunk - should still scroll to the file header.
|
|
offset = findDiffOffset(lines, "foo.go", 99)
|
|
if offset != 0 {
|
|
t.Errorf("expected offset=0 for foo.go:99 (beyond all hunks), got %d", offset)
|
|
}
|
|
}
|
|
|
|
func TestDiffToggle_AutoScrollsToFinding(t *testing.T) {
|
|
// When pressing 'd' to switch from findings to diff, diffOffset
|
|
// should auto-scroll to the location of the current finding.
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusAwaitingApproval
|
|
run.Steps[0].FindingsJSON = ptr(`{"summary":"test","items":[` +
|
|
`{"id":"f1","severity":"error","file":"foo.go","line":33,"description":"bug1"},` +
|
|
`{"id":"f2","severity":"warning","file":"bar.go","line":5,"description":"bug2"}]}`)
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.height = 40
|
|
m.stepDiffs[types.StepReview] = "diff --git a/foo.go b/foo.go\n" +
|
|
"--- a/foo.go\n" +
|
|
"+++ b/foo.go\n" +
|
|
"@@ -30,3 +30,4 @@ func bar() {\n" +
|
|
" context\n" +
|
|
"+added\n" +
|
|
"diff --git a/bar.go b/bar.go\n" +
|
|
"--- a/bar.go\n" +
|
|
"+++ b/bar.go\n" +
|
|
"@@ -3,3 +3,4 @@\n" +
|
|
"+new line\n"
|
|
|
|
// Cursor is on finding 0 (foo.go:33). Press 'd' to show diff.
|
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
|
|
model := result.(Model)
|
|
|
|
if !model.showDiff {
|
|
t.Fatal("expected showDiff=true")
|
|
}
|
|
// Should auto-scroll to the hunk containing foo.go line 33.
|
|
// The hunk header "@@ -30,3 +30,4 @@" is at index 3.
|
|
if model.diffOffset != 3 {
|
|
t.Errorf("expected diffOffset=3 for foo.go:33, got %d", model.diffOffset)
|
|
}
|
|
}
|
|
|
|
func TestAbortConfirmation_OtherKeyResetsConfirm(t *testing.T) {
|
|
// Pressing any other key after first 'x' should reset confirmAbort.
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusAwaitingApproval
|
|
run.Steps[0].FindingsJSON = ptr(`{"summary":"test","items":[{"id":"f1","severity":"error","file":"a.go","line":1,"description":"bug"}]}`)
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.confirmAbort = true // simulate first press
|
|
|
|
// Press 'j' (a navigation key, not 'x').
|
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
|
updated := result.(Model)
|
|
|
|
if updated.confirmAbort {
|
|
t.Error("expected confirmAbort to be false after pressing a different key")
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_LongLinesTruncated(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
// Create a diff with a line longer than the box content width.
|
|
longLine := "+" + strings.Repeat("x", 200) // 201 chars total
|
|
raw := "diff --git a/foo.go b/foo.go\n--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n context line\n" + longLine + "\n"
|
|
|
|
boxWidth := 80
|
|
contentWidth := boxWidth - 4 // 2 border + 2 padding = 76
|
|
|
|
result := renderDiff(raw, boxWidth, 0, 0, "", "")
|
|
plain := stripANSI(result)
|
|
|
|
// Check that no content line exceeds the box width.
|
|
for _, line := range strings.Split(plain, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Each line in the box should be exactly boxWidth visual chars wide.
|
|
w := lipgloss.Width(line)
|
|
if w > boxWidth {
|
|
t.Errorf("line exceeds box width (%d > %d): %s", w, boxWidth, line)
|
|
}
|
|
}
|
|
|
|
// Verify the long line was truncated by checking the content width.
|
|
// The long line should NOT appear in full inside the box.
|
|
if strings.Contains(plain, strings.Repeat("x", contentWidth+1)) {
|
|
t.Error("expected long diff line to be truncated to fit box content width")
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_ShortLinesNotTruncated(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
// A short line should appear in full.
|
|
raw := "diff --git a/foo.go b/foo.go\n--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n context line\n+short addition\n"
|
|
|
|
result := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(result)
|
|
|
|
if !strings.Contains(plain, "short addition") {
|
|
t.Error("expected short diff line to appear in full")
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_TruncatedLinePreservesPrefix(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
// A long addition line should still start with "+" after truncation.
|
|
longLine := "+" + strings.Repeat("a", 200)
|
|
raw := "diff --git a/foo.go b/foo.go\n--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n context\n" + longLine + "\n"
|
|
|
|
result := renderDiff(raw, 80, 0, 0, "", "")
|
|
plain := stripANSI(result)
|
|
|
|
// The truncated line should still contain "+a" (the diff prefix is preserved).
|
|
// Box lines look like: │ +aaa... │
|
|
found := false
|
|
for _, line := range strings.Split(plain, "\n") {
|
|
// Extract content between box borders.
|
|
if strings.HasPrefix(strings.TrimSpace(line), "│") && strings.Contains(line, "+a") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected truncated addition line to still start with + prefix")
|
|
}
|
|
}
|
|
|
|
// --- Log line truncation tests ---
|
|
|
|
func TestModel_View_LogLongLinesWrapped(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
run := testRun()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
// Create a log line much longer than the box content width.
|
|
longLog := "running " + strings.Repeat("x", 200) // well over 80 chars
|
|
m.logs = []string{longLog}
|
|
|
|
view := stripANSI(m.View())
|
|
|
|
// No line in the rendered output should exceed the box width.
|
|
for _, line := range strings.Split(view, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
w := lipgloss.Width(line)
|
|
if w > 80 {
|
|
t.Errorf("log line exceeds box width (%d > 80): %s", w, line)
|
|
}
|
|
}
|
|
|
|
if got := strings.Count(view, "x"); got < 200 {
|
|
t.Errorf("expected wrapped log output to preserve all 200 x characters, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestModel_View_LogShortLinesNotTruncated(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
run := testRun()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.logs = []string{"running go test ./..."}
|
|
|
|
view := stripANSI(m.View())
|
|
|
|
if !strings.Contains(view, "running go test ./...") {
|
|
t.Error("expected short log line to appear in full")
|
|
}
|
|
}
|
|
|
|
func TestRenderCIView_LogLongLinesWrapped(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
run := testRunWithCI()
|
|
run.Steps[5].Status = types.StepStatusRunning
|
|
longLog := "monitoring CI for PR #42 (timeout: 4h)..."
|
|
longLog2 := "running " + strings.Repeat("y", 200)
|
|
logs := []string{longLog, longLog2}
|
|
|
|
result := stripANSI(renderCIView(run, run.Steps, "", logs, 80))
|
|
|
|
// No line should exceed the box width (80).
|
|
for _, line := range strings.Split(result, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
w := lipgloss.Width(line)
|
|
if w > 80 {
|
|
t.Errorf("CI log line exceeds box width (%d > 80): %s", w, line)
|
|
}
|
|
}
|
|
|
|
if got := strings.Count(result, "y"); got < 200 {
|
|
t.Errorf("expected wrapped CI log output to preserve all 200 y characters, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderFindingsWithSelection_LongFilePathTruncated(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
|
|
// Create a finding with a very long file path that would overflow an 80-width box.
|
|
longPath := "src/internal/very/deeply/nested/package/structure/" + strings.Repeat("x", 100) + "/handler.go"
|
|
raw := fmt.Sprintf(`{"items":[{"id":"f1","severity":"error","file":"%s","line":42,"description":"Missing error check"}]}`, longPath)
|
|
selected := map[string]bool{"f1": true}
|
|
|
|
// Width is 76 (box content width = 80 - 4 for border/padding).
|
|
content, _ := renderFindingsWithSelection(raw, 76, 0, selected, 0)
|
|
result := stripANSI(content)
|
|
|
|
// No line in the findings content should exceed 76 chars.
|
|
for _, line := range strings.Split(result, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
w := lipgloss.Width(line)
|
|
if w > 76 {
|
|
t.Errorf("finding gutter line exceeds content width (%d > 76): %s", w, line)
|
|
}
|
|
}
|
|
|
|
// The full long file path should NOT appear.
|
|
if strings.Contains(result, longPath) {
|
|
t.Error("expected long file path to be truncated to fit content width")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindingsWithSelection_ShortFilePathNotTruncated(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
|
|
raw := `{"items":[{"id":"f1","severity":"error","file":"src/handler.go","line":42,"description":"Missing error check"}]}`
|
|
selected := map[string]bool{"f1": true}
|
|
|
|
content, _ := renderFindingsWithSelection(raw, 76, 0, selected, 0)
|
|
result := stripANSI(content)
|
|
|
|
// Short file path should appear in full.
|
|
if !strings.Contains(result, "src/handler.go:42") {
|
|
t.Error("expected short file path to appear in full, got:\n" + result)
|
|
}
|
|
}
|
|
|
|
func TestRenderFindingsWithSelection_TruncatedGutterPreservesSeverityIcon(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
|
|
longPath := strings.Repeat("z", 200) + "/handler.go"
|
|
raw := fmt.Sprintf(`{"items":[{"id":"f1","severity":"error","file":"%s","line":1,"description":"test"}]}`, longPath)
|
|
selected := map[string]bool{"f1": true}
|
|
|
|
content, _ := renderFindingsWithSelection(raw, 76, 0, selected, 0)
|
|
result := stripANSI(content)
|
|
|
|
// The severity icon and checkbox should still be present even with truncation.
|
|
if !strings.Contains(result, "[x]") {
|
|
t.Error("expected checkbox to survive truncation")
|
|
}
|
|
if !strings.Contains(result, "E") {
|
|
t.Error("expected severity icon to survive truncation")
|
|
}
|
|
}
|
|
|
|
func TestDiffToggle_NoOpWhenNoDiffData(t *testing.T) {
|
|
// Pressing 'd' when the awaiting step has no diff data should NOT toggle showDiff.
|
|
// This prevents the bug where showDiff=true hides selection actions
|
|
// but no diff actually renders.
|
|
run := testRun()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.steps[0].Status = types.StepStatusAwaitingApproval
|
|
m.stepFindings[types.StepReview] = `{"items":[{"id":"f1","severity":"error","file":"a.go","line":1,"description":"bad"}]}`
|
|
// No diff data set for this step.
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
|
|
model := updated.(Model)
|
|
if model.showDiff {
|
|
t.Error("expected showDiff to remain false when no diff data exists for the awaiting step")
|
|
}
|
|
}
|
|
|
|
func TestActionBar_HidesDiffWhenNoDiffData(t *testing.T) {
|
|
// The action bar should NOT show 'd diff' when no diff data exists
|
|
// for the current awaiting step.
|
|
configureTUIColors()
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusAwaitingApproval
|
|
m := NewModel("", nil, run)
|
|
m.width = 80
|
|
m.height = 50
|
|
m.stepFindings[types.StepReview] = `{"summary":"test","items":[{"id":"f1","severity":"error","file":"foo.go","line":1,"description":"bad"}]}`
|
|
m.resetFindingSelection(types.StepReview)
|
|
// No diff data set.
|
|
|
|
view := stripANSI(m.View())
|
|
if strings.Contains(view, "d diff") {
|
|
t.Error("should NOT show 'd diff' when no diff data exists for the awaiting step")
|
|
}
|
|
if strings.Contains(view, "d findings") {
|
|
t.Error("should NOT show 'd findings' when no diff data exists")
|
|
}
|
|
}
|