package main
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- normalizeForVerify ---
func TestNormalizeForVerify_RemovesEmptyBrParagraph(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "br with space before slash",
input: `<p><br /></p><p>text</p>`,
want: `<p>text</p>`,
},
{
name: "br without space",
input: `<p><br/></p><p>text</p>`,
want: `<p>text</p>`,
},
{
name: "br with whitespace around",
input: "<p> \n <br /> \n </p><p>text</p>",
want: "<p>text</p>",
},
{
name: "no empty paragraphs",
input: `<p>hello</p>`,
want: `<p>hello</p>`,
},
{
name: "multiple empty paragraphs",
input: `<p><br /></p><p><br/></p><p>text</p>`,
want: `<p>text</p>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeForVerify(tt.input))
})
}
}
func TestNormalizeForVerify_SpanInsideCode(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "span inside code unwrapped",
input: `<code>hello<span> world</span></code>`,
want: `<code>hello world</code>`,
},
{
name: "span with attributes inside code",
input: `<code>a<span class="x"> : </span></code>`,
want: `<code>a : </code>`,
},
{
name: "no span inside code",
input: `<code>plain</code>`,
want: `<code>plain</code>`,
},
{
name: "span outside code untouched",
input: `<p><span>text</span></p>`,
want: `<p><span>text</span></p>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeForVerify(tt.input))
})
}
}
func TestNormalizeForVerify_AdjacentCodeMerged(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "directly adjacent",
input: `<code>hello</code><code>world</code>`,
want: `<code>helloworld</code>`,
},
{
name: "with whitespace between",
input: `<code>hello</code> <code>world</code>`,
want: `<code>hello</code> <code>world</code>`,
},
{
name: "single code element untouched",
input: `<code>hello</code>`,
want: `<code>hello</code>`,
},
{
name: "combined: span inside + adjacent merge",
input: `<code>plan<span> : </span></code><code>vclock</code>`,
want: `<code>plan : vclock</code>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeForVerify(tt.input))
})
}
}
// --- computeDiffOps ---
func TestComputeDiffOps_IdenticalInputs(t *testing.T) {
lines := []string{"a", "b", "c"}
ops := computeDiffOps(lines, lines)
require.Len(t, ops, 3)
for _, op := range ops {
assert.Equal(t, opEqual, op.op)
}
}
func TestComputeDiffOps_CompletelyDifferent(t *testing.T) {
a := []string{"a", "b"}
b := []string{"x", "y"}
ops := computeDiffOps(a, b)
var removes, adds int
for _, op := range ops {
switch op.op {
case opRemove:
removes++
case opAdd:
adds++
}
}
assert.Equal(t, 2, removes)
assert.Equal(t, 2, adds)
}
func TestComputeDiffOps_EmptyInputs(t *testing.T) {
assert.Empty(t, computeDiffOps(nil, nil))
assert.Empty(t, computeDiffOps([]string{}, []string{}))
}
func TestComputeDiffOps_OneEmpty(t *testing.T) {
ops := computeDiffOps([]string{"a", "b"}, nil)
require.Len(t, ops, 2)
for _, op := range ops {
assert.Equal(t, opRemove, op.op)
}
ops = computeDiffOps(nil, []string{"x", "y"})
require.Len(t, ops, 2)
for _, op := range ops {
assert.Equal(t, opAdd, op.op)
}
}
func TestComputeDiffOps_SingleLineChange(t *testing.T) {
a := []string{"aaa", "bbb", "ccc"}
b := []string{"aaa", "BBB", "ccc"}
ops := computeDiffOps(a, b)
// Should be: equal(aaa), remove(bbb), add(BBB), equal(ccc)
require.Len(t, ops, 4)
assert.Equal(t, opEqual, ops[0].op)
assert.Equal(t, "aaa", ops[0].text)
assert.Equal(t, opRemove, ops[1].op)
assert.Equal(t, "bbb", ops[1].text)
assert.Equal(t, opAdd, ops[2].op)
assert.Equal(t, "BBB", ops[2].text)
assert.Equal(t, opEqual, ops[3].op)
assert.Equal(t, "ccc", ops[3].text)
}
func TestComputeDiffOps_LineNumbers(t *testing.T) {
a := []string{"same", "old"}
b := []string{"same", "new"}
ops := computeDiffOps(a, b)
// equal: lineA=1, lineB=1
assert.Equal(t, 1, ops[0].lineA)
assert.Equal(t, 1, ops[0].lineB)
// remove: lineA=2, lineB=-1
assert.Equal(t, 2, ops[1].lineA)
assert.Equal(t, -1, ops[1].lineB)
// add: lineA=-1, lineB=2
assert.Equal(t, -1, ops[2].lineA)
assert.Equal(t, 2, ops[2].lineB)
}
func TestComputeDiffOps_Insertion(t *testing.T) {
a := []string{"a", "c"}
b := []string{"a", "b", "c"}
ops := computeDiffOps(a, b)
require.Len(t, ops, 3)
assert.Equal(t, opEqual, ops[0].op)
assert.Equal(t, opAdd, ops[1].op)
assert.Equal(t, "b", ops[1].text)
assert.Equal(t, opEqual, ops[2].op)
}
func TestComputeDiffOps_Deletion(t *testing.T) {
a := []string{"a", "b", "c"}
b := []string{"a", "c"}
ops := computeDiffOps(a, b)
require.Len(t, ops, 3)
assert.Equal(t, opEqual, ops[0].op)
assert.Equal(t, opRemove, ops[1].op)
assert.Equal(t, "b", ops[1].text)
assert.Equal(t, opEqual, ops[2].op)
}
// --- buildHunks ---
func TestBuildHunks_NoChanges(t *testing.T) {
ops := computeDiffOps([]string{"a", "b", "c"}, []string{"a", "b", "c"})
hunks := buildHunks(ops, 3)
assert.Empty(t, hunks)
}
func TestBuildHunks_SingleChange(t *testing.T) {
a := []string{"1", "2", "3", "4", "5"}
b := []string{"1", "2", "X", "4", "5"}
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 1)
require.Len(t, hunks, 1)
h := hunks[0]
// Context=1: line 2 (before) + remove(3)/add(X) + line 4 (after) = 3 each side
assert.Equal(t, 3, h.countA) // 2, remove(3), 4
assert.Equal(t, 3, h.countB) // 2, add(X), 4
}
func TestBuildHunks_TwoSeparateChanges(t *testing.T) {
// Changes far enough apart to be separate hunks
a := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"}
b := []string{"1", "X", "3", "4", "5", "6", "7", "8", "9", "10", "Y", "12"}
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 1)
assert.Len(t, hunks, 2)
}
func TestBuildHunks_MergesNearbyChanges(t *testing.T) {
// Two changes only 2 lines apart with ctx=3 should merge
a := []string{"1", "2", "3", "4", "5", "6", "7"}
b := []string{"1", "X", "3", "4", "Y", "6", "7"}
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 3)
assert.Len(t, hunks, 1, "nearby changes should merge into one hunk")
}
func TestBuildHunks_ContextClampedToFileEdge(t *testing.T) {
// Change at line 1 — context shouldn't go negative
a := []string{"old", "same"}
b := []string{"new", "same"}
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 3)
require.Len(t, hunks, 1)
assert.Equal(t, 1, hunks[0].startA)
assert.Equal(t, 1, hunks[0].startB)
}
func TestBuildHunks_Counts(t *testing.T) {
a := []string{"ctx", "old1", "old2", "ctx"}
b := []string{"ctx", "new1", "ctx"}
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 1)
require.Len(t, hunks, 1)
h := hunks[0]
// countA = context lines + removed lines
// countB = context lines + added lines
aLines := 0
bLines := 0
for _, dl := range h.lines {
if dl.op == opEqual || dl.op == opRemove {
aLines++
}
if dl.op == opEqual || dl.op == opAdd {
bLines++
}
}
assert.Equal(t, aLines, h.countA)
assert.Equal(t, bLines, h.countB)
}
// --- inlineHighlight ---
func TestInlineHighlight_IdenticalLines(t *testing.T) {
a, b := inlineHighlight("same text", "same text")
// No ANSI escapes added when lines are identical
assert.Equal(t, "same text", a)
assert.Equal(t, "same text", b)
}
func TestInlineHighlight_SingleWordDiff(t *testing.T) {
a, b := inlineHighlight("hello world", "hello Earth")
// "hello " is common prefix, no common suffix
assert.Contains(t, a, "hello ")
assert.Contains(t, b, "hello ")
// Changed part should have bold marker
assert.Contains(t, a, ansiBold)
assert.Contains(t, b, ansiBold)
// Changed part should have appropriate background
assert.Contains(t, a, ansiRedBg)
assert.Contains(t, b, ansiGrnBg)
}
func TestInlineHighlight_MiddleChange(t *testing.T) {
a, b := inlineHighlight("abc-OLD-xyz", "abc-NEW-xyz")
// Common prefix "abc-", common suffix "-xyz"
// Both lines should highlight "OLD" / "NEW" in bold
assert.Contains(t, a, ansiBold)
assert.Contains(t, b, ansiBold)
assert.Contains(t, a, "OLD")
assert.Contains(t, b, "NEW")
// Prefix and suffix present without bold
assertPlainContains(t, a, "abc-")
assertPlainContains(t, b, "abc-")
}
func TestInlineHighlight_PrefixOnlyDifference(t *testing.T) {
a, b := inlineHighlight("XXX-same", "YYY-same")
// "-same" is common suffix
assert.Contains(t, a, "XXX")
assert.Contains(t, b, "YYY")
assert.Contains(t, a, ansiBold)
}
func TestInlineHighlight_SuffixOnlyDifference(t *testing.T) {
a, b := inlineHighlight("same-XXX", "same-YYY")
// "same-" is common prefix
assert.Contains(t, a, "XXX")
assert.Contains(t, b, "YYY")
assert.Contains(t, a, ansiBold)
}
func TestInlineHighlight_EmptyVsNonEmpty(t *testing.T) {
_, b := inlineHighlight("", "added")
assert.Contains(t, b, "added")
assert.Contains(t, b, ansiBold)
}
func TestInlineHighlight_Unicode(t *testing.T) {
a, b := inlineHighlight("привет мир", "привет мор")
assert.Contains(t, a, ansiBold)
assert.Contains(t, b, ansiBold)
// Common prefix "привет м" + common suffix "р" should be plain
assertPlainContains(t, a, "привет м")
assertPlainContains(t, b, "привет м")
}
// assertPlainContains checks that s contains substr in a position
// not immediately preceded by an ANSI escape.
func assertPlainContains(t *testing.T, s, substr string) {
t.Helper()
assert.Contains(t, s, substr, "string should contain %q", substr)
}
// --- integration: computeDiffOps + buildHunks round-trip consistency ---
func TestDiffOps_AllOpsPreserveText(t *testing.T) {
a := []string{"line1", "line2", "line3", "line4"}
b := []string{"line1", "changed", "line3", "added", "line4"}
ops := computeDiffOps(a, b)
// Reconstruct A and B from ops
var gotA, gotB []string
for _, op := range ops {
switch op.op {
case opEqual:
gotA = append(gotA, op.text)
gotB = append(gotB, op.text)
case opRemove:
gotA = append(gotA, op.text)
case opAdd:
gotB = append(gotB, op.text)
}
}
assert.Equal(t, a, gotA, "reconstructed A must match original")
assert.Equal(t, b, gotB, "reconstructed B must match original")
}
func TestBuildHunks_AllChangedLinesPresent(t *testing.T) {
a := strings.Split("a\nb\nc\nd\ne\nf\ng\nh\ni\nj", "\n")
b := strings.Split("a\nB\nc\nd\ne\nf\ng\nH\ni\nj", "\n")
ops := computeDiffOps(a, b)
hunks := buildHunks(ops, 1)
// Collect all changed texts from hunks
var removed, added []string
for _, h := range hunks {
for _, dl := range h.lines {
switch dl.op {
case opRemove:
removed = append(removed, dl.text)
case opAdd:
added = append(added, dl.text)
}
}
}
assert.Equal(t, []string{"b", "h"}, removed)
assert.Equal(t, []string{"B", "H"}, added)
}