diff --git a/__snapshots__/test_accept.snap b/__snapshots__/test_accept.snap new file mode 100644 index 0000000..3b601ee --- /dev/null +++ b/__snapshots__/test_accept.snap @@ -0,0 +1,5 @@ +--- +version: 0.1.0 +test_name: TestAccept +--- +new content to accept \ No newline at end of file diff --git a/freeze.go b/freeze.go index 1e4c10b..3b14add 100644 --- a/freeze.go +++ b/freeze.go @@ -7,6 +7,7 @@ import ( "github.com/ptdewey/freeze/internal/diff" "github.com/ptdewey/freeze/internal/files" "github.com/ptdewey/freeze/internal/pretty" + "github.com/ptdewey/freeze/internal/review" ) const version = "0.1.0" @@ -126,3 +127,15 @@ func formatValue(v any) string { // } return utter.Sdump(v) } + +func Review() error { + return review.Review() +} + +func AcceptAll() error { + return review.AcceptAll() +} + +func RejectAll() error { + return review.RejectAll() +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000..da287cd --- /dev/null +++ b/internal/diff/diff_test.go @@ -0,0 +1,182 @@ +package diff_test + +import ( + "testing" + + "github.com/ptdewey/freeze/internal/diff" +) + +func TestHistogramEmpty(t *testing.T) { + both := diff.Histogram("", "") + if both != nil { + t.Errorf("empty strings should return nil, got %v", both) + } + + oldEmpty := diff.Histogram("", "hello") + if oldEmpty == nil { + t.Errorf("new content should return non-nil") + } + + newEmpty := diff.Histogram("hello", "") + if newEmpty == nil { + t.Errorf("old content should return non-nil") + } +} + +func TestHistogramIdentical(t *testing.T) { + old := "line1\nline2\nline3" + new := "line1\nline2\nline3" + + result := diff.Histogram(old, new) + + if len(result) != 3 { + t.Errorf("expected 3 diff lines, got %d", len(result)) + } + + for i, dl := range result { + if dl.Kind != diff.DiffShared { + t.Errorf("line %d: expected DiffShared, got %v", i, dl.Kind) + } + if dl.Number != i+1 { + t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) + } + } +} + +func TestHistogramCompletelyDifferent(t *testing.T) { + old := "old content" + new := "new content" + + result := diff.Histogram(old, new) + + if len(result) != 2 { + t.Errorf("expected 2 diff lines, got %d", len(result)) + } + + hasOld := false + hasNew := false + for _, dl := range result { + if dl.Kind == diff.DiffOld { + hasOld = true + } + if dl.Kind == diff.DiffNew { + hasNew = true + } + } + + if !hasOld || !hasNew { + t.Error("expected both old and new diff kinds") + } +} + +func TestHistogramSingleLineChange(t *testing.T) { + old := "line1\nline2\nline3" + new := "line1\nmodified\nline3" + + result := diff.Histogram(old, new) + + if len(result) < 3 { + t.Errorf("expected at least 3 diff lines, got %d", len(result)) + } + + if result[0].Kind != diff.DiffShared || result[0].Line != "line1" { + t.Errorf("line 0: expected shared 'line1', got %v %s", result[0].Kind, result[0].Line) + } + + hasModified := false + for _, dl := range result { + if dl.Line == "modified" { + hasModified = true + if dl.Kind != diff.DiffNew { + t.Errorf("'modified' should be marked as new, got %v", dl.Kind) + } + } + } + if !hasModified { + t.Error("diff missing 'modified' line") + } +} + +func TestHistogramAddLine(t *testing.T) { + old := "line1\nline2" + new := "line1\nline1.5\nline2" + + result := diff.Histogram(old, new) + + newCount := 0 + for _, dl := range result { + if dl.Kind == diff.DiffNew { + newCount++ + if dl.Line != "line1.5" { + t.Errorf("expected new line 'line1.5', got '%s'", dl.Line) + } + } + } + + if newCount != 1 { + t.Errorf("expected 1 new line, got %d", newCount) + } +} + +func TestHistogramRemoveLine(t *testing.T) { + old := "line1\nline2\nline3" + new := "line1\nline3" + + result := diff.Histogram(old, new) + + oldCount := 0 + for _, dl := range result { + if dl.Kind == diff.DiffOld { + oldCount++ + if dl.Line != "line2" { + t.Errorf("expected old line 'line2', got '%s'", dl.Line) + } + } + } + + if oldCount != 1 { + t.Errorf("expected 1 old line, got %d", oldCount) + } +} + +func TestHistogramLineNumbers(t *testing.T) { + old := "a\nb\nc" + new := "a\nb\nc" + + result := diff.Histogram(old, new) + + for i, dl := range result { + if dl.Number != i+1 { + t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) + } + } +} + +func TestHistogramMultilineChanges(t *testing.T) { + old := "start\nmiddle\nend" + new := "start\nnew1\nnew2\nend" + + result := diff.Histogram(old, new) + + newCount := 0 + for _, dl := range result { + if dl.Kind == diff.DiffNew { + newCount++ + } + } + + if newCount != 2 { + t.Errorf("expected 2 new lines, got %d", newCount) + } +} + +func TestHistogramWithEmptyLines(t *testing.T) { + old := "line1\n\nline3" + new := "line1\nline2\nline3" + + result := diff.Histogram(old, new) + + if len(result) == 0 { + t.Error("expected non-empty diff result") + } +} diff --git a/internal/files/files_test.go b/internal/files/files_test.go new file mode 100644 index 0000000..bb7398e --- /dev/null +++ b/internal/files/files_test.go @@ -0,0 +1,239 @@ +package files_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ptdewey/freeze/internal/files" +) + +func TestSnapshotFileName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"TestMyFunction", "test_my_function"}, + {"test_another_one", "test_another_one"}, + {"TestCamelCase", "test_camel_case"}, + {"TestWithNumbers123", "test_with_numbers123"}, + {"TestABC", "test_a_b_c"}, + {"test", "test"}, + {"TEST", "t_e_s_t"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := files.SnapshotFileName(tt.input) + if result != tt.expected { + t.Errorf("SnapshotFileName(%s) = %s, want %s", tt.input, result, tt.expected) + } + }) + } +} + +func TestSerializeDeserialize(t *testing.T) { + snap := &files.Snapshot{ + Version: "1.0.0", + Name: "TestExample", + Content: "test content\nmultiline", + } + + serialized := snap.Serialize() + expected := "---\nversion: 1.0.0\ntest_name: TestExample\n---\ntest content\nmultiline" + if serialized != expected { + t.Errorf("Serialize():\nexpected:\n%s\n\ngot:\n%s", expected, serialized) + } + + deserialized, err := files.Deserialize(serialized) + if err != nil { + t.Fatalf("Deserialize failed: %v", err) + } + + if deserialized.Version != snap.Version { + t.Errorf("Version mismatch: %s != %s", deserialized.Version, snap.Version) + } + if deserialized.Name != snap.Name { + t.Errorf("Name mismatch: %s != %s", deserialized.Name, snap.Name) + } + if deserialized.Content != snap.Content { + t.Errorf("Content mismatch: %s != %s", deserialized.Content, snap.Content) + } +} + +func TestDeserializeInvalidFormat(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"missing separators", "no separators here"}, + {"only one separator", "---\nno closing separator"}, + {"empty string", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := files.Deserialize(tt.input) + if err == nil { + t.Error("expected error for invalid format") + } + }) + } +} + +func TestDeserializeValidFormats(t *testing.T) { + tests := []struct { + name string + input string + wantVer string + wantTest string + wantContent string + }{ + { + "simple", + "---\nversion: 1.0\ntest_name: Test\n---\ncontent", + "1.0", + "Test", + "content", + }, + { + "multiline content", + "---\nversion: 0.1\ntest_name: MyTest\n---\nline1\nline2\nline3", + "0.1", + "MyTest", + "line1\nline2\nline3", + }, + { + "with extra fields", + "---\nversion: 1.0\ntest_name: Test\nextra: ignored\n---\ncontent", + "1.0", + "Test", + "content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snap, err := files.Deserialize(tt.input) + if err != nil { + t.Fatalf("Deserialize failed: %v", err) + } + if snap.Version != tt.wantVer { + t.Errorf("Version = %s, want %s", snap.Version, tt.wantVer) + } + if snap.Name != tt.wantTest { + t.Errorf("Name = %s, want %s", snap.Name, tt.wantTest) + } + if snap.Content != tt.wantContent { + t.Errorf("Content = %s, want %s", snap.Content, tt.wantContent) + } + }) + } +} + +func TestSaveAndReadSnapshot(t *testing.T) { + snap := &files.Snapshot{ + Version: "0.1.0", + Name: "TestSaveRead", + Content: "saved content", + } + + if err := files.SaveSnapshot(snap, "test"); err != nil { + t.Fatalf("SaveSnapshot failed: %v", err) + } + + read, err := files.ReadSnapshot("TestSaveRead", "test") + if err != nil { + t.Fatalf("ReadSnapshot failed: %v", err) + } + + if read.Content != snap.Content { + t.Errorf("Content mismatch: %s != %s", read.Content, snap.Content) + } + if read.Version != snap.Version { + t.Errorf("Version mismatch: %s != %s", read.Version, snap.Version) + } + + cleanupSnapshot(t, "TestSaveRead", "test") +} + +func TestReadSnapshotNotFound(t *testing.T) { + _, err := files.ReadSnapshot("NonExistentTest", "nonexistent") + if err == nil { + t.Error("expected error for non-existent snapshot") + } +} + +func TestAcceptSnapshot(t *testing.T) { + newSnap := &files.Snapshot{ + Version: "0.1.0", + Name: "TestAccept", + Content: "new content to accept", + } + + if err := files.SaveSnapshot(newSnap, "new"); err != nil { + t.Fatalf("SaveSnapshot failed: %v", err) + } + + if err := files.AcceptSnapshot("TestAccept"); err != nil { + t.Fatalf("AcceptSnapshot failed: %v", err) + } + + accepted, err := files.ReadSnapshot("TestAccept", "accepted") + if err != nil { + t.Fatalf("ReadSnapshot failed: %v", err) + } + + if accepted.Content != newSnap.Content { + t.Errorf("Content mismatch: %s != %s", accepted.Content, newSnap.Content) + } + + _, err = files.ReadSnapshot("TestAccept", "new") + if err == nil { + t.Error("expected error: .new file should be deleted after accept") + } + + cleanupSnapshot(t, "TestAccept", "accepted") +} + +func TestRejectSnapshot(t *testing.T) { + snap := &files.Snapshot{ + Version: "0.1.0", + Name: "TestReject", + Content: "content to reject", + } + + if err := files.SaveSnapshot(snap, "new"); err != nil { + t.Fatalf("SaveSnapshot failed: %v", err) + } + + if err := files.RejectSnapshot("TestReject"); err != nil { + t.Fatalf("RejectSnapshot failed: %v", err) + } + + _, err := files.ReadSnapshot("TestReject", "new") + if err == nil { + t.Error("expected error: .new file should be deleted after reject") + } +} + +func cleanupSnapshot(t *testing.T, testName, state string) { + t.Helper() + + root, err := os.Getwd() + if err != nil { + t.Logf("cleanup: failed to get cwd: %v", err) + return + } + + for root != "/" && root != "" { + if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil { + break + } + root = filepath.Dir(root) + } + + fileName := files.SnapshotFileName(testName) + "." + state + filePath := filepath.Join(root, "__snapshots__", fileName) + _ = os.Remove(filePath) +} diff --git a/internal/pretty/pretty_test.go b/internal/pretty/pretty_test.go new file mode 100644 index 0000000..82ad3f2 --- /dev/null +++ b/internal/pretty/pretty_test.go @@ -0,0 +1,166 @@ +package pretty_test + +import ( + "os" + "testing" + + "github.com/ptdewey/freeze/internal/pretty" +) + +func TestColorFunctionsWithColor(t *testing.T) { + os.Unsetenv("NO_COLOR") + + tests := []struct { + name string + fn func(string) string + text string + }{ + {"Red", pretty.Red, "error"}, + {"Green", pretty.Green, "success"}, + {"Yellow", pretty.Yellow, "warning"}, + {"Blue", pretty.Blue, "info"}, + {"Gray", pretty.Gray, "gray"}, + {"Bold", pretty.Bold, "bold"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.fn(tt.text) + if result == "" { + t.Errorf("%s returned empty string", tt.name) + } + if result == tt.text { + t.Errorf("%s did not add color codes", tt.name) + } + if !contains(result, tt.text) { + t.Errorf("%s does not contain original text", tt.name) + } + }) + } +} + +func TestColorFunctionsNoColor(t *testing.T) { + os.Setenv("NO_COLOR", "1") + defer os.Unsetenv("NO_COLOR") + + tests := []struct { + name string + fn func(string) string + text string + }{ + {"Red", pretty.Red, "error"}, + {"Green", pretty.Green, "success"}, + {"Yellow", pretty.Yellow, "warning"}, + {"Blue", pretty.Blue, "info"}, + {"Gray", pretty.Gray, "gray"}, + {"Bold", pretty.Bold, "bold"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.fn(tt.text) + if result != tt.text { + t.Errorf("%s should return plain text when NO_COLOR is set", tt.name) + } + }) + } +} + +func TestHeader(t *testing.T) { + os.Unsetenv("NO_COLOR") + + result := pretty.Header("test header") + if result == "" { + t.Error("Header returned empty string") + } + if result == "test header" { + t.Error("Header should apply formatting") + } + if !contains(result, "test header") { + t.Error("Header should contain original text") + } +} + +func TestSuccess(t *testing.T) { + os.Unsetenv("NO_COLOR") + + result := pretty.Success("success message") + if result == "" { + t.Error("Success returned empty string") + } + if !contains(result, "success message") { + t.Error("Success should contain original text") + } +} + +func TestError(t *testing.T) { + os.Unsetenv("NO_COLOR") + + result := pretty.Error("error message") + if result == "" { + t.Error("Error returned empty string") + } + if !contains(result, "error message") { + t.Error("Error should contain original text") + } +} + +func TestWarning(t *testing.T) { + os.Unsetenv("NO_COLOR") + + result := pretty.Warning("warning message") + if result == "" { + t.Error("Warning returned empty string") + } + if !contains(result, "warning message") { + t.Error("Warning should contain original text") + } +} + +func TestTerminalWidth(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + {"default", "", 80}, + {"valid width", "120", 120}, + {"invalid width", "invalid", 80}, + {"zero width", "0", 80}, + {"negative width", "-10", 80}, + {"large width", "1000", 1000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue == "" { + os.Unsetenv("COLUMNS") + } else { + os.Setenv("COLUMNS", tt.envValue) + } + defer os.Unsetenv("COLUMNS") + + result := pretty.TerminalWidth() + if result != tt.expected { + t.Errorf("TerminalWidth() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestClearScreen(t *testing.T) { + pretty.ClearScreen() +} + +func TestClearLine(t *testing.T) { + pretty.ClearLine() +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/review.go b/internal/review/review.go similarity index 99% rename from review.go rename to internal/review/review.go index 73b46be..9d3b4dd 100644 --- a/review.go +++ b/internal/review/review.go @@ -1,4 +1,4 @@ -package freeze +package review import ( "bufio"