changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
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}