package pulls import ( "html/template" "testing" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" ) func u32(v uint32) *uint32 { return &v } // lines returns n lines named l0..l{n-1}, optionally overriding some by index. func lines(n int, override map[int]string) []template.HTML { out := make([]template.HTML, n) for i := range out { if v, ok := override[i]; ok { out[i] = template.HTML(v) } else { out[i] = template.HTML("l" + string(rune('0'+i%10))) } } return out } func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk { return &gitmirrorv1.Hunk{Lines: pairs} } // identity opposite maps + generous bounds, for exercising the merge/context logic directly. func identityOpp(n int) (map[int]int, map[int]int) { l, r := map[int]int{}, map[int]int{} for i := 0; i < n; i++ { l[i], r[i] = i, i } return l, r } func TestExtractLines_FillsInteriorGap(t *testing.T) { got := extractLines([]linePair{{0, 0}, {2, 2}}) want := []linePair{{0, 0}, {1, 1}, {2, 2}} if len(got) != len(want) { t.Fatalf("got %+v, want %+v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("pair %d = %+v, want %+v", i, got[i], want[i]) } } } func TestPadBeforeAfter_ClampAndCount(t *testing.T) { // n+1 = 4 lines when far from the edges. if got := padBefore(10, numContextLines); len(got) != 4 || got[0] != 6 || got[3] != 9 { t.Fatalf("padBefore(10) = %v", got) } if got := padAfter(10, 100, numContextLines); len(got) != 4 || got[0] != 11 || got[3] != 14 { t.Fatalf("padAfter(10,100) = %v", got) } // Clamp at start of file. if got := padBefore(2, numContextLines); len(got) != 2 || got[0] != 0 || got[1] != 1 { t.Fatalf("padBefore(2) = %v", got) } // Clamp at end of file. if got := padAfter(98, 100, numContextLines); len(got) != 2 || got[0] != 99 || got[1] != 100 { t.Fatalf("padAfter(98,100) = %v", got) } } func TestMergeAdjacent_MergeClose(t *testing.T) { oppL, oppR := identityOpp(200) // Two deletions 2 lines apart on the lhs -> context windows overlap -> merge. got := mergeAdjacent([][]linePair{{{2, -1}}, {{4, -1}}}, oppL, oppR, 199, 199, numContextLines) if len(got) != 1 { t.Fatalf("want 1 merged hunk, got %d: %+v", len(got), got) } } func TestMergeAdjacent_SplitFar(t *testing.T) { oppL, oppR := identityOpp(200) got := mergeAdjacent([][]linePair{{{2, -1}}, {{40, -1}}}, oppL, oppR, 199, 199, numContextLines) if len(got) != 2 { t.Fatalf("want 2 hunks, got %d: %+v", len(got), got) } } func TestMergeAdjacent_LargeOneSidedInsertion(t *testing.T) { oppL, oppR := identityOpp(200) // Two modifications adjacent on the lhs (2 and 4) but far apart on the rhs (2 and 40), // as if 30+ lines were inserted between them. difftastic merges by per-side line // distance, so the lhs overlap keeps them one hunk — the case aligned-index windowing // would have split. got := mergeAdjacent([][]linePair{{{2, 2}}, {{4, 40}}}, oppL, oppR, 199, 199, numContextLines) if len(got) != 1 { t.Fatalf("want 1 merged hunk (lhs overlap), got %d: %+v", len(got), got) } } func TestBuildOpposites_DeletionPairing(t *testing.T) { // Delete base line 4 of a 10-line file -> head has 9 lines. Unchanged base 5..9 must // pair with head 4..8 (the bijection difftastic's opposite_positions encodes). base := lines(10, nil) head := lines(9, nil) hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(4)})} oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(base, head, hunks) if maxLhs != 9 || maxRhs != 8 { t.Fatalf("max lines = %d,%d want 9,8", maxLhs, maxRhs) } for l := 5; l <= 9; l++ { if oppLhs[l] != l-1 { t.Fatalf("oppLhs[%d] = %d, want %d", l, oppLhs[l], l-1) } } if oppRhs[4] != 5 { // head line 4 <-> base line 5 t.Fatalf("oppRhs[4] = %d, want 5", oppRhs[4]) } // The deleted base line 4 has no counterpart. if _, ok := oppLhs[4]; ok { t.Fatalf("deleted base line 4 should have no opposite") } } func TestBuildHunks_MarksNovelAndAddsContext(t *testing.T) { base := lines(10, map[int]string{5: "old"}) head := lines(10, map[int]string{5: "new"}) built := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})}) if len(built) != 1 { t.Fatalf("want 1 hunk, got %d", len(built)) } var nChanged, nContext int for _, r := range built[0].rows { if r.changed { nChanged++ if r.lhs != 5 || r.rhs != 5 { t.Fatalf("changed row = %+v, want {5,5}", r) } } else { nContext++ } } if nChanged != 1 { t.Fatalf("want 1 changed row, got %d", nChanged) } // Side-by-side display shows numContextLines (3) context rows each side, index-based // (difftastic's matched_lines_indexes_for_hunk), both clamped inside the 10-line file. if nContext != 6 { t.Fatalf("want 6 context rows, got %d: %+v", nContext, built[0].rows) } } func TestBuildHunks_MergedKeepsInteriorContext(t *testing.T) { // Regression: a deletion and a nearby addition, merged into one hunk, are separated by // unchanged lines. Those interior context rows must survive (reconstructing context via // extractLines/fillBetween dropped them across the deletion->addition boundary). // Base: 20 lines; delete base line 5. Head: base with line 5 removed and a new line // inserted at head index 8 (a few lines after the deletion point). base := lines(20, nil) head := make([]template.HTML, 0, 20) head = append(head, base[:5]...) // head 0..4 == base 0..4 head = append(head, base[6:9]...) // head 5..7 == base 6..8 head = append(head, template.HTML("INSERTED")) // head 8 (new) head = append(head, base[9:]...) // head 9.. == base 9.. hunks := []*gitmirrorv1.Hunk{ hunk(&gitmirrorv1.LinePair{Lhs: u32(5)}), // delete base line 5 hunk(&gitmirrorv1.LinePair{Rhs: u32(8)}), // insert head line 8 } built := buildHunks(base, head, hunks) if len(built) != 1 { t.Fatalf("want 1 merged hunk, got %d", len(built)) } // The unchanged lines between the deletion (base 5) and the insertion (head 8, i.e. base // 8) — base lines 6,7,8 — must appear as context rows (both sides present, unchanged). want := map[int]bool{6: false, 7: false, 8: false} got := map[int]bool{} for _, r := range built[0].rows { if !r.changed && r.lhs >= 0 { if _, ok := want[r.lhs]; ok { got[r.lhs] = true } } } for ln := range want { if !got[ln] { t.Fatalf("interior context base line %d missing from merged hunk: %+v", ln, built[0].rows) } } } func TestIndexesForHunk_SliceAndClamp(t *testing.T) { base := lines(20, nil) head := lines(20, nil) // Single change at line 10. pairs, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)})}) lo, hi := indexesForHunk(pairs, []linePair{{10, 10}}, numContextLines) if lo != 7 || hi != 14 { // [10-3 .. 10+3+1) t.Fatalf("slice = [%d,%d), want [7,14)", lo, hi) } // Clamp at start of file. pairs2, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(1), Rhs: u32(1)})}) lo2, _ := indexesForHunk(pairs2, []linePair{{1, 1}}, numContextLines) if lo2 != 0 { t.Fatalf("start clamp = %d, want 0", lo2) } }