changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 18 kB view raw
1package diff 2 3import ( 4 _ "embed" 5 "strings" 6 "testing" 7) 8 9type algorithmFactory struct { 10 name string 11 new func() Diff 12} 13 14var diffAlgorithms = []algorithmFactory{ 15 {name: "LCS", new: func() Diff { return &LCS{} }}, 16 {name: "Myers", new: func() Diff { return &Myers{} }}, 17} 18 19//go:embed fixtures/diffs_original.md 20var fixtureOriginal string 21 22//go:embed fixtures/diffs_updated.md 23var fixtureUpdated string 24 25func TestDiff_Compute_EmptySequences(t *testing.T) { 26 for _, alg := range diffAlgorithms { 27 alg := alg 28 t.Run(alg.name, func(t *testing.T) { 29 m := alg.new() 30 31 t.Run("both empty", func(t *testing.T) { 32 edits, err := m.Compute([]string{}, []string{}) 33 if err != nil { 34 t.Fatalf("unexpected error: %v", err) 35 } 36 if len(edits) != 0 { 37 t.Errorf("expected 0 edits, got %d", len(edits)) 38 } 39 }) 40 41 t.Run("a empty, b has content", func(t *testing.T) { 42 b := []string{"line1", "line2"} 43 edits, err := m.Compute([]string{}, b) 44 if err != nil { 45 t.Fatalf("unexpected error: %v", err) 46 } 47 if len(edits) != 2 { 48 t.Fatalf("expected 2 edits, got %d", len(edits)) 49 } 50 for i, edit := range edits { 51 if edit.Kind != Insert { 52 t.Errorf("edit %d: expected Insert, got %v", i, edit.Kind) 53 } 54 if edit.Content != b[i] { 55 t.Errorf("edit %d: expected content %q, got %q", i, b[i], edit.Content) 56 } 57 } 58 }) 59 60 t.Run("b empty, a has content", func(t *testing.T) { 61 a := []string{"line1", "line2"} 62 edits, err := m.Compute(a, []string{}) 63 if err != nil { 64 t.Fatalf("unexpected error: %v", err) 65 } 66 if len(edits) != 2 { 67 t.Fatalf("expected 2 edits, got %d", len(edits)) 68 } 69 for i, edit := range edits { 70 if edit.Kind != Delete { 71 t.Errorf("edit %d: expected Delete, got %v", i, edit.Kind) 72 } 73 if edit.Content != a[i] { 74 t.Errorf("edit %d: expected content %q, got %q", i, a[i], edit.Content) 75 } 76 } 77 }) 78 }) 79 } 80} 81 82func TestDiff_Compute_IdenticalSequences(t *testing.T) { 83 a := []string{"line1", "line2", "line3"} 84 b := []string{"line1", "line2", "line3"} 85 86 for _, alg := range diffAlgorithms { 87 alg := alg 88 t.Run(alg.name, func(t *testing.T) { 89 m := alg.new() 90 edits, err := m.Compute(a, b) 91 if err != nil { 92 t.Fatalf("unexpected error: %v", err) 93 } 94 95 if len(edits) != 3 { 96 t.Fatalf("expected 3 edits, got %d", len(edits)) 97 } 98 99 for i, edit := range edits { 100 if edit.Kind != Equal { 101 t.Errorf("edit %d: expected Equal, got %v", i, edit.Kind) 102 } 103 if edit.AIndex != i || edit.BIndex != i { 104 t.Errorf("edit %d: expected indices (%d,%d), got (%d,%d)", i, i, i, edit.AIndex, edit.BIndex) 105 } 106 } 107 }) 108 } 109} 110 111func TestDiff_Compute_SimpleInsert(t *testing.T) { 112 a := []string{"line1", "line3"} 113 b := []string{"line1", "line2", "line3"} 114 115 for _, alg := range diffAlgorithms { 116 alg := alg 117 t.Run(alg.name, func(t *testing.T) { 118 m := alg.new() 119 edits, err := m.Compute(a, b) 120 if err != nil { 121 t.Fatalf("unexpected error: %v", err) 122 } 123 124 // Verify structure: Equal(line1), Insert(line2), Equal(line3) 125 if len(edits) != 3 { 126 t.Fatalf("expected 3 edits, got %d", len(edits)) 127 } 128 129 if edits[0].Kind != Equal || edits[0].Content != "line1" { 130 t.Errorf("edit 0: expected Equal(line1), got %v(%s)", edits[0].Kind, edits[0].Content) 131 } 132 if edits[1].Kind != Insert || edits[1].Content != "line2" { 133 t.Errorf("edit 1: expected Insert(line2), got %v(%s)", edits[1].Kind, edits[1].Content) 134 } 135 if edits[2].Kind != Equal || edits[2].Content != "line3" { 136 t.Errorf("edit 2: expected Equal(line3), got %v(%s)", edits[2].Kind, edits[2].Content) 137 } 138 }) 139 } 140} 141 142func TestDiff_Compute_SimpleDelete(t *testing.T) { 143 a := []string{"line1", "line2", "line3"} 144 b := []string{"line1", "line3"} 145 146 for _, alg := range diffAlgorithms { 147 alg := alg 148 t.Run(alg.name, func(t *testing.T) { 149 m := alg.new() 150 edits, err := m.Compute(a, b) 151 if err != nil { 152 t.Fatalf("unexpected error: %v", err) 153 } 154 155 // Verify structure: Equal(line1), Delete(line2), Equal(line3) 156 if len(edits) != 3 { 157 t.Fatalf("expected 3 edits, got %d", len(edits)) 158 } 159 160 if edits[0].Kind != Equal || edits[0].Content != "line1" { 161 t.Errorf("edit 0: expected Equal(line1), got %v(%s)", edits[0].Kind, edits[0].Content) 162 } 163 if edits[1].Kind != Delete || edits[1].Content != "line2" { 164 t.Errorf("edit 1: expected Delete(line2), got %v(%s)", edits[1].Kind, edits[1].Content) 165 } 166 if edits[2].Kind != Equal || edits[2].Content != "line3" { 167 t.Errorf("edit 2: expected Equal(line3), got %v(%s)", edits[2].Kind, edits[2].Content) 168 } 169 }) 170 } 171} 172 173func TestDiff_Compute_CompleteReplacement(t *testing.T) { 174 a := []string{"old1", "old2"} 175 b := []string{"new1", "new2"} 176 177 for _, alg := range diffAlgorithms { 178 alg := alg 179 t.Run(alg.name, func(t *testing.T) { 180 m := alg.new() 181 edits, err := m.Compute(a, b) 182 if err != nil { 183 t.Fatalf("unexpected error: %v", err) 184 } 185 186 // Should be all deletes followed by all inserts (or interleaved) 187 deleteCount := 0 188 insertCount := 0 189 for _, edit := range edits { 190 switch edit.Kind { 191 case Delete: 192 deleteCount++ 193 case Insert: 194 insertCount++ 195 case Equal: 196 t.Errorf("unexpected Equal edit when sequences are completely different") 197 } 198 } 199 200 if deleteCount != 2 { 201 t.Errorf("expected 2 deletes, got %d", deleteCount) 202 } 203 if insertCount != 2 { 204 t.Errorf("expected 2 inserts, got %d", insertCount) 205 } 206 }) 207 } 208} 209 210func TestDiff_Compute_Fixtures(t *testing.T) { 211 original := strings.Split(strings.TrimSpace(fixtureOriginal), "\n") 212 updated := strings.Split(strings.TrimSpace(fixtureUpdated), "\n") 213 214 for _, alg := range diffAlgorithms { 215 alg := alg 216 t.Run(alg.name, func(t *testing.T) { 217 m := alg.new() 218 edits, err := m.Compute(original, updated) 219 if err != nil { 220 t.Fatalf("unexpected error: %v", err) 221 } 222 223 if len(edits) == 0 { 224 t.Fatal("expected non-empty edit list") 225 } 226 227 reconstructed := ApplyEdits(original, edits) 228 if len(reconstructed) != len(updated) { 229 t.Fatalf("reconstructed length %d != updated length %d", len(reconstructed), len(updated)) 230 } 231 for i := range reconstructed { 232 if reconstructed[i] != updated[i] { 233 t.Errorf("line %d: reconstructed %q != updated %q", i, reconstructed[i], updated[i]) 234 } 235 } 236 237 counts := CountEditKinds(edits) 238 if counts[Equal] == 0 { 239 t.Error("expected some Equal edits (files share common lines like blank lines)") 240 } 241 if counts[Insert] == 0 { 242 t.Error("expected some Insert edits") 243 } 244 if counts[Delete] == 0 { 245 t.Error("expected some Delete edits") 246 } 247 248 t.Logf("Edit statistics: Equal=%d, Insert=%d, Delete=%d, Total=%d", 249 counts[Equal], counts[Insert], counts[Delete], len(edits)) 250 }) 251 } 252} 253 254func TestDiff_Name(t *testing.T) { 255 for _, alg := range diffAlgorithms { 256 alg := alg 257 t.Run(alg.name, func(t *testing.T) { 258 m := alg.new() 259 if m.Name() != alg.name { 260 t.Errorf("expected name %q, got %q", alg.name, m.Name()) 261 } 262 }) 263 } 264} 265 266func TestAreSimilarLines(t *testing.T) { 267 tests := []struct { 268 name string 269 a string 270 b string 271 expected bool 272 }{ 273 { 274 name: "identical lines", 275 a: "github.com/foo/bar v1.0.0", 276 b: "github.com/foo/bar v1.0.0", 277 expected: true, 278 }, 279 { 280 name: "similar package different version", 281 a: "github.com/charmbracelet/x/ansi v0.10.1 // indirect", 282 b: "github.com/charmbracelet/x/ansi v0.10.3 // indirect", 283 expected: true, 284 }, 285 { 286 name: "different packages", 287 a: "github.com/charmbracelet/x/term v0.2.1 // indirect", 288 b: "github.com/charmbracelet/x/exp/teatest v0.0.0-20251", 289 expected: false, 290 }, 291 { 292 name: "empty strings", 293 a: "", 294 b: "", 295 expected: true, 296 }, 297 { 298 name: "one empty", 299 a: "some content", 300 b: "", 301 expected: false, 302 }, 303 { 304 name: "completely different", 305 a: "package main", 306 b: "import fmt", 307 expected: false, 308 }, 309 { 310 name: "short common prefix", 311 a: "github.com/foo/bar", 312 b: "github.com/baz/qux", 313 expected: false, 314 }, 315 } 316 317 for _, tt := range tests { 318 t.Run(tt.name, func(t *testing.T) { 319 result := areSimilarLines(tt.a, tt.b) 320 if result != tt.expected { 321 t.Errorf("areSimilarLines(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected) 322 } 323 }) 324 } 325} 326 327func TestMergeReplacements(t *testing.T) { 328 tests := []struct { 329 name string 330 input []Edit 331 expected []Edit 332 }{ 333 { 334 name: "empty edits", 335 input: []Edit{}, 336 expected: []Edit{}, 337 }, 338 { 339 name: "single edit", 340 input: []Edit{ 341 {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 342 }, 343 expected: []Edit{ 344 {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 345 }, 346 }, 347 { 348 name: "merge similar delete and insert", 349 input: []Edit{ 350 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 351 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"}, 352 }, 353 expected: []Edit{ 354 {Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 355 }, 356 }, 357 { 358 name: "don't merge dissimilar delete and insert", 359 input: []Edit{ 360 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 361 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"}, 362 }, 363 expected: []Edit{ 364 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 365 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"}, 366 }, 367 }, 368 { 369 name: "merge insert and delete (reversed order)", 370 input: []Edit{ 371 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"}, 372 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 373 }, 374 expected: []Edit{ 375 {Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 376 }, 377 }, 378 { 379 name: "mixed operations with merge", 380 input: []Edit{ 381 {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 382 {Kind: Delete, AIndex: 1, BIndex: -1, Content: "github.com/foo/bar v1.0.0"}, 383 {Kind: Insert, AIndex: -1, BIndex: 1, Content: "github.com/foo/bar v2.0.0"}, 384 {Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"}, 385 }, 386 expected: []Edit{ 387 {Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"}, 388 {Kind: Replace, AIndex: 1, BIndex: 1, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"}, 389 {Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"}, 390 }, 391 }, 392 { 393 name: "multiple inserts and deletes without merge", 394 input: []Edit{ 395 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"}, 396 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"}, 397 {Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"}, 398 }, 399 expected: []Edit{ 400 {Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"}, 401 {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"}, 402 {Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"}, 403 }, 404 }, 405 } 406 407 for _, tt := range tests { 408 t.Run(tt.name, func(t *testing.T) { 409 result := MergeReplacements(tt.input) 410 411 if len(result) != len(tt.expected) { 412 t.Fatalf("expected %d edits, got %d", len(tt.expected), len(result)) 413 } 414 415 for i := range result { 416 if result[i].Kind != tt.expected[i].Kind { 417 t.Errorf("edit %d: expected Kind %v, got %v", i, tt.expected[i].Kind, result[i].Kind) 418 } 419 if result[i].AIndex != tt.expected[i].AIndex { 420 t.Errorf("edit %d: expected AIndex %d, got %d", i, tt.expected[i].AIndex, result[i].AIndex) 421 } 422 if result[i].BIndex != tt.expected[i].BIndex { 423 t.Errorf("edit %d: expected BIndex %d, got %d", i, tt.expected[i].BIndex, result[i].BIndex) 424 } 425 if result[i].Content != tt.expected[i].Content { 426 t.Errorf("edit %d: expected Content %q, got %q", i, tt.expected[i].Content, result[i].Content) 427 } 428 if result[i].NewContent != tt.expected[i].NewContent { 429 t.Errorf("edit %d: expected NewContent %q, got %q", i, tt.expected[i].NewContent, result[i].NewContent) 430 } 431 } 432 }) 433 } 434} 435 436func TestDiff_Compute_Unicode(t *testing.T) { 437 a := []string{"Emoji 🚀", "Regular text"} 438 b := []string{"Emoji 🎉", "Regular text"} 439 440 for _, alg := range diffAlgorithms { 441 t.Run(alg.name, func(t *testing.T) { 442 m := alg.new() 443 edits, err := m.Compute(a, b) 444 if err != nil { 445 t.Fatalf("unexpected error with unicode: %v", err) 446 } 447 448 reconstructed := ApplyEdits(a, edits) 449 if len(reconstructed) != len(b) { 450 t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) 451 } 452 for i := range reconstructed { 453 if reconstructed[i] != b[i] { 454 t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) 455 } 456 } 457 }) 458 } 459} 460 461func TestDiff_Compute_VeryLongLines(t *testing.T) { 462 longLine1 := strings.Repeat("a", 5000) 463 longLine2 := strings.Repeat("b", 5000) 464 longLine3 := strings.Repeat("c", 5000) 465 466 a := []string{longLine1, longLine2} 467 b := []string{longLine1, longLine3} 468 469 for _, alg := range diffAlgorithms { 470 t.Run(alg.name, func(t *testing.T) { 471 m := alg.new() 472 edits, err := m.Compute(a, b) 473 if err != nil { 474 t.Fatalf("unexpected error with long lines: %v", err) 475 } 476 477 reconstructed := ApplyEdits(a, edits) 478 if len(reconstructed) != len(b) { 479 t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) 480 } 481 for i := range reconstructed { 482 if reconstructed[i] != b[i] { 483 t.Errorf("line %d: lengths %d != %d", i, len(reconstructed[i]), len(b[i])) 484 } 485 } 486 }) 487 } 488} 489 490func TestDiff_Compute_WhitespaceOnly(t *testing.T) { 491 a := []string{"line1", " ", "line3"} 492 b := []string{"line1", " ", "line3"} 493 494 for _, alg := range diffAlgorithms { 495 t.Run(alg.name, func(t *testing.T) { 496 m := alg.new() 497 edits, err := m.Compute(a, b) 498 if err != nil { 499 t.Fatalf("unexpected error: %v", err) 500 } 501 502 reconstructed := ApplyEdits(a, edits) 503 if len(reconstructed) != len(b) { 504 t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) 505 } 506 for i := range reconstructed { 507 if reconstructed[i] != b[i] { 508 t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) 509 } 510 } 511 }) 512 } 513} 514 515func TestDiff_Compute_AlternatingLines(t *testing.T) { 516 a := []string{"a1", "a2", "a3", "a4", "a5"} 517 b := []string{"b1", "b2", "b3", "b4", "b5"} 518 519 for _, alg := range diffAlgorithms { 520 t.Run(alg.name, func(t *testing.T) { 521 m := alg.new() 522 edits, err := m.Compute(a, b) 523 if err != nil { 524 t.Fatalf("unexpected error: %v", err) 525 } 526 527 reconstructed := ApplyEdits(a, edits) 528 if len(reconstructed) != len(b) { 529 t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) 530 } 531 for i := range reconstructed { 532 if reconstructed[i] != b[i] { 533 t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) 534 } 535 } 536 }) 537 } 538} 539 540func TestDiff_CrossValidation(t *testing.T) { 541 testCases := []struct { 542 name string 543 a []string 544 b []string 545 }{ 546 {"simple", []string{"a", "b", "c"}, []string{"a", "x", "c"}}, 547 {"complex", []string{"1", "2", "3", "4"}, []string{"1", "x", "y", "4"}}, 548 {"empty to content", []string{}, []string{"a", "b", "c"}}, 549 {"content to empty", []string{"a", "b", "c"}, []string{}}, 550 } 551 552 for _, tc := range testCases { 553 t.Run(tc.name, func(t *testing.T) { 554 lcs := &LCS{} 555 myers := &Myers{} 556 557 lcsEdits, err := lcs.Compute(tc.a, tc.b) 558 if err != nil { 559 t.Fatalf("LCS error: %v", err) 560 } 561 562 myersEdits, err := myers.Compute(tc.a, tc.b) 563 if err != nil { 564 t.Fatalf("Myers error: %v", err) 565 } 566 567 lcsResult := ApplyEdits(tc.a, lcsEdits) 568 myersResult := ApplyEdits(tc.a, myersEdits) 569 570 if len(lcsResult) != len(tc.b) { 571 t.Errorf("LCS reconstruction length mismatch: %d != %d", len(lcsResult), len(tc.b)) 572 } 573 if len(myersResult) != len(tc.b) { 574 t.Errorf("Myers reconstruction length mismatch: %d != %d", len(myersResult), len(tc.b)) 575 } 576 577 for i := range tc.b { 578 if i < len(lcsResult) && lcsResult[i] != tc.b[i] { 579 t.Errorf("LCS line %d: %q != %q", i, lcsResult[i], tc.b[i]) 580 } 581 if i < len(myersResult) && myersResult[i] != tc.b[i] { 582 t.Errorf("Myers line %d: %q != %q", i, myersResult[i], tc.b[i]) 583 } 584 } 585 }) 586 } 587} 588 589func TestDiff_EditIndicesValid(t *testing.T) { 590 a := []string{"line1", "line2", "line3"} 591 b := []string{"line1", "modified", "line3", "line4"} 592 593 for _, alg := range diffAlgorithms { 594 t.Run(alg.name, func(t *testing.T) { 595 m := alg.new() 596 edits, err := m.Compute(a, b) 597 if err != nil { 598 t.Fatalf("unexpected error: %v", err) 599 } 600 601 for i, edit := range edits { 602 switch edit.Kind { 603 case Equal: 604 if edit.AIndex < 0 || edit.AIndex >= len(a) { 605 t.Errorf("edit %d: invalid AIndex %d (len(a)=%d)", i, edit.AIndex, len(a)) 606 } 607 if edit.BIndex < 0 || edit.BIndex >= len(b) { 608 t.Errorf("edit %d: invalid BIndex %d (len(b)=%d)", i, edit.BIndex, len(b)) 609 } 610 case Delete: 611 if edit.AIndex < 0 || edit.AIndex >= len(a) { 612 t.Errorf("edit %d: invalid AIndex %d for Delete", i, edit.AIndex) 613 } 614 case Insert: 615 if edit.BIndex < 0 || edit.BIndex >= len(b) { 616 t.Errorf("edit %d: invalid BIndex %d for Insert", i, edit.BIndex) 617 } 618 } 619 } 620 }) 621 } 622} 623 624func BenchmarkLCS_SmallInput(b *testing.B) { 625 a := []string{"line1", "line2", "line3", "line4", "line5"} 626 c := []string{"line1", "modified", "line3", "line4", "added"} 627 lcs := &LCS{} 628 629 for b.Loop() { 630 _, _ = lcs.Compute(a, c) 631 } 632} 633 634func BenchmarkMyers_SmallInput(b *testing.B) { 635 a := []string{"line1", "line2", "line3", "line4", "line5"} 636 c := []string{"line1", "modified", "line3", "line4", "added"} 637 myers := &Myers{} 638 639 for b.Loop() { 640 _, _ = myers.Compute(a, c) 641 } 642} 643 644func BenchmarkLCS_MediumInput(b *testing.B) { 645 a := make([]string, 50) 646 c := make([]string, 50) 647 for i := range 50 { 648 a[i] = "line" + strings.Repeat("x", i) 649 if i%5 == 0 { 650 c[i] = "modified" + strings.Repeat("y", i) 651 } else { 652 c[i] = a[i] 653 } 654 } 655 656 lcs := &LCS{} 657 658 for b.Loop() { 659 _, _ = lcs.Compute(a, c) 660 } 661} 662 663func BenchmarkMyers_MediumInput(b *testing.B) { 664 a := make([]string, 50) 665 c := make([]string, 50) 666 for i := range 50 { 667 a[i] = "line" + strings.Repeat("x", i) 668 if i%5 == 0 { 669 c[i] = "modified" + strings.Repeat("y", i) 670 } else { 671 c[i] = a[i] 672 } 673 } 674 675 myers := &Myers{} 676 677 for b.Loop() { 678 _, _ = myers.Compute(a, c) 679 } 680}