diff --git a/internal/difflib/difflib_test.go b/internal/difflib/difflib_test.go index cedb6d7b7..8ab16ad25 100644 --- a/internal/difflib/difflib_test.go +++ b/internal/difflib/difflib_test.go @@ -237,3 +237,343 @@ func splitChars(s string) []string { return chars } + +// TestSequenceMatcherCaching tests that GetMatchingBlocks and GetOpCodes +// return cached results when called multiple times. +func TestSequenceMatcherCaching(t *testing.T) { + a := splitChars("abc") + b := splitChars("abd") + + sm := NewMatcher(a, b) + + // Call GetMatchingBlocks twice - second call should use cache + blocks1 := sm.GetMatchingBlocks() + blocks2 := sm.GetMatchingBlocks() + assertEqual(t, blocks1, blocks2) + + // Call GetOpCodes twice - second call should use cache + codes1 := sm.GetOpCodes() + codes2 := sm.GetOpCodes() + assertEqual(t, codes1, codes2) +} + +// TestSetSeqSamePointer tests that SetSeq1 and SetSeq2 do NOT reset caches +// when the same slice pointer is passed (early return optimization). +func TestSetSeqSamePointer(t *testing.T) { + a := []string{"a", "b", "c"} + b := []string{"x", "y", "z"} + + sm := NewMatcher(a, b) + + // Get initial blocks + blocks1 := sm.GetMatchingBlocks() + + // Set the same sequences again using SetSeqs + // Since we pass the same slice pointers, the caches should NOT be reset + // (the implementation checks pointer equality for early return) + sm.SetSeq1(a) + sm.SetSeq2(b) + + // Blocks should remain cached (not nil) after setting the same sequences + // so GetMatchingBlocks returns the cached result + blocks2 := sm.GetMatchingBlocks() + assertEqual(t, blocks1, blocks2) +} + +// TestSequenceMatcherWithIsJunk tests the junk filtering functionality. +func TestSequenceMatcherWithIsJunk(t *testing.T) { + // Test with a simple IsJunk function that marks whitespace as junk + a := []string{"a", " ", "b", " ", "c"} + b := []string{"a", "b", "c"} + + sm := NewMatcher(nil, nil) + sm.IsJunk = func(s string) bool { + return s == " " + } + sm.SetSeqs(a, b) + + // The matcher should still find matches but handle junk elements + blocks := sm.GetMatchingBlocks() + if len(blocks) == 0 { + t.Error("expected some matching blocks with junk filter") + } +} + +// TestAutoJunkWithLargeSequence tests the autoJunk feature with sequences >= 200 elements. +func TestAutoJunkWithLargeSequence(t *testing.T) { + // Create a sequence with more than 200 elements where one element appears + // more than 1% of the time (which makes it "popular" and gets filtered) + a := make([]string, 250) + b := make([]string, 250) + + // Fill with unique elements + for i := range 250 { + a[i] = fmt.Sprintf("a%d", i) + b[i] = fmt.Sprintf("a%d", i) + } + + // Make element "common" appear more than 1% (3+ times out of 250) + for i := range 10 { + b[i] = "common" + } + + sm := NewMatcher(a, b) + // The popular element "common" should be filtered + if len(sm.bPopular) == 0 { + t.Log("bPopular might be empty if 'common' doesn't exceed threshold, which is expected") + } + + // The matcher should still work + blocks := sm.GetMatchingBlocks() + if blocks == nil { + t.Error("expected matching blocks") + } +} + +// TestFindLongestMatchWithJunk tests finding longest match with junk elements. +func TestFindLongestMatchWithJunk(t *testing.T) { + // Create sequences where junk elements are adjacent to interesting matches + a := []string{"x", "a", "b", "c", "y"} + b := []string{"a", "b", "c"} + + sm := NewMatcher(nil, nil) + // Mark x and y as junk + sm.IsJunk = func(s string) bool { + return s == "x" || s == "y" + } + sm.SetSeqs(a, b) + + blocks := sm.GetMatchingBlocks() + // Should find the "a", "b", "c" match + found := false + for _, block := range blocks { + if block.Size == 3 { + found = true + break + } + } + if !found { + t.Error("expected to find a match of size 3") + } +} + +// TestFindLongestMatchExtension tests the extension of matches past popular elements. +func TestFindLongestMatchExtension(t *testing.T) { + // Test cases that exercise the match extension loops in findLongestMatch + a := []string{"a", "b", "c", "d", "e"} + b := []string{"x", "b", "c", "d", "y"} + + sm := NewMatcher(a, b) + blocks := sm.GetMatchingBlocks() + + // Should find the "b", "c", "d" match + found := false + for _, block := range blocks { + if block.Size >= 3 { + found = true + break + } + } + if !found { + t.Error("expected to find a match of size >= 3") + } +} + +// TestJunkFilteringInChainB tests the IsJunk function in chainB. +func TestJunkFilteringInChainB(t *testing.T) { + // Create a matcher with junk filtering + a := []string{"line1", "junk", "line2", "junk", "line3"} + b := []string{"line1", "junk", "line2", "junk", "line3", "junk"} + + sm := NewMatcher(nil, nil) + sm.IsJunk = func(s string) bool { + return s == "junk" + } + sm.SetSeqs(a, b) + + // Verify junk is correctly identified + if !sm.isBJunk("junk") { + t.Error("expected 'junk' to be identified as junk") + } + + // Non-junk should not be identified as junk + if sm.isBJunk("line1") { + t.Error("expected 'line1' to not be junk") + } + + // Should still be able to find matches + blocks := sm.GetMatchingBlocks() + if len(blocks) == 0 { + t.Error("expected some matching blocks") + } +} + +// TestMatchExtensionWithJunkOnBothSides tests junk matching extension. +func TestMatchExtensionWithJunkOnBothSides(t *testing.T) { + // Create sequences where junk elements surround interesting matches + // to exercise the junk extension loops in findLongestMatch + a := []string{"junk1", "junk2", "a", "b", "c", "junk3", "junk4"} + b := []string{"junk1", "junk2", "a", "b", "c", "junk3", "junk4"} + + sm := NewMatcher(nil, nil) + sm.IsJunk = func(s string) bool { + return strings.HasPrefix(s, "junk") + } + sm.SetSeqs(a, b) + + blocks := sm.GetMatchingBlocks() + // Should find matches including junk elements that are identical + totalSize := 0 + for _, block := range blocks { + totalSize += block.Size + } + if totalSize < 3 { + t.Errorf("expected total match size >= 3, got %d", totalSize) + } +} + +// TestFindLongestMatchBreakCondition tests the j >= bhi break condition. +func TestFindLongestMatchBreakCondition(t *testing.T) { + // Create sequences that will trigger the j >= bhi condition + // This happens when b2j has indices that exceed the search range + a := []string{"x", "y", "z"} + b := []string{"a", "b", "x", "y", "z"} + + sm := NewMatcher(a, b) + blocks := sm.GetMatchingBlocks() + + // Should find the "x", "y", "z" match + found := false + for _, block := range blocks { + if block.Size == 3 { + found = true + break + } + } + if !found { + t.Error("expected to find a match of size 3") + } +} + +// TestAutoJunkPopularElements tests the autoJunk filtering of popular elements. +func TestAutoJunkPopularElements(t *testing.T) { + // Create a sequence with > 200 elements where one element appears + // more than 1% of the time + n := 250 + a := make([]string, n) + b := make([]string, n) + + // Fill with mostly unique elements + for i := range n { + a[i] = fmt.Sprintf("line%d", i) + b[i] = fmt.Sprintf("line%d", i) + } + + // Make "popular" appear more than 1% (more than 2-3 times) + // We need it to appear > n/100 + 1 times = 3+ times + for i := range 10 { + b[i*25] = "popular" + } + + sm := NewMatcher(a, b) + + // The element "popular" should be filtered as popular + if len(sm.bPopular) == 0 { + t.Log("bPopular might be empty if threshold not exceeded") + } + + // Matcher should still produce valid results + blocks := sm.GetMatchingBlocks() + if blocks == nil { + t.Error("expected non-nil matching blocks") + } +} + +// TestFindLongestMatchWithJunkExtension tests the junk extension loops +// at the end of findLongestMatch function. +func TestFindLongestMatchWithJunkExtension(t *testing.T) { + // Create sequences where junk elements are adjacent to matches + // This should trigger the junk extension loops + a := []string{"junk", "a", "b", "c", "junk"} + b := []string{"junk", "a", "b", "c", "junk"} + + sm := NewMatcher(nil, nil) + sm.IsJunk = func(s string) bool { + return s == "junk" + } + sm.SetSeqs(a, b) + + blocks := sm.GetMatchingBlocks() + // Should find matches including junk extension + totalSize := 0 + for _, block := range blocks { + totalSize += block.Size + } + // The non-junk elements (a, b, c) should definitely match. + // Junk elements may or may not be included depending on extension behavior. + if totalSize < 3 { + t.Errorf("expected total match size >= 3, got %d", totalSize) + } +} + +// TestFindLongestMatchEdgeCases tests edge cases in findLongestMatch. +func TestFindLongestMatchEdgeCases(t *testing.T) { + // Test case where matches are found at the end of sequences + a := []string{"unique1", "unique2", "match"} + b := []string{"other1", "other2", "match"} + + sm := NewMatcher(a, b) + blocks := sm.GetMatchingBlocks() + + // Should find the "match" element + found := false + for _, block := range blocks { + if block.Size == 1 && block.A == 2 && block.B == 2 { + found = true + break + } + } + if !found { + t.Error("expected to find a match at the end") + } +} + +// TestMatcherWithBothSequencesSame tests the matcher with identical sequences. +func TestMatcherWithBothSequencesSame(t *testing.T) { + a := []string{"line1", "line2", "line3"} + b := []string{"line1", "line2", "line3"} + + sm := NewMatcher(a, b) + blocks := sm.GetMatchingBlocks() + + // Should find all lines match + if len(blocks) < 1 { + t.Error("expected at least one matching block") + } + + // The last block is always a sentinel with size 0 + for _, block := range blocks[:len(blocks)-1] { + if block.Size != 3 { + t.Errorf("expected matching block of size 3, got %d", block.Size) + } + } +} + +// TestWriteUnifiedDiffWithDefaultEol tests that default EOL is applied. +func TestWriteUnifiedDiffWithDefaultEol(t *testing.T) { + // Test that when Eol is empty, it defaults to "\n" + diff := UnifiedDiff{ + A: splitChars("abc"), + B: splitChars("abd"), + FromFile: "file1", + ToFile: "file2", + // Eol not set - should default to "\n" + } + result, err := GetUnifiedDiffString(diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "\n") { + t.Error("expected newlines in output") + } +} diff --git a/internal/difflib/options_test.go b/internal/difflib/options_test.go index ada5a5558..56a3c0ef1 100644 --- a/internal/difflib/options_test.go +++ b/internal/difflib/options_test.go @@ -94,3 +94,129 @@ func ansiPrinterBuilder(mark string) PrinterBuilder { } } } + +// TestDefaultPrinterBuilder tests the DefaultPrinterBuilder function. +func TestDefaultPrinterBuilder(t *testing.T) { + var buf strings.Builder + w := bufio.NewWriter(&buf) + + printer := DefaultPrinterBuilder(w) + err := printer("hello world") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + w.Flush() + + if buf.String() != "hello world" { + t.Errorf("expected 'hello world', got %q", buf.String()) + } +} + +// TestOptionsWithAllCustomPrinters tests that all custom printers are applied. +func TestOptionsWithAllCustomPrinters(t *testing.T) { + const ( + a = "line1\nline2\nline3\n" + b = "line1\nmodified\nline3\nnewline\n" + ) + + // Create custom printers for all types including OtherPrinter and Formatter + customPrinter := func(prefix string) PrinterBuilder { + return func(w *bufio.Writer) Printer { + return func(str string) error { + _, err := w.WriteString(prefix + str) + return err + } + } + } + + // customFormatter is a simplified formatter for testing purposes only. + // It only handles string arguments with %s placeholders. + // This is sufficient for the diff header format strings used in the library. + customFormatter := func(w *bufio.Writer) Formatter { + return func(format string, args ...any) error { + s := "[FMT]" + format + for _, arg := range args { + if str, ok := arg.(string); ok { + s = strings.Replace(s, "%s", str, 1) + } + } + _, err := w.WriteString(s) + return err + } + } + + diff, err := GetUnifiedDiffString(UnifiedDiff{ + A: SplitLines(a), + B: SplitLines(b), + FromFile: "Original", + ToFile: "Modified", + Context: 1, + Options: &Options{ + EqualPrinter: customPrinter("[EQ]"), + DeletePrinter: customPrinter("[DEL]"), + UpdatePrinter: customPrinter("[UPD]"), + InsertPrinter: customPrinter("[INS]"), + OtherPrinter: customPrinter("[OTH]"), + Formatter: customFormatter, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the custom formatter was used for headers + if !strings.Contains(diff, "[FMT]") { + t.Error("expected custom formatter to be used") + } + + // Verify custom printers were used + if !strings.Contains(diff, "[EQ]") { + t.Error("expected equal printer to be used") + } + if !strings.Contains(diff, "[DEL]") { + t.Error("expected delete printer to be used") + } + if !strings.Contains(diff, "[INS]") { + t.Error("expected insert printer to be used") + } +} + +// TestOptionsWithDefaults tests that default options are applied correctly. +func TestOptionsWithDefaults(t *testing.T) { + // Test with nil options + opts := optionsWithDefaults(nil) + if opts == nil { + t.Fatal("expected non-nil options") + } + if opts.EqualPrinter == nil { + t.Error("expected EqualPrinter to be set") + } + if opts.DeletePrinter == nil { + t.Error("expected DeletePrinter to be set") + } + if opts.UpdatePrinter == nil { + t.Error("expected UpdatePrinter to be set") + } + if opts.InsertPrinter == nil { + t.Error("expected InsertPrinter to be set") + } + if opts.OtherPrinter == nil { + t.Error("expected OtherPrinter to be set") + } + if opts.Formatter == nil { + t.Error("expected Formatter to be set") + } + + // Test with partial options (only some fields set) + partialOpts := &Options{ + EqualPrinter: ansiPrinterBuilder(greenMark), + } + opts = optionsWithDefaults(partialOpts) + if opts.EqualPrinter == nil { + t.Error("expected EqualPrinter to be preserved") + } + if opts.DeletePrinter == nil { + t.Error("expected DeletePrinter to have default") + } +} diff --git a/internal/testintegration/go.mod b/internal/testintegration/go.mod index 528d76541..72b6d709c 100644 --- a/internal/testintegration/go.mod +++ b/internal/testintegration/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/go-openapi/testify/v2 v2.1.8 + go.yaml.in/yaml/v3 v3.0.4 pgregory.net/rapid v1.2.0 ) diff --git a/internal/testintegration/go.sum b/internal/testintegration/go.sum index 5dd715a81..aa1842d5b 100644 --- a/internal/testintegration/go.sum +++ b/internal/testintegration/go.sum @@ -1,2 +1,6 @@ +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=