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)
}
}