package main
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.bigb.es/confluence-md-utilities/converter"
"go.bigb.es/confluence-md-utilities/format"
)
// --- normalizeForVerify ---
func TestNormalizeForVerify_RemovesEmptyBrParagraph(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "br with space before slash",
input: `
text
`,
want: `text
`,
},
{
name: "br without space",
input: `
text
`,
want: `text
`,
},
{
name: "br with whitespace around",
input: " \n
\n
text
",
want: "text
",
},
{
name: "no empty paragraphs",
input: `hello
`,
want: `hello
`,
},
{
name: "multiple empty paragraphs",
input: `
text
`,
want: `text
`,
},
}
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: `hello world`,
want: `hello world`,
},
{
name: "span with attributes inside code",
input: `a : `,
want: `a : `,
},
{
name: "no span inside code",
input: `plain`,
want: `plain`,
},
{
name: "span outside code also stripped (decorative wrappers don't survive markdown)",
input: `text
`,
want: `text
`,
},
}
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: `helloworld`,
want: `helloworld`,
},
{
name: "with whitespace between",
input: `hello world`,
want: `hello world`,
},
{
name: "single code element untouched",
input: `hello`,
want: `hello`,
},
{
name: "combined: span inside + adjacent merge",
input: `plan : vclock`,
want: `plan : vclock`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeForVerify(tt.input))
})
}
}
func TestNormalizeForVerify_BareTextAfterHeading(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "genuine bare text is wrapped",
input: `Title
bare text`,
want: `Title
bare text
`,
},
{
// Regression: \S used to match the '<' of the following tag, so the
// capture swallowed and wrapped it in ...
,
// splitting a code macro from its body and breaking round-trip.
name: "structured-macro after heading is left intact",
input: "Heading
\n",
want: "Heading
\n",
},
{
name: "paragraph after heading is left intact",
input: "T
\nalready a paragraph
",
want: "T
\nalready a paragraph
",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeForVerify(tt.input))
})
}
}
// TestVerifyRoundTrip_CodeBlockAfterHeading is the end-to-end regression for the
// no-language fenced code block immediately after a heading: it must survive
// xml2md → md2xml with its body intact.
func TestVerifyRoundTrip_CodeBlockAfterHeading(t *testing.T) {
xml := "Heading
\n" +
`` +
`` +
`` + "\nAfter.
"
norm := normalizeForVerify(xml)
md, err := converter.ConfluenceToMarkdown(norm)
require.NoError(t, err)
rt, err := converter.MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Equal(t,
format.PrettyXML(norm, " "),
format.PrettyXML(normalizeForVerify(rt), " "),
"code-block body must survive the round-trip")
}
func TestNormalizeForVerify_TaskID(t *testing.T) {
// Confluence-assigned task ids are renumbered by the round-trip; verify must
// ignore the value by zeroing it on both sides.
in := `9complete`
want := `0complete`
assert.Equal(t, want, normalizeForVerify(in))
}
func TestNormalizeForVerify_ListStyleAttr(t *testing.T) {
// Markdown can't carry list bullet styling, so presentational attributes on
// / are stripped on both sides.
assert.Equal(t, ``,
normalizeForVerify(``))
assert.Equal(t, `- x
`,
normalizeForVerify(`- x
`))
// A bare is untouched.
assert.Equal(t, ``, normalizeForVerify(``))
}
func TestNormalizeForVerify_TaskBodyTrailingWS(t *testing.T) {
// The stored form ends a task body with a stray nbsp after the user link that
// no Markdown construct reproduces; strip it (regular space and nbsp).
nbsp := " "
in := `` + nbsp + ``
want := ``
assert.Equal(t, want, normalizeForVerify(in))
}
func TestNormalizeForVerify_EmphasisWhitespace(t *testing.T) {
// The stored ` x ` and the round-trip ` x `
// must canonicalize to the same shape (whitespace outside the tags).
stored := `a x b`
roundtrip := `a x b`
assert.Equal(t, normalizeForVerify(roundtrip), normalizeForVerify(stored))
assert.Equal(t, `a x b`, normalizeForVerify(stored))
}
// --- 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)
}