diff --git a/README.md b/README.md index 8a315ad..dd493fa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ func TestMultipleValues(t *testing.T) { response := handleRequest(request) // Snapshot both request and response together - shutter.SnapMany(t, "request and response", []any{request, response}) + shutter.SnapMany(t, "title", []any{request, response}) } ``` diff --git a/internal/files/files.go b/internal/files/files.go index 0cc9d5e..2405397 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -92,6 +92,17 @@ func getSnapshotFileName(snapTitle string, state string) string { } } +// getSnapshotPath returns the full path for a snapshot file +func getSnapshotPath(snapTitle string, state string) (string, error) { + snapshotDir, err := getSnapshotDir() + if err != nil { + return "", err + } + + fileName := getSnapshotFileName(snapTitle, state) + return filepath.Join(snapshotDir, fileName), nil +} + func SaveSnapshot(snap *Snapshot, state string) error { snapshotDir, err := getSnapshotDir() if err != nil { @@ -152,14 +163,15 @@ func ListNewSnapshots() ([]string, error) { } func AcceptSnapshot(snapTitle string) error { - snapshotDir, err := getSnapshotDir() + newPath, err := getSnapshotPath(snapTitle, "new") if err != nil { return err } - fileName := SnapshotFileName(snapTitle) - newPath := filepath.Join(snapshotDir, fileName+".snap.new") - acceptedPath := filepath.Join(snapshotDir, fileName+".snap") + acceptedPath, err := getSnapshotPath(snapTitle, "accepted") + if err != nil { + return err + } data, err := os.ReadFile(newPath) if err != nil { @@ -174,13 +186,10 @@ func AcceptSnapshot(snapTitle string) error { } func RejectSnapshot(snapTitle string) error { - snapshotDir, err := getSnapshotDir() + filePath, err := getSnapshotPath(snapTitle, "new") if err != nil { return err } - fileName := SnapshotFileName(snapTitle) + ".snap.new" - filePath := filepath.Join(snapshotDir, fileName) - return os.Remove(filePath) } diff --git a/internal/pretty/__snapshots__/diff_box_complex_mixed.snap b/internal/pretty/__snapshots__/diff_box_complex_mixed.snap new file mode 100644 index 0000000..045c024 --- /dev/null +++ b/internal/pretty/__snapshots__/diff_box_complex_mixed.snap @@ -0,0 +1,23 @@ +--- +title: diff_box_complex_mixed +test_name: TestDiffSnapshotBox_VisualRegression_ComplexMixed +file_name: boxes_test.go +version: 0.1.0 +--- +─── Snapshot Diff ───────────────────────────────────────────────────────────────────────────────────────────────────────── + + title: Visual Complex + test: TestVisualComplex + file: testvisualcomplex.snap + +──────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 1 │ unchanged1 + 2 - delete1 + 3 - delete2 + 2 │ unchanged2 + 5 - modify_old + 3 + modify_new + 4 + add1 + 5 │ unchanged3 + 6 + add2 +──────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/internal/pretty/__snapshots__/diff_box_large_line_numbers.snap b/internal/pretty/__snapshots__/diff_box_large_line_numbers.snap new file mode 100644 index 0000000..2787df0 --- /dev/null +++ b/internal/pretty/__snapshots__/diff_box_large_line_numbers.snap @@ -0,0 +1,121 @@ +--- +title: diff_box_large_line_numbers +test_name: TestDiffSnapshotBox_VisualRegression_LargeLineNumbers +file_name: boxes_test.go +version: 0.1.0 +--- +─── Snapshot Diff ───────────────────────────────────────────────────────────────────────────────────────────────────────── + + title: Large Line Numbers + test: TestVisualLarge + file: testvisuallarge.snap + +──────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────── +  1 │ line 1 +  2 │ line 2 +  3 │ line 3 +  4 │ line 4 +  5 │ line 5 +  6 │ line 6 +  7 │ line 7 +  8 │ line 8 +  9 │ line 9 +  10 │ line 10 +  11 │ line 11 +  12 │ line 12 +  13 │ line 13 +  14 │ line 14 +  15 │ line 15 +  16 │ line 16 +  17 │ line 17 +  18 │ line 18 +  19 │ line 19 +  20 │ line 20 +  21 │ line 21 +  22 │ line 22 +  23 │ line 23 +  24 │ line 24 +  25 │ line 25 +  26 │ line 26 +  27 │ line 27 +  28 │ line 28 +  29 │ line 29 +  30 │ line 30 +  31 │ line 31 +  32 │ line 32 +  33 │ line 33 +  34 │ line 34 +  35 │ line 35 +  36 │ line 36 +  37 │ line 37 +  38 │ line 38 +  39 │ line 39 +  40 │ line 40 +  41 │ line 41 +  42 │ line 42 +  43 │ line 43 +  44 │ line 44 +  45 │ line 45 +  46 │ line 46 +  47 │ line 47 +  48 │ line 48 +  49 │ line 49 +  50 - old line 50 +  50 + new line 50 +  51 │ line 51 +  52 │ line 52 +  53 │ line 53 +  54 │ line 54 +  55 │ line 55 +  56 │ line 56 +  57 │ line 57 +  58 │ line 58 +  59 │ line 59 +  60 │ line 60 +  61 │ line 61 +  62 │ line 62 +  63 │ line 63 +  64 │ line 64 +  65 │ line 65 +  66 │ line 66 +  67 │ line 67 +  68 │ line 68 +  69 │ line 69 +  70 │ line 70 +  71 │ line 71 +  72 │ line 72 +  73 │ line 73 +  74 │ line 74 +  75 │ line 75 +  76 │ line 76 +  77 │ line 77 +  78 │ line 78 +  79 │ line 79 +  80 │ line 80 +  81 │ line 81 +  82 │ line 82 +  83 │ line 83 +  84 │ line 84 +  85 │ line 85 +  86 │ line 86 +  87 │ line 87 +  88 │ line 88 +  89 │ line 89 +  90 │ line 90 +  91 │ line 91 +  92 │ line 92 +  93 │ line 93 +  94 │ line 94 +  95 │ line 95 +  96 │ line 96 +  97 │ line 97 +  98 │ line 98 +  99 │ line 99 + 100 - old line 100 + 100 + new line 100 + 101 │ line 101 + 102 │ line 102 + 103 │ line 103 + 104 │ line 104 + 105 │ line 105 +──────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/internal/pretty/__snapshots__/diff_box_simple_modification.snap b/internal/pretty/__snapshots__/diff_box_simple_modification.snap new file mode 100644 index 0000000..25a8eb5 --- /dev/null +++ b/internal/pretty/__snapshots__/diff_box_simple_modification.snap @@ -0,0 +1,18 @@ +--- +title: diff_box_simple_modification +test_name: TestDiffSnapshotBox_VisualRegression_SimpleModification +file_name: boxes_test.go +version: 0.1.0 +--- +─── Snapshot Diff ───────────────────────────────────────────────────────────────────────────────────── + + title: Visual Test + test: TestVisualSimple + file: testvisualsimple.snap + +──────┬───────────────────────────────────────────────────────────────────────────────────────────────── + 1 │ line1 + 2 - line2 + 2 + modified + 3 │ line3 +──────┴───────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/internal/pretty/__snapshots__/new_snapshot_box.snap b/internal/pretty/__snapshots__/new_snapshot_box.snap new file mode 100644 index 0000000..c7836dc --- /dev/null +++ b/internal/pretty/__snapshots__/new_snapshot_box.snap @@ -0,0 +1,19 @@ +--- +title: new_snapshot_box +test_name: TestNewSnapshotBox_VisualRegression +file_name: boxes_test.go +version: 0.1.0 +--- +─── New Snapshot ───────────────────────────────────────────────────────────────────────────────────── + + title: New Snapshot Visual + test: TestNewVisual + file: test_new_visual.snap + +────┬───────────────────────────────────────────────────────────────────────────────────────────────── + 1 + line1 + 2 + line2 + 3 + line3 + 4 + line4 + 5 + line5 +────┴───────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/internal/pretty/boxes.go b/internal/pretty/boxes.go index 33105bd..87bef66 100644 --- a/internal/pretty/boxes.go +++ b/internal/pretty/boxes.go @@ -2,7 +2,6 @@ package pretty import ( "fmt" - "strconv" "strings" "github.com/ptdewey/shutter/internal/diff" @@ -13,6 +12,25 @@ func NewSnapshotBox(snap *files.Snapshot) string { return newSnapshotBoxInternal(snap) } +// calculateLineNumWidth returns the width needed to display line numbers +func calculateLineNumWidth(maxLineNum int) int { + return len(fmt.Sprintf("%d", maxLineNum)) +} + +// formatColoredLine applies color to a line based on diff kind +func formatColoredLine(line string, kind diff.DiffKind) string { + switch kind { + case diff.DiffOld: + return Red(line) + case diff.DiffNew: + return Green(line) + case diff.DiffShared: + return line + default: + return line + } +} + func DiffSnapshotBox(old, newSnapshot *files.Snapshot, diffLines []diff.DiffLine) string { width := TerminalWidth() snapshotFileName := files.SnapshotFileName(newSnapshot.Test) + ".snap" @@ -49,7 +67,7 @@ func DiffSnapshotBox(old, newSnapshot *files.Snapshot, diffLines []diff.DiffLine if maxNewNum > maxLineNum { maxLineNum = maxNewNum } - lineNumWidth := len(fmt.Sprintf("%d", maxLineNum)) + lineNumWidth := calculateLineNumWidth(maxLineNum) // Top bar with corner (account for both line number columns) topBar := strings.Repeat("─", (lineNumWidth*2)+4) + "┬" + @@ -86,14 +104,7 @@ func DiffSnapshotBox(old, newSnapshot *files.Snapshot, diffLines []diff.DiffLine maxContentWidth := width - (lineNumWidth * 2) - 8 if len(dl.Line) > maxContentWidth { truncated := dl.Line[:maxContentWidth-3] + "..." - switch dl.Kind { - case diff.DiffOld: - formatted = Red(truncated) - case diff.DiffNew: - formatted = Green(truncated) - case diff.DiffShared: - formatted = truncated - } + formatted = formatColoredLine(truncated, dl.Kind) } display := fmt.Sprintf("%s %s %s %s", leftNum, rightNum, prefix, formatted) @@ -116,21 +127,18 @@ func newSnapshotBoxInternal(snap *files.Snapshot) string { if snap.Title != "" { sb.WriteString(Blue(" title: ") + snap.Title + "\n") - // sb.WriteString(fmt.Sprintf(" title: %s\n", Blue(snap.Title))) } if snap.Test != "" { - // sb.WriteString(fmt.Sprintf(" test: %s\n", Blue(snap.Test))) sb.WriteString(Blue(" test: ") + snap.Test + "\n") } if snap.FileName != "" { - // sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snap.FileName))) sb.WriteString(Blue(" file: ") + snap.FileName + "\n") } sb.WriteString("\n") lines := strings.Split(snap.Content, "\n") numLines := len(lines) - lineNumWidth := len(strconv.Itoa(numLines)) + lineNumWidth := calculateLineNumWidth(numLines) topBar := strings.Repeat("─", lineNumWidth+3) + "┬" + strings.Repeat("─", width-lineNumWidth-2) + "\n" diff --git a/internal/pretty/boxes_test.go b/internal/pretty/boxes_test.go new file mode 100644 index 0000000..d9f8957 --- /dev/null +++ b/internal/pretty/boxes_test.go @@ -0,0 +1,1008 @@ +package pretty_test + +import ( + "fmt" + "math/rand" + "os" + "strings" + "testing" + + "github.com/ptdewey/shutter" + "github.com/ptdewey/shutter/internal/diff" + "github.com/ptdewey/shutter/internal/files" + "github.com/ptdewey/shutter/internal/pretty" +) + +// BoxValidation holds expected properties for validation +type BoxValidation struct { + // Title and filename expectations + Title string + TestName string + FileName string + HasTitle bool + HasTestName bool + HasFileName bool + + // Diff line expectations + ExpectedAdds []string // Lines that should appear as additions (green +) + ExpectedDeletes []string // Lines that should appear as deletions (red -) + ExpectedContext []string // Lines that should appear as context (gray │) + + // Structural expectations + HasTopBar bool + HasBottomBar bool + MinLines int // Minimum number of content lines expected +} + +// ValidateDiffBox checks that a diff box output matches expectations +func ValidateDiffBox(t *testing.T, output string, validation BoxValidation) { + t.Helper() + + // Remove ANSI codes for easier content checking + stripped := stripANSI(output) + + // Check title/test/filename presence + if validation.HasTitle { + if !strings.Contains(stripped, "title: "+validation.Title) { + t.Errorf("Expected title '%s' not found in output", validation.Title) + } + } + + if validation.HasTestName { + if !strings.Contains(stripped, "test: "+validation.TestName) { + t.Errorf("Expected test name '%s' not found in output", validation.TestName) + } + } + + if validation.HasFileName { + if !strings.Contains(stripped, "file: "+validation.FileName) { + t.Errorf("Expected file name '%s' not found in output", validation.FileName) + } + } + + // Check for box structure + if validation.HasTopBar { + if !strings.Contains(stripped, "┬") { + t.Error("Expected top bar with ┬ character") + } + } + + if validation.HasBottomBar { + if !strings.Contains(stripped, "┴") { + t.Error("Expected bottom bar with ┴ character") + } + } + + // Check expected additions (green + lines) + for _, expectedAdd := range validation.ExpectedAdds { + if !containsDiffLine(output, "+", expectedAdd) { + t.Errorf("Expected addition not found: + %s", expectedAdd) + } + } + + // Check expected deletions (red - lines) + for _, expectedDelete := range validation.ExpectedDeletes { + if !containsDiffLine(output, "-", expectedDelete) { + t.Errorf("Expected deletion not found: - %s", expectedDelete) + } + } + + // Check expected context (shared lines) + for _, expectedContext := range validation.ExpectedContext { + if !containsDiffLine(output, "│", expectedContext) { + t.Errorf("Expected context line not found: │ %s", expectedContext) + } + } + + // Check minimum line count + if validation.MinLines > 0 { + lines := strings.Split(output, "\n") + contentLines := countContentLines(lines) + if contentLines < validation.MinLines { + t.Errorf("Expected at least %d content lines, got %d", validation.MinLines, contentLines) + } + } +} + +// containsDiffLine checks if a line with the given prefix and content exists +func containsDiffLine(output, prefix, content string) bool { + lines := strings.Split(output, "\n") + stripped := stripANSI(output) + strippedLines := strings.Split(stripped, "\n") + + for i, line := range strippedLines { + // Check if line contains the prefix and content + if strings.Contains(line, prefix) && strings.Contains(line, content) { + // Verify the original line has proper coloring + originalLine := lines[i] + switch prefix { + case "+": + // Green additions should have ANSI codes + if !strings.Contains(originalLine, "\033[") { + continue // Skip if no color + } + case "-": + // Red deletions should have ANSI codes + if !strings.Contains(originalLine, "\033[") { + continue + } + case "│": + // Context lines may or may not have color + } + return true + } + } + return false +} + +// countContentLines counts lines that contain diff content (not headers/borders) +func countContentLines(lines []string) int { + count := 0 + for _, line := range lines { + stripped := stripANSI(line) + // Content lines have line numbers followed by +, -, or │ + if strings.Contains(stripped, "+") || + strings.Contains(stripped, "-") || + strings.Contains(stripped, "│") { + count++ + } + } + return count +} + +// stripANSI removes ANSI escape codes from a string +func stripANSI(s string) string { + var result strings.Builder + inEscape := false + for _, r := range s { + if r == '\033' { + inEscape = true + continue + } + if inEscape { + if r == 'm' { + inEscape = false + } + continue + } + result.WriteRune(r) + } + return result.String() +} + +// TestDiffSnapshotBox_SimpleModification tests a basic modification scenario +func TestDiffSnapshotBox_SimpleModification(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "line1\nline2\nline3" + newContent := "line1\nmodified\nline3" + + oldSnap := &files.Snapshot{ + Title: "Simple Modification", + Test: "TestSimple", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Simple Modification", + Test: "TestSimple", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Simple Modification", + TestName: "TestSimple", + FileName: "testsimple.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{"modified"}, + ExpectedDeletes: []string{"line2"}, + ExpectedContext: []string{"line1", "line3"}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 4, // 1 shared + 1 delete + 1 add + 1 shared + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_PureAddition tests adding lines only +func TestDiffSnapshotBox_PureAddition(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "line1\nline2" + newContent := "line1\nline2\nline3\nline4" + + oldSnap := &files.Snapshot{ + Title: "Pure Addition", + Test: "TestAddition", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Pure Addition", + Test: "TestAddition", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Pure Addition", + TestName: "TestAddition", + FileName: "testaddition.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{"line3", "line4"}, + ExpectedDeletes: []string{}, + ExpectedContext: []string{"line1", "line2"}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 4, // 2 shared + 2 adds + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_PureDeletion tests deleting lines only +func TestDiffSnapshotBox_PureDeletion(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "line1\nline2\nline3\nline4" + newContent := "line1\nline2" + + oldSnap := &files.Snapshot{ + Title: "Pure Deletion", + Test: "TestDeletion", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Pure Deletion", + Test: "TestDeletion", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Pure Deletion", + TestName: "TestDeletion", + FileName: "testdeletion.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{}, + ExpectedDeletes: []string{"line3", "line4"}, + ExpectedContext: []string{"line1", "line2"}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 4, // 2 shared + 2 deletes + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_ComplexMixed tests multiple types of changes +func TestDiffSnapshotBox_ComplexMixed(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + oldContent := `unchanged1 +delete1 +delete2 +unchanged2 +modify_old +unchanged3` + + newContent := `unchanged1 +unchanged2 +modify_new +add1 +unchanged3 +add2` + + oldSnap := &files.Snapshot{ + Title: "Complex Mixed", + Test: "TestComplexMixed", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Complex Mixed", + Test: "TestComplexMixed", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Complex Mixed", + TestName: "TestComplexMixed", + FileName: "testcomplexmixed.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{"modify_new", "add1", "add2"}, + ExpectedDeletes: []string{"delete1", "delete2", "modify_old"}, + ExpectedContext: []string{"unchanged1", "unchanged2", "unchanged3"}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 9, // 3 shared + 3 deletes + 3 adds + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_EmptyOld tests diff from empty to content +func TestDiffSnapshotBox_EmptyOld(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "" + newContent := "line1\nline2\nline3" + + oldSnap := &files.Snapshot{ + Title: "Empty to Content", + Test: "TestEmptyOld", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Empty to Content", + Test: "TestEmptyOld", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Empty to Content", + TestName: "TestEmptyOld", + FileName: "testemptyold.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{"line1", "line2", "line3"}, + ExpectedDeletes: []string{}, + ExpectedContext: []string{}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 3, // 3 adds + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_EmptyNew tests diff from content to empty +func TestDiffSnapshotBox_EmptyNew(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "line1\nline2\nline3" + newContent := "" + + oldSnap := &files.Snapshot{ + Title: "Content to Empty", + Test: "TestEmptyNew", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Content to Empty", + Test: "TestEmptyNew", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Content to Empty", + TestName: "TestEmptyNew", + FileName: "testemptynew.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{}, + ExpectedDeletes: []string{"line1", "line2", "line3"}, + ExpectedContext: []string{}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 3, // 3 deletes + } + + ValidateDiffBox(t, result, validation) +} + +// TestDiffSnapshotBox_NoTitle tests snapshot without title +func TestDiffSnapshotBox_NoTitle(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "old" + newContent := "new" + + oldSnap := &files.Snapshot{ + Title: "", + Test: "TestNoTitle", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "", + Test: "TestNoTitle", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + stripped := stripANSI(result) + + // Should NOT contain "title:" line + if strings.Contains(stripped, "title:") { + t.Error("Expected no title line when title is empty") + } + + // Should still contain test and file + if !strings.Contains(stripped, "test: TestNoTitle") { + t.Error("Expected test name to be present") + } + if !strings.Contains(stripped, "file: testnotitle.snap") { + t.Error("Expected file name to be present") + } +} + +// TestDiffSnapshotBox_LargeLineNumbers tests proper padding for multi-digit line numbers +func TestDiffSnapshotBox_LargeLineNumbers(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + // Create content with 100+ lines to test 3-digit line numbers + oldLines := make([]string, 105) + newLines := make([]string, 105) + for i := 0; i < 105; i++ { + oldLines[i] = fmt.Sprintf("line %d", i+1) + newLines[i] = fmt.Sprintf("line %d", i+1) + } + // Modify lines 50, 75, and 100 + oldLines[49] = "old line 50" + newLines[49] = "new line 50" + oldLines[74] = "old line 75" + newLines[74] = "new line 75" + oldLines[99] = "old line 100" + newLines[99] = "new line 100" + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + oldSnap := &files.Snapshot{ + Title: "Large Line Numbers", + Test: "TestLargeLineNumbers", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Large Line Numbers", + Test: "TestLargeLineNumbers", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + stripped := stripANSI(result) + + // Check that 3-digit line numbers appear + if !strings.Contains(stripped, "100") { + t.Error("Expected 3-digit line number 100 to appear") + } + + // Validate line number alignment by checking that numbers are right-aligned + // Line 1 should have padding for 3 digits + lines := strings.Split(stripped, "\n") + foundSingleDigit := false + foundTripleDigit := false + + for _, line := range lines { + // Look for lines with content markers + if strings.Contains(line, "│") || strings.Contains(line, "+") || strings.Contains(line, "-") { + // Single digit should have padding (e.g., " 1" or " 2") + if strings.Contains(line, " 1 ") || strings.Contains(line, " 2 ") { + foundSingleDigit = true + } + // Triple digit should align (e.g., "100" or "105") + if strings.Contains(line, "100 ") || strings.Contains(line, "105 ") { + foundTripleDigit = true + } + } + } + + if !foundSingleDigit { + t.Error("Expected to find padded single-digit line numbers") + } + if !foundTripleDigit { + t.Error("Expected to find triple-digit line numbers") + } +} + +// TestDiffSnapshotBox_UnicodeContent tests diff with unicode characters +func TestDiffSnapshotBox_UnicodeContent(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "Hello 世界\nこんにちは\n🎉 emoji" + newContent := "Hello 世界\nさようなら\n🎊 party" + + oldSnap := &files.Snapshot{ + Title: "Unicode Test", + Test: "TestUnicode", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Unicode Test", + Test: "TestUnicode", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + validation := BoxValidation{ + Title: "Unicode Test", + TestName: "TestUnicode", + FileName: "testunicode.snap", + HasTitle: true, + HasTestName: true, + HasFileName: true, + ExpectedAdds: []string{"さようなら", "🎊 party"}, + ExpectedDeletes: []string{"こんにちは", "🎉 emoji"}, + ExpectedContext: []string{"Hello 世界"}, + HasTopBar: true, + HasBottomBar: true, + MinLines: 5, // 1 shared + 2 deletes + 2 adds + } + + ValidateDiffBox(t, result, validation) +} + +// TestNewSnapshotBox_Basic tests the new snapshot box rendering +func TestNewSnapshotBox_Basic(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + content := "line1\nline2\nline3" + + snap := &files.Snapshot{ + Title: "New Snapshot", + Test: "TestNewSnapshot", + FileName: "test_new.snap", + Content: content, + } + + result := pretty.NewSnapshotBox(snap) + + stripped := stripANSI(result) + + // Check header + if !strings.Contains(stripped, "New Snapshot") { + t.Error("Expected 'New Snapshot' header") + } + + // Check metadata + if !strings.Contains(stripped, "title: New Snapshot") { + t.Error("Expected title in output") + } + if !strings.Contains(stripped, "test: TestNewSnapshot") { + t.Error("Expected test name in output") + } + if !strings.Contains(stripped, "file: test_new.snap") { + t.Error("Expected file name in output") + } + + // Check content lines (all should be green additions) + if !containsDiffLine(result, "+", "line1") { + t.Error("Expected line1 as addition") + } + if !containsDiffLine(result, "+", "line2") { + t.Error("Expected line2 as addition") + } + if !containsDiffLine(result, "+", "line3") { + t.Error("Expected line3 as addition") + } + + // Check box structure + if !strings.Contains(stripped, "┬") { + t.Error("Expected top bar with ┬") + } + if !strings.Contains(stripped, "┴") { + t.Error("Expected bottom bar with ┴") + } +} + +// TestNewSnapshotBox_EmptyContent tests new snapshot with empty content +func TestNewSnapshotBox_EmptyContent(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + snap := &files.Snapshot{ + Title: "Empty Snapshot", + Test: "TestEmpty", + FileName: "test_empty.snap", + Content: "", + } + + result := pretty.NewSnapshotBox(snap) + + // Should still render box with metadata, just no content lines + stripped := stripANSI(result) + + if !strings.Contains(stripped, "title: Empty Snapshot") { + t.Error("Expected title in output") + } + + // Should have box structure even with empty content + if !strings.Contains(stripped, "┬") { + t.Error("Expected top bar with ┬") + } + if !strings.Contains(stripped, "┴") { + t.Error("Expected bottom bar with ┴") + } +} + +// Snapshot testing for visual regression + +func TestDiffSnapshotBox_VisualRegression_SimpleModification(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + oldContent := "line1\nline2\nline3" + newContent := "line1\nmodified\nline3" + + oldSnap := &files.Snapshot{ + Title: "Visual Test", + Test: "TestVisualSimple", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Visual Test", + Test: "TestVisualSimple", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + shutter.SnapString(t, "diff_box_simple_modification", result) +} + +func TestDiffSnapshotBox_VisualRegression_ComplexMixed(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + oldContent := `unchanged1 +delete1 +delete2 +unchanged2 +modify_old +unchanged3` + + newContent := `unchanged1 +unchanged2 +modify_new +add1 +unchanged3 +add2` + + oldSnap := &files.Snapshot{ + Title: "Visual Complex", + Test: "TestVisualComplex", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Visual Complex", + Test: "TestVisualComplex", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + shutter.SnapString(t, "diff_box_complex_mixed", result) +} + +func TestDiffSnapshotBox_VisualRegression_LargeLineNumbers(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + // Create content with 100+ lines + oldLines := make([]string, 105) + newLines := make([]string, 105) + for i := 0; i < 105; i++ { + oldLines[i] = fmt.Sprintf("line %d", i+1) + newLines[i] = fmt.Sprintf("line %d", i+1) + } + oldLines[49] = "old line 50" + newLines[49] = "new line 50" + oldLines[99] = "old line 100" + newLines[99] = "new line 100" + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + oldSnap := &files.Snapshot{ + Title: "Large Line Numbers", + Test: "TestVisualLarge", + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: "Large Line Numbers", + Test: "TestVisualLarge", + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + shutter.SnapString(t, "diff_box_large_line_numbers", result) +} + +func TestNewSnapshotBox_VisualRegression(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "100") + defer os.Unsetenv("COLUMNS") + + content := "line1\nline2\nline3\nline4\nline5" + + snap := &files.Snapshot{ + Title: "New Snapshot Visual", + Test: "TestNewVisual", + FileName: "test_new_visual.snap", + Content: content, + } + + result := pretty.NewSnapshotBox(snap) + + shutter.SnapString(t, "new_snapshot_box", result) +} + +// Randomized testing + +func TestDiffSnapshotBox_Random_Additions(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + rng := rand.New(rand.NewSource(12345)) // Fixed seed for reproducibility + + for i := 0; i < 10; i++ { + t.Run(fmt.Sprintf("random_addition_%d", i), func(t *testing.T) { + // Generate random number of old lines (5-20) + numOldLines := rng.Intn(16) + 5 + oldLines := make([]string, numOldLines) + for j := 0; j < numOldLines; j++ { + oldLines[j] = fmt.Sprintf("old_line_%d", j+1) + } + + // Add random number of new lines (1-10) + numNewLines := rng.Intn(10) + 1 + newLines := make([]string, numOldLines+numNewLines) + copy(newLines, oldLines) + for j := 0; j < numNewLines; j++ { + newLines[numOldLines+j] = fmt.Sprintf("new_line_%d", j+1) + } + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + oldSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Addition %d", i), + Test: fmt.Sprintf("TestRandomAdd_%d", i), + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Addition %d", i), + Test: fmt.Sprintf("TestRandomAdd_%d", i), + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + // Validate structure + stripped := stripANSI(result) + + // Should have box structure + if !strings.Contains(stripped, "┬") { + t.Error("Missing top bar") + } + if !strings.Contains(stripped, "┴") { + t.Error("Missing bottom bar") + } + + // Should contain title and test name + if !strings.Contains(stripped, fmt.Sprintf("Random Addition %d", i)) { + t.Error("Missing title") + } + + // Count additions + addCount := strings.Count(result, "+") + if addCount < numNewLines { + t.Errorf("Expected at least %d additions, got %d", numNewLines, addCount) + } + }) + } +} + +func TestDiffSnapshotBox_Random_Deletions(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "120") + defer os.Unsetenv("COLUMNS") + + rng := rand.New(rand.NewSource(54321)) + + for i := 0; i < 10; i++ { + t.Run(fmt.Sprintf("random_deletion_%d", i), func(t *testing.T) { + // Generate random number of old lines (10-30) + numOldLines := rng.Intn(21) + 10 + oldLines := make([]string, numOldLines) + for j := 0; j < numOldLines; j++ { + oldLines[j] = fmt.Sprintf("line_%d", j+1) + } + + // Delete random number of lines (1-5) + numToDelete := rng.Intn(5) + 1 + if numToDelete > numOldLines { + numToDelete = numOldLines / 2 + } + newLines := make([]string, numOldLines-numToDelete) + copy(newLines, oldLines[:len(newLines)]) + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + oldSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Deletion %d", i), + Test: fmt.Sprintf("TestRandomDel_%d", i), + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Deletion %d", i), + Test: fmt.Sprintf("TestRandomDel_%d", i), + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + // Validate structure + stripped := stripANSI(result) + + if !strings.Contains(stripped, "┬") { + t.Error("Missing top bar") + } + if !strings.Contains(stripped, "┴") { + t.Error("Missing bottom bar") + } + + // Count deletions (at least numToDelete should appear) + delCount := strings.Count(result, "-") + if delCount < numToDelete { + t.Errorf("Expected at least %d deletions, got %d", numToDelete, delCount) + } + }) + } +} + +func TestDiffSnapshotBox_Random_Mixed(t *testing.T) { + os.Unsetenv("NO_COLOR") + os.Setenv("COLUMNS", "140") + defer os.Unsetenv("COLUMNS") + + rng := rand.New(rand.NewSource(99999)) + + for i := 0; i < 10; i++ { + t.Run(fmt.Sprintf("random_mixed_%d", i), func(t *testing.T) { + // Generate random old content (10-30 lines) + numOldLines := rng.Intn(21) + 10 + oldLines := make([]string, numOldLines) + for j := 0; j < numOldLines; j++ { + oldLines[j] = fmt.Sprintf("old_line_%d_%s", j+1, randomWord(rng)) + } + + // Randomly modify, add, delete + newLines := make([]string, 0, numOldLines*2) + for j := 0; j < numOldLines; j++ { + action := rng.Intn(100) + if action < 70 { // 70% keep unchanged + newLines = append(newLines, oldLines[j]) + } else if action < 85 { // 15% modify + newLines = append(newLines, fmt.Sprintf("modified_%d_%s", j+1, randomWord(rng))) + } else if action < 95 { // 10% add + newLines = append(newLines, oldLines[j]) + newLines = append(newLines, fmt.Sprintf("added_%d_%s", j+1, randomWord(rng))) + } + // 5% delete (skip adding line) + } + + oldContent := strings.Join(oldLines, "\n") + newContent := strings.Join(newLines, "\n") + + oldSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Mixed %d", i), + Test: fmt.Sprintf("TestRandomMixed_%d", i), + Content: oldContent, + } + + newSnap := &files.Snapshot{ + Title: fmt.Sprintf("Random Mixed %d", i), + Test: fmt.Sprintf("TestRandomMixed_%d", i), + Content: newContent, + } + + diffLines := diff.Histogram(oldContent, newContent) + result := pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) + + // Validate basic structure + stripped := stripANSI(result) + + if !strings.Contains(stripped, "┬") { + t.Error("Missing top bar") + } + if !strings.Contains(stripped, "┴") { + t.Error("Missing bottom bar") + } + if !strings.Contains(stripped, fmt.Sprintf("Random Mixed %d", i)) { + t.Error("Missing title") + } + + // Should have some diff markers + hasPlus := strings.Contains(result, "+") + hasMinus := strings.Contains(result, "-") + hasPipe := strings.Contains(result, "│") + + if !hasPlus && !hasMinus && !hasPipe { + t.Error("Expected at least one type of diff marker") + } + }) + } +} + +// Helper function for random word generation +func randomWord(rng *rand.Rand) string { + words := []string{"apple", "banana", "cherry", "date", "elderberry", "fig", "grape", "honeydew"} + return words[rng.Intn(len(words))] +} diff --git a/internal/pretty/pretty.go b/internal/pretty/pretty.go index 0888458..50e765d 100644 --- a/internal/pretty/pretty.go +++ b/internal/pretty/pretty.go @@ -23,50 +23,40 @@ func TerminalWidth() int { return 80 } -func Red(s string) string { +func hasColor() bool { + return os.Getenv("NO_COLOR") == "" +} + +// colorize wraps text with the given color code +func colorize(s, code string) string { if !hasColor() { return s } - return colorRed + s + colorReset + return code + s + colorReset +} + +func Red(s string) string { + return colorize(s, colorRed) } func Green(s string) string { - if !hasColor() { - return s - } - return colorGreen + s + colorReset + return colorize(s, colorGreen) } func Yellow(s string) string { - if !hasColor() { - return s - } - return colorYellow + s + colorReset + return colorize(s, colorYellow) } func Blue(s string) string { - if !hasColor() { - return s - } - return colorBlue + s + colorReset + return colorize(s, colorBlue) } func Gray(s string) string { - if !hasColor() { - return s - } - return colorGray + s + colorReset + return colorize(s, colorGray) } func Bold(s string) string { - if !hasColor() { - return s - } - return colorBold + s + colorReset -} - -func hasColor() bool { - return os.Getenv("NO_COLOR") == "" + return colorize(s, colorBold) } func Header(text string) string { diff --git a/internal/review/review.go b/internal/review/review.go index 487620d..f7ef398 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -27,6 +27,18 @@ func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { return diff.Histogram(old.Content, new.Content) } +// applyToSnapshots applies an operation to all snapshots and returns the count of successful operations +func applyToSnapshots(snapshots []string, operation func(string) error) (int, error) { + successCount := 0 + for _, snapTitle := range snapshots { + if err := operation(snapTitle); err != nil { + return successCount, err + } + successCount++ + } + return successCount, nil +} + func Review() error { snapshots, err := files.ListNewSnapshots() if err != nil { @@ -87,20 +99,20 @@ func reviewLoop(snapshots []string) error { case Skip: fmt.Println(pretty.Warning("⊘ Snapshot skipped")) case AcceptAllChoice: - for j := i; j < len(snapshots); j++ { - if err := files.AcceptSnapshot(snapshots[j]); err != nil { - fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) - } + remaining := snapshots[i:] + if _, err := applyToSnapshots(remaining, files.AcceptSnapshot); err != nil { + fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) + return err } - fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)-i) + fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(remaining)) return nil case RejectAllChoice: - for j := i; j < len(snapshots); j++ { - if err := files.RejectSnapshot(snapshots[j]); err != nil { - fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) - } + remaining := snapshots[i:] + if _, err := applyToSnapshots(remaining, files.RejectSnapshot); err != nil { + fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) + return err } - fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)-i) + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(remaining)) return nil case SkipAllChoice: fmt.Printf(pretty.Warning("⊘ Skipped %d snapshot(s)\n"), len(snapshots)-i) @@ -154,13 +166,12 @@ func AcceptAll() error { return err } - for _, testName := range snapshots { - if err := files.AcceptSnapshot(testName); err != nil { - return err - } + count, err := applyToSnapshots(snapshots, files.AcceptSnapshot) + if err != nil { + return err } - fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) + fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), count) return nil } @@ -170,12 +181,11 @@ func RejectAll() error { return err } - for _, testName := range snapshots { - if err := files.RejectSnapshot(testName); err != nil { - return err - } + count, err := applyToSnapshots(snapshots, files.RejectSnapshot) + if err != nil { + return err } - fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), count) return nil }