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
734 lines
25 KiB
Go
734 lines
25 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/kunchenguid/no-mistakes/internal/ipc"
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
"github.com/muesli/termenv"
|
|
)
|
|
|
|
func TestRenderPipelineView_WrappedInBox(t *testing.T) {
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusCompleted
|
|
run.Steps[0].DurationMS = ptr(int64(1200))
|
|
run.Steps[1].Status = types.StepStatusRunning
|
|
|
|
out := stripANSI(renderPipelineView(run, run.Steps, 80, 0, 40))
|
|
// Pipeline view should be wrapped in a box with rounded corners.
|
|
if !strings.Contains(out, "╭") || !strings.Contains(out, "╯") {
|
|
t.Error("expected pipeline view to be wrapped in a box with rounded corners")
|
|
}
|
|
// Title should be "Pipeline" in the top border.
|
|
lines := strings.Split(out, "\n")
|
|
if !strings.Contains(lines[0], "Pipeline") {
|
|
t.Errorf("expected 'Pipeline' title in top border, got %q", lines[0])
|
|
}
|
|
}
|
|
|
|
func TestModel_View_LogTailWrappedInBox(t *testing.T) {
|
|
run := testRun()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.logs = []string{"running go test ./...", "PASS: TestFoo (0.3s)"}
|
|
|
|
view := stripANSI(m.View())
|
|
// Log section should have "Log" title in a box.
|
|
if !strings.Contains(view, "Log") {
|
|
t.Error("expected 'Log' section title")
|
|
}
|
|
// The log lines should be inside a box with borders.
|
|
logSection := false
|
|
for _, line := range strings.Split(view, "\n") {
|
|
if strings.Contains(line, "Log") && strings.Contains(line, "╭") {
|
|
logSection = true
|
|
}
|
|
if logSection && strings.Contains(line, "running go test") {
|
|
if !strings.Contains(line, "│") {
|
|
t.Errorf("expected log content inside box borders, got %q", line)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !logSection {
|
|
t.Error("expected log section to have a boxed title")
|
|
}
|
|
}
|
|
|
|
// --- Findings gutter alignment tests ---
|
|
|
|
func TestRenderFindings_GutterFixedWidth(t *testing.T) {
|
|
// DESIGN.md Gutter System: cursor, checkbox, severity icon each get their
|
|
// own fixed-width column. Content never shifts when selection state changes.
|
|
//
|
|
// > [x] ● src/handler.go:42
|
|
// Missing error check on db.Close()
|
|
//
|
|
// [x] ▲ src/config.go:17
|
|
// Unused import "fmt"
|
|
|
|
raw := `{"findings":[
|
|
{"id":"f1","severity":"error","file":"main.go","line":10,"description":"nil pointer"},
|
|
{"id":"f2","severity":"warning","file":"util.go","description":"unused var"}
|
|
],"summary":"2 issues"}`
|
|
|
|
allSelected := map[string]bool{"f1": true, "f2": true}
|
|
content, _ := renderFindingsWithSelection(raw, 80, 0, allSelected, 0)
|
|
got := stripANSI(content)
|
|
|
|
lines := strings.Split(got, "\n")
|
|
|
|
// Find the first finding line (has a checkbox).
|
|
var findingLines []string
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "[x]") || strings.Contains(line, "[ ]") {
|
|
findingLines = append(findingLines, line)
|
|
}
|
|
}
|
|
|
|
if len(findingLines) < 2 {
|
|
t.Fatalf("expected at least 2 finding lines, got %d in:\n%s", len(findingLines), got)
|
|
}
|
|
|
|
// The gutter should be: "> [x] ● " or " [x] ● " (8 chars).
|
|
// Cursor (1) + space (1) + checkbox (3) + space (1) + icon (1) + space (1) = 8
|
|
for i, line := range findingLines {
|
|
// Cursor column: position 0 should be ">" or " "
|
|
if line[0] != '>' && line[0] != ' ' {
|
|
t.Errorf("finding %d: expected cursor column at position 0, got %q", i, string(line[0]))
|
|
}
|
|
// Space at position 1
|
|
if line[1] != ' ' {
|
|
t.Errorf("finding %d: expected space at position 1, got %q", i, string(line[1]))
|
|
}
|
|
// Checkbox at positions 2-4: "[x]" or "[ ]"
|
|
cb := line[2:5]
|
|
if cb != "[x]" && cb != "[ ]" {
|
|
t.Errorf("finding %d: expected checkbox at positions 2-4, got %q", i, cb)
|
|
}
|
|
// Space at position 5
|
|
if line[5] != ' ' {
|
|
t.Errorf("finding %d: expected space at position 5, got %q", i, string(line[5]))
|
|
}
|
|
}
|
|
|
|
// First finding should have cursor ">"
|
|
if findingLines[0][0] != '>' {
|
|
t.Errorf("expected cursor on first finding, got %q", string(findingLines[0][0]))
|
|
}
|
|
// Second finding should have space (no cursor)
|
|
if findingLines[1][0] != ' ' {
|
|
t.Errorf("expected no cursor on second finding, got %q", string(findingLines[1][0]))
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_DescriptionClearsGutter(t *testing.T) {
|
|
// Description lines should be indented to clear the gutter (8 chars).
|
|
raw := `{"findings":[{"id":"f1","severity":"error","file":"main.go","line":10,"description":"buffer overflow risk"}],"summary":"1 issue"}`
|
|
|
|
selected := map[string]bool{"f1": true}
|
|
content, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0)
|
|
got := stripANSI(content)
|
|
|
|
lines := strings.Split(got, "\n")
|
|
// Find the description line (follows the finding line with checkbox).
|
|
var descLine string
|
|
for i, line := range lines {
|
|
if strings.Contains(line, "[x]") && i+1 < len(lines) {
|
|
descLine = lines[i+1]
|
|
break
|
|
}
|
|
}
|
|
|
|
if descLine == "" {
|
|
t.Fatalf("could not find description line in:\n%s", got)
|
|
}
|
|
|
|
// Description should be indented 8 chars to clear the gutter.
|
|
if len(descLine) < 8 {
|
|
t.Fatalf("description line too short: %q", descLine)
|
|
}
|
|
indent := descLine[:8]
|
|
if strings.TrimSpace(indent) != "" {
|
|
t.Errorf("expected 8-char indent before description, got %q", indent)
|
|
}
|
|
if !strings.Contains(descLine, "buffer overflow risk") {
|
|
t.Errorf("expected description text, got %q", descLine)
|
|
}
|
|
}
|
|
|
|
func TestModel_View_FindingsInBox(t *testing.T) {
|
|
// When findings are shown, they should be wrapped in a "Findings" box.
|
|
run := testRun()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.steps[0].Status = types.StepStatusAwaitingApproval
|
|
m.stepFindings[types.StepReview] = `{"findings":[{"id":"f1","severity":"error","file":"app.go","line":5,"description":"buffer overflow"}],"summary":"1 issue"}`
|
|
m.resetFindingSelection(types.StepReview)
|
|
m.width = 80
|
|
|
|
view := stripANSI(m.View())
|
|
|
|
// Should have a "Findings" titled box.
|
|
if !strings.Contains(view, "Findings") {
|
|
t.Error("expected Findings title in boxed section")
|
|
}
|
|
|
|
// The findings box should have rounded border chars.
|
|
hasTopBorder := false
|
|
hasBottomBorder := false
|
|
for _, line := range strings.Split(view, "\n") {
|
|
if strings.Contains(line, "╭") && strings.Contains(line, "Findings") {
|
|
hasTopBorder = true
|
|
}
|
|
if strings.Contains(line, "╰") && !strings.Contains(line, "Pipeline") && !strings.Contains(line, "Log") && !strings.Contains(line, "Diff") {
|
|
hasBottomBorder = true
|
|
}
|
|
}
|
|
if !hasTopBorder {
|
|
t.Error("expected top border with Findings title")
|
|
}
|
|
if !hasBottomBorder {
|
|
t.Error("expected bottom border for Findings box")
|
|
}
|
|
}
|
|
|
|
func TestRenderCIView_WrappedInBox(t *testing.T) {
|
|
run := testRunWithCI()
|
|
run.Steps[5].Status = types.StepStatusRunning
|
|
logs := []string{"monitoring CI for PR #42 (timeout: 4h)..."}
|
|
|
|
out := stripANSI(renderCIView(run, run.Steps, "", logs, 80))
|
|
|
|
// Should be wrapped in a box with "CI" title per DESIGN.md.
|
|
lines := strings.Split(out, "\n")
|
|
if len(lines) == 0 {
|
|
t.Fatal("expected non-empty output")
|
|
}
|
|
if !strings.Contains(lines[0], "CI") {
|
|
t.Errorf("expected 'CI' title in top border, got %q", lines[0])
|
|
}
|
|
if !strings.Contains(lines[0], "╭") {
|
|
t.Error("expected rounded top-left corner in CI box")
|
|
}
|
|
// Should have rounded bottom corner.
|
|
hasBottom := false
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "╰") && strings.Contains(line, "╯") {
|
|
hasBottom = true
|
|
break
|
|
}
|
|
}
|
|
if !hasBottom {
|
|
t.Error("expected rounded bottom border in CI box")
|
|
}
|
|
}
|
|
|
|
func TestRenderCIView_NoRedundantHeader(t *testing.T) {
|
|
// The box title "CI" replaces the old "◉ CI Monitor" header.
|
|
run := testRunWithCI()
|
|
run.Steps[5].Status = types.StepStatusRunning
|
|
logs := []string{"monitoring CI for PR #42 (timeout: 4h)..."}
|
|
|
|
out := stripANSI(renderCIView(run, run.Steps, "", logs, 80))
|
|
|
|
if strings.Contains(out, "CI Monitor") {
|
|
t.Error("expected no redundant 'CI Monitor' header - box title handles it")
|
|
}
|
|
}
|
|
|
|
func TestRenderCIView_ContentInsideBox(t *testing.T) {
|
|
run := testRunWithCI()
|
|
run.Steps[5].Status = types.StepStatusRunning
|
|
logs := []string{"monitoring CI for PR #42 (timeout: 4h)..."}
|
|
|
|
out := stripANSI(renderCIView(run, run.Steps, "", logs, 80))
|
|
|
|
// State should be inside box borders.
|
|
foundState := false
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.Contains(line, "Monitoring") && strings.Contains(line, "│") {
|
|
foundState = true
|
|
}
|
|
}
|
|
if !foundState {
|
|
t.Error("expected state indicator inside box borders")
|
|
}
|
|
}
|
|
|
|
func TestModel_View_CIViewInBox(t *testing.T) {
|
|
run := testRunWithCI()
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.steps = run.Steps
|
|
m.steps[5].Status = types.StepStatusRunning
|
|
m.logs = []string{"monitoring CI for PR #42 (timeout: 4h)..."}
|
|
m.width = 80
|
|
|
|
view := stripANSI(m.View())
|
|
|
|
// The CI section should be in a box with "CI" title.
|
|
hasCIBox := false
|
|
for _, line := range strings.Split(view, "\n") {
|
|
if strings.Contains(line, "╭") && strings.Contains(line, "CI") && !strings.Contains(line, "Pipeline") {
|
|
hasCIBox = true
|
|
break
|
|
}
|
|
}
|
|
if !hasCIBox {
|
|
t.Error("expected 'CI' titled box in full model view")
|
|
}
|
|
}
|
|
|
|
// Spacing Rules: 1 blank line between sections, never more than 1.
|
|
func TestModel_View_OneBlankLineBetweenSections(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
defer lipgloss.SetColorProfile(termenv.ANSI)
|
|
|
|
findings := `{"findings":[{"severity":"warning","description":"test finding"}],"summary":"1 issue"}`
|
|
run := &ipc.RunInfo{
|
|
ID: "run-001", RepoID: "repo-001", Branch: "main", HeadSHA: "abc123", BaseSHA: "000000",
|
|
Status: types.RunRunning,
|
|
Steps: []ipc.StepResultInfo{
|
|
{ID: "s1", StepName: types.StepReview, StepOrder: 1, Status: types.StepStatusAwaitingApproval, FindingsJSON: &findings},
|
|
{ID: "s2", StepName: types.StepTest, StepOrder: 2, Status: types.StepStatusPending},
|
|
},
|
|
}
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 60
|
|
m.logs = []string{"running test"}
|
|
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
|
|
// Between any two box bottom/top borders, there should be exactly 1 blank line.
|
|
// That means: ╯ followed by newline, blank line, then ╭
|
|
lines := strings.Split(plain, "\n")
|
|
for i := 0; i < len(lines)-1; i++ {
|
|
trimmed := strings.TrimSpace(lines[i])
|
|
if strings.HasSuffix(trimmed, "╯") && i+1 < len(lines) {
|
|
// Next box should be separated by 1 blank line
|
|
nextContent := -1
|
|
for j := i + 1; j < len(lines); j++ {
|
|
if strings.TrimSpace(lines[j]) != "" {
|
|
nextContent = j
|
|
break
|
|
}
|
|
}
|
|
if nextContent < 0 {
|
|
continue // no more content, this is the last box
|
|
}
|
|
if strings.Contains(lines[nextContent], "╭") {
|
|
blankCount := nextContent - i - 1
|
|
if blankCount != 1 {
|
|
t.Errorf("expected 1 blank line between sections at lines %d-%d, got %d blank lines\nbetween: %q\nand: %q",
|
|
i, nextContent, blankCount, lines[i], lines[nextContent])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spacing between Pipeline and CI boxes should also have 1 blank line.
|
|
func TestModel_View_OneBlankLineBetweenPipelineAndCI(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
defer lipgloss.SetColorProfile(termenv.ANSI)
|
|
|
|
run := &ipc.RunInfo{
|
|
ID: "run-001", RepoID: "repo-001", Branch: "main", HeadSHA: "abc123", BaseSHA: "000000",
|
|
Status: types.RunRunning,
|
|
Steps: []ipc.StepResultInfo{
|
|
{ID: "s1", StepName: types.StepReview, StepOrder: 1, Status: types.StepStatusCompleted},
|
|
{ID: "s2", StepName: types.StepCI, StepOrder: 2, Status: types.StepStatusRunning},
|
|
},
|
|
}
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 60
|
|
m.logs = []string{"monitoring CI for PR #42"}
|
|
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
lines := strings.Split(plain, "\n")
|
|
|
|
for i := 0; i < len(lines)-1; i++ {
|
|
trimmed := strings.TrimSpace(lines[i])
|
|
if strings.HasSuffix(trimmed, "╯") {
|
|
nextContent := -1
|
|
for j := i + 1; j < len(lines); j++ {
|
|
if strings.TrimSpace(lines[j]) != "" {
|
|
nextContent = j
|
|
break
|
|
}
|
|
}
|
|
if nextContent < 0 {
|
|
continue
|
|
}
|
|
if strings.Contains(lines[nextContent], "╭") {
|
|
blankCount := nextContent - i - 1
|
|
if blankCount != 1 {
|
|
t.Errorf("expected 1 blank line between sections at lines %d-%d, got %d\nbetween: %q\nand: %q",
|
|
i, nextContent, blankCount, lines[i], lines[nextContent])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Diff stats should match DESIGN.md: "3 files +42 -17" not "3 file(s) changed"
|
|
func TestRenderDiff_StatsPluralization(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
defer lipgloss.SetColorProfile(termenv.ANSI)
|
|
|
|
// Multiple files should say "files"
|
|
raw := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/b.go b/b.go\n--- a/b.go\n+++ b/b.go\n@@ -1 +1 @@\n-old2\n+new2\n"
|
|
result := renderDiff(raw, 80, 20, 0, "", "")
|
|
plain := stripANSI(result)
|
|
if !strings.Contains(plain, "2 files") {
|
|
t.Errorf("expected '2 files' (plural) for multiple files, got: %s", plain)
|
|
}
|
|
|
|
// Single file should say "file"
|
|
raw2 := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-old\n+new\n"
|
|
result2 := renderDiff(raw2, 80, 20, 0, "", "")
|
|
plain2 := stripANSI(result2)
|
|
if !strings.Contains(plain2, "1 file") {
|
|
t.Errorf("expected '1 file' (singular) for one file, got: %s", plain2)
|
|
}
|
|
if strings.Contains(plain2, "1 files") {
|
|
t.Errorf("expected '1 file' not '1 files' for one file, got: %s", plain2)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_StatsMatchDesign(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
defer lipgloss.SetColorProfile(termenv.ANSI)
|
|
|
|
raw := "diff --git a/foo.go b/foo.go\nindex abc..def 100644\n--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,4 @@\n context\n+added1\n+added2\n-removed\n"
|
|
result := renderDiff(raw, 80, 20, 0, "", "")
|
|
plain := stripANSI(result)
|
|
|
|
// Should say "1 file" (singular) or "3 files" (plural), NOT "file(s) changed"
|
|
if strings.Contains(plain, "file(s)") {
|
|
t.Error("diff stats should not contain 'file(s)' - use 'file'/'files' per DESIGN.md")
|
|
}
|
|
if strings.Contains(plain, "changed") {
|
|
t.Error("diff stats should not contain 'changed' - use compact format per DESIGN.md: '1 file +2 -1'")
|
|
}
|
|
// Should contain the file count and +/- stats
|
|
if !strings.Contains(plain, "1 file") {
|
|
t.Errorf("expected '1 file' in diff stats, got: %s", plain)
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_BlankLineBetweenItems(t *testing.T) {
|
|
// DESIGN.md Gutter System shows a blank line between each finding item.
|
|
raw := `{"findings":[
|
|
{"id":"f1","severity":"error","file":"main.go","line":10,"description":"nil pointer"},
|
|
{"id":"f2","severity":"warning","file":"util.go","line":5,"description":"unused var"}
|
|
],"summary":"2 issues"}`
|
|
|
|
got := stripANSI(renderFindings(raw, 80))
|
|
lines := strings.Split(got, "\n")
|
|
|
|
// Find the description lines by looking for 8-space indented content.
|
|
// After each description line, there should be a blank line before the next finding
|
|
// (except after the last finding).
|
|
foundBlankBetween := false
|
|
for i, line := range lines {
|
|
if strings.TrimSpace(line) == "nil pointer" {
|
|
// After description of first finding, next line should be blank,
|
|
// then the second finding's gutter line follows.
|
|
if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "" {
|
|
foundBlankBetween = true
|
|
}
|
|
}
|
|
}
|
|
if !foundBlankBetween {
|
|
t.Errorf("expected blank line between finding items per DESIGN.md, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_ScrollUpIndicator(t *testing.T) {
|
|
// When scrolled down (offset > 0) with lines remaining below,
|
|
// the bottom border should show an up arrow indicating lines above.
|
|
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))
|
|
}
|
|
|
|
// Scroll down 5 lines, view height 5 - should have lines above AND below.
|
|
got := stripANSI(renderDiff(b.String(), 80, 5, 5, "", ""))
|
|
lines := strings.Split(got, "\n")
|
|
lastLine := ""
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if strings.TrimSpace(lines[i]) != "" {
|
|
lastLine = lines[i]
|
|
break
|
|
}
|
|
}
|
|
if !strings.Contains(lastLine, "↑") {
|
|
t.Errorf("expected ↑ in bottom border when scrolled down, got %q", lastLine)
|
|
}
|
|
if !strings.Contains(lastLine, "↓") {
|
|
t.Errorf("expected ↓ in bottom border when lines remain below, got %q", lastLine)
|
|
}
|
|
}
|
|
|
|
func TestRenderDiff_ScrollUpOnlyAtBottom(t *testing.T) {
|
|
// When scrolled to the very end, should show ↑ but not ↓.
|
|
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,5 +1,5 @@\n")
|
|
for i := 0; i < 5; i++ {
|
|
b.WriteString(fmt.Sprintf("+line %d\n", i))
|
|
}
|
|
|
|
// 9 total lines, view height 5, offset 4 - at the bottom.
|
|
got := stripANSI(renderDiff(b.String(), 80, 5, 4, "", ""))
|
|
lines := strings.Split(got, "\n")
|
|
lastLine := ""
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if strings.TrimSpace(lines[i]) != "" {
|
|
lastLine = lines[i]
|
|
break
|
|
}
|
|
}
|
|
if !strings.Contains(lastLine, "↑") {
|
|
t.Errorf("expected ↑ in bottom border at end of diff, got %q", lastLine)
|
|
}
|
|
if strings.Contains(lastLine, "↓") {
|
|
t.Errorf("expected no ↓ at end of diff, got %q", lastLine)
|
|
}
|
|
}
|
|
|
|
// --- Color consistency tests per DESIGN.md Color Roles ---
|
|
|
|
func TestRenderPipelineView_StatusSuffixDim(t *testing.T) {
|
|
// DESIGN.md Typography Scale: "Meta: Dim (bright black). Durations, file
|
|
// references, counts, hints, footer." Status suffixes like "- awaiting approval"
|
|
// are meta-level hints and must be styled dim (bright black).
|
|
run := testRun()
|
|
run.Steps[1].Status = types.StepStatusAwaitingApproval
|
|
|
|
got := renderPipelineView(run, run.Steps, 80, 0, 40)
|
|
|
|
// The suffix text "- awaiting approval" should be styled dim (contain ANSI codes).
|
|
// When stripped, the text should be present; in the raw output, it should be wrapped
|
|
// in dim styling, not appear as plain unstyled text.
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
styledSuffix := dimStyle.Render("- awaiting approval")
|
|
|
|
if !strings.Contains(got, styledSuffix) {
|
|
t.Errorf("expected status suffix '- awaiting approval' to be styled dim (bright black), but it was not found as styled text in output")
|
|
}
|
|
}
|
|
|
|
func TestRenderPipelineView_FailedErrorDim(t *testing.T) {
|
|
// Failed step error messages are also meta-level info and should be dim.
|
|
run := testRun()
|
|
errMsg := "lint failed"
|
|
run.Steps[2].Status = types.StepStatusFailed
|
|
run.Steps[2].Error = &errMsg
|
|
|
|
got := renderPipelineView(run, run.Steps, 80, 0, 40)
|
|
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
styledSuffix := dimStyle.Render("- " + errMsg)
|
|
|
|
if !strings.Contains(got, styledSuffix) {
|
|
t.Errorf("expected failed error suffix to be styled dim, but it was not found as styled text in output")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_CursorStyledBlue(t *testing.T) {
|
|
// DESIGN.md Color Roles: "Primary action/focus: blue - interactive elements."
|
|
// The cursor ">" indicating the focused finding should be styled blue.
|
|
raw := `{"findings":[{"id":"f1","severity":"error","file":"main.go","line":10,"description":"nil pointer"}],"summary":"1 issue"}`
|
|
selected := map[string]bool{"f1": true}
|
|
|
|
got, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0)
|
|
|
|
blueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBlue))
|
|
styledCursor := blueStyle.Render(">")
|
|
|
|
if !strings.Contains(got, styledCursor) {
|
|
t.Errorf("expected cursor '>' to be styled blue per DESIGN.md Primary action/focus, but it was not found as styled text")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_CheckboxSelectedGreen(t *testing.T) {
|
|
// DESIGN.md Color Roles: "Success: green - completed, additions."
|
|
// Selected checkboxes "[x]" represent a successful/confirmed selection.
|
|
raw := `{"findings":[{"id":"f1","severity":"error","file":"main.go","line":10,"description":"nil pointer"}],"summary":"1 issue"}`
|
|
selected := map[string]bool{"f1": true}
|
|
|
|
got, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0)
|
|
|
|
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiGreen))
|
|
styledCheckbox := greenStyle.Render("[x]")
|
|
|
|
if !strings.Contains(got, styledCheckbox) {
|
|
t.Errorf("expected selected checkbox '[x]' to be styled green per DESIGN.md Success color, but it was not found as styled text")
|
|
}
|
|
}
|
|
|
|
func TestRenderFindings_CheckboxUnselectedDim(t *testing.T) {
|
|
// DESIGN.md Color Roles: "Muted/secondary: bright black."
|
|
// Unselected checkboxes "[ ]" should be dim to de-emphasize.
|
|
raw := `{"findings":[{"id":"f1","severity":"error","file":"main.go","line":10,"description":"nil pointer"}],"summary":"1 issue"}`
|
|
selected := map[string]bool{} // nothing selected
|
|
|
|
got, _ := renderFindingsWithSelection(raw, 80, 0, selected, 0)
|
|
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
styledCheckbox := dimStyle.Render("[ ]")
|
|
|
|
if !strings.Contains(got, styledCheckbox) {
|
|
t.Errorf("expected unselected checkbox '[ ]' to be styled dim (bright black) per DESIGN.md Muted color, but it was not found as styled text")
|
|
}
|
|
}
|
|
|
|
func TestNewModel_PopulatesStepFindingsFromInitialSteps_DisplaysOnView(t *testing.T) {
|
|
findings := `{"findings":[{"severity":"warning","description":"stale finding from re-attach"}],"summary":"1 issue"}`
|
|
run := &ipc.RunInfo{
|
|
ID: "run-001",
|
|
RepoID: "repo-001",
|
|
Branch: "feature/foo",
|
|
HeadSHA: "abc123",
|
|
BaseSHA: "000000",
|
|
Status: types.RunRunning,
|
|
Steps: []ipc.StepResultInfo{
|
|
{ID: "s1", StepName: types.StepReview, StepOrder: 1, Status: types.StepStatusAwaitingApproval, FindingsJSON: &findings},
|
|
{ID: "s2", StepName: types.StepTest, StepOrder: 2, Status: types.StepStatusPending},
|
|
},
|
|
}
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
view := m.View()
|
|
|
|
// The findings from the initial steps should be visible in the view.
|
|
if !strings.Contains(view, "stale finding from re-attach") {
|
|
t.Error("expected findings from initial step to appear in view on re-attach")
|
|
}
|
|
}
|
|
|
|
// --- Iteration 8: Footer visibility during approval + log line coloring ---
|
|
|
|
func TestFooter_ShowsDetachDuringApproval(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.Ascii)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusCompleted
|
|
run.Steps[1].Status = types.StepStatusAwaitingApproval
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
|
|
// Footer should show "q detach" even when a step is awaiting approval.
|
|
if !strings.Contains(plain, "q detach") {
|
|
t.Errorf("expected 'q detach' footer during approval state, got:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestLogTail_PassLinesStyledGreen(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusRunning
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.logs = []string{"running go test ./...", "PASS: TestFoo (0.3s)"}
|
|
view := m.View()
|
|
|
|
// PASS lines should be styled green (ANSI color 2), not just dim.
|
|
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiGreen))
|
|
greenPass := greenStyle.Render("PASS: TestFoo (0.3s)")
|
|
if !strings.Contains(view, greenPass) {
|
|
t.Error("expected PASS log line to be styled green, not dim")
|
|
}
|
|
}
|
|
|
|
func TestLogTail_FailLinesStyledRed(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusRunning
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.logs = []string{"running go test ./...", "FAIL: TestBar (0.1s)"}
|
|
view := m.View()
|
|
|
|
// FAIL lines should be styled red (ANSI color 1), not just dim.
|
|
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiRed))
|
|
redFail := redStyle.Render("FAIL: TestBar (0.1s)")
|
|
if !strings.Contains(view, redFail) {
|
|
t.Error("expected FAIL log line to be styled red, not dim")
|
|
}
|
|
}
|
|
|
|
func TestLogTail_RegularLineStaysDim(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusRunning
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.logs = []string{"running go test ./..."}
|
|
view := m.View()
|
|
|
|
// Regular log lines should remain dim (bright black).
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
dimLine := dimStyle.Render("running go test ./...")
|
|
if !strings.Contains(view, dimLine) {
|
|
t.Error("expected regular log line to remain dim-styled")
|
|
}
|
|
}
|
|
|
|
func TestFindingsBoxTitle_ShowsSeverityCounts(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusCompleted
|
|
run.Steps[1].Status = types.StepStatusAwaitingApproval
|
|
|
|
findingsJSON := `{"summary":"test issues","items":[{"id":"f1","severity":"error","file":"foo.go","line":1,"description":"bad thing"}]}`
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.stepFindings[types.StepTest] = findingsJSON
|
|
m.resetFindingSelection(types.StepTest)
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
|
|
// The findings box title should show severity counts, e.g. "Findings - E 1".
|
|
if !strings.Contains(plain, "Findings - E 1") {
|
|
t.Errorf("expected findings box title with severity counts 'Findings - E 1', got:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestDiffBoxTitle_ReviewStep(t *testing.T) {
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
|
run := testRun()
|
|
run.Steps[0].Status = types.StepStatusAwaitingApproval
|
|
|
|
m := NewModel("/tmp/sock", nil, run)
|
|
m.width = 80
|
|
m.height = 40
|
|
m.stepDiffs[types.StepReview] = "diff --git a/bar.go b/bar.go\n--- a/bar.go\n+++ b/bar.go\n@@ -1 +1 @@\n-old\n+new\n"
|
|
m.showDiff = true
|
|
view := m.View()
|
|
plain := stripANSI(view)
|
|
|
|
// Should say "Diff - Review" for the review step.
|
|
if !strings.Contains(plain, "Diff - Review") {
|
|
t.Errorf("expected 'Diff - Review' in box title, got:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
// --- Findings viewport scrolling tests ---
|