package mark
import (
"testing"
"github.com/kovetskiy/mark/v16/confluence"
"github.com/stretchr/testify/assert"
)
// ---------------------------------------------------------------------------
// Helper function unit tests
// ---------------------------------------------------------------------------
func TestTruncateSelection(t *testing.T) {
assert.Equal(t, "hello", truncateSelection("hello", 10))
assert.Equal(t, "hello", truncateSelection("hello", 5))
assert.Equal(t, "hell…", truncateSelection("hello", 4))
assert.Equal(t, "", truncateSelection("", 5))
// Multibyte runes count as single units.
assert.Equal(t, "世界…", truncateSelection("世界 is the world", 2))
}
func TestLevenshteinDistance(t *testing.T) {
tests := []struct {
s1, s2 string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"abc", "abc", 0},
{"abc", "axc", 1}, // one substitution
{"abc", "ab", 1}, // one deletion
{"ab", "abc", 1}, // one insertion
{"kitten", "sitting", 3},
// Multibyte: é is one rune, so distance from "héllo" to "hello" is 1.
{"héllo", "hello", 1},
}
for _, tt := range tests {
t.Run(tt.s1+"/"+tt.s2, func(t *testing.T) {
assert.Equal(t, tt.want, levenshteinDistance(tt.s1, tt.s2))
})
}
}
func TestContextBefore(t *testing.T) {
// Basic cases.
assert.Equal(t, "", contextBefore("hello", 0, 10))
assert.Equal(t, "hello", contextBefore("hello", 5, 10))
assert.Equal(t, "llo", contextBefore("hello", 5, 3))
// "héllo" is 6 bytes (h=1, é=2, l=1, l=1, o=1).
// maxBytes=4 → raw start=2, which lands mid-rune (é's continuation byte).
// Should advance to byte 3 (first 'l').
assert.Equal(t, "llo", contextBefore("héllo", 6, 4))
}
func TestContextAfter(t *testing.T) {
// Basic cases.
assert.Equal(t, "", contextAfter("hello", 5, 10))
assert.Equal(t, "hello", contextAfter("hello", 0, 10))
assert.Equal(t, "hel", contextAfter("hello", 0, 3))
// "héllo" is 6 bytes. contextAfter(s, 0, 2) → raw end=2 (é's continuation
// byte), which is not a rune start. Should back up to 1, returning just "h".
assert.Equal(t, "h", contextAfter("héllo", 0, 2))
}
// makeComments builds an InlineComments value from alternating
// (selection, markerRef) pairs, all with location "inline".
func makeComments(pairs ...string) *confluence.InlineComments {
c := &confluence.InlineComments{}
for i := 0; i+1 < len(pairs); i += 2 {
selection, ref := pairs[i], pairs[i+1]
c.Results = append(c.Results, confluence.InlineCommentResult{
Extensions: confluence.InlineCommentExtensions{
Location: "inline",
InlineProperties: confluence.InlineCommentProperties{
OriginalSelection: selection,
MarkerRef: ref,
},
},
})
}
return c
}
func TestMergeComments(t *testing.T) {
body := "
Hello world
"
oldBody := `Hello world
`
comments := makeComments("world", "uuid-123")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello world
`, result)
}
func TestMergeComments_Escaping(t *testing.T) {
body := "Hello & world
"
oldBody := `Hello & world
`
comments := makeComments("&", "uuid-456")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello & world
`, result)
}
func TestMergeComments_Disambiguation(t *testing.T) {
body := "Item one. Item two. Item one.
"
// Comment is on the second "Item one."
oldBody := `Item one. Item two. Item one.
`
comments := makeComments("Item one.", "uuid-1")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// Context should correctly pick the second occurrence
assert.Equal(t, `Item one. Item two. Item one.
`, result)
}
// TestMergeComments_SelectionMissing verifies that a comment whose selection
// no longer appears in the new body is dropped without returning an error or panicking.
// A warning is logged so the user knows the comment was not relocated.
func TestMergeComments_SelectionMissing(t *testing.T) {
body := "Completely different content
"
oldBody := `old text
`
comments := makeComments("old text", "uuid-gone")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// Comment is dropped; body is returned unchanged.
assert.Equal(t, body, result)
}
// TestMergeComments_OverlappingSelections verifies that when two comments
// reference overlapping text regions the later one (by position) is kept and
// the earlier overlapping one is dropped rather than corrupting the body.
func TestMergeComments_OverlappingSelections(t *testing.T) {
body := "foo bar baz
"
// Neither comment has a marker in oldBody, so no positional context is
// available; the algorithm falls back to a plain string search.
oldBody := "foo bar baz
"
// "foo bar" starts at 3, ends at 10; "bar baz" starts at 7, ends at 14.
// They overlap on "bar". The later match (uuid-B at position 7) wins.
comments := makeComments("foo bar", "uuid-A", "bar baz", "uuid-B")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `foo bar baz
`, result)
}
// TestMergeComments_NilComments verifies that a nil comments pointer is
// handled gracefully and the new body is returned unchanged.
func TestMergeComments_NilComments(t *testing.T) {
body := "Hello world
"
result, err := mergeComments(body, "", nil)
assert.NoError(t, err)
assert.Equal(t, body, result)
}
// TestMergeComments_HTMLEntities verifies that selections containing HTML
// entities (<, >) are matched correctly. The API returns raw (unescaped)
// text for OriginalSelection; htmlEscapeText encodes &, < and > to their
// entity forms before searching.
func TestMergeComments_HTMLEntities(t *testing.T) {
body := `Hello <world> it's me
`
oldBody := `Hello <world> it's me
`
// The API returns the raw (unescaped) selection text.
comments := makeComments("", "uuid-ent")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello <world> it's me
`, result)
}
// TestMergeComments_ApostropheEncoded verifies the known limitation: when a
// selection includes an apostrophe that Confluence stores as the numeric
// entity ' in the page body, mergeComments cannot locate the selection
// (htmlEscapeText does not encode ' to ') and the comment is dropped with
// a warning rather than panicking or producing invalid output.
func TestMergeComments_ApostropheEncoded(t *testing.T) {
// New body uses ' entity (as Confluence sometimes stores apostrophes).
body := `Hello <world> it's me
`
// Old body has the comment marker around a selection that includes an apostrophe.
oldBody := `Hello <world> it's me
`
// The API returns the raw unescaped selection including a literal apostrophe.
comments := makeComments(" it's", "uuid-apos-enc")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// The comment is dropped (body unchanged) because htmlEscapeText("it's")
// produces "it's", which doesn't match "it's" in the new body.
assert.Equal(t, body, result)
}
// TestMergeComments_ApostropheSelection verifies that a selection containing a
// literal apostrophe is found when the new body also contains a literal
// apostrophe (as mark's renderer typically emits). This exercises the
// htmlEscapeText path which intentionally does not encode ' or ".
func TestMergeComments_ApostropheSelection(t *testing.T) {
body := `Hello it's a test
`
oldBody := `Hello it's a test
`
// The API returns the raw (unescaped) selection text with a literal apostrophe.
comments := makeComments("it's", "uuid-apos")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello it's a test
`, result)
}
// TestMergeComments_NestedTags verifies that a marker whose stored content
// contains nested inline tags (e.g. ) is still recognised by
// markerRegex and the comment is correctly relocated into the new body.
func TestMergeComments_NestedTags(t *testing.T) {
// The new body contains plain bold text (no marker yet).
body := "Hello world
"
// The old body already has the marker wrapping the bold tag.
oldBody := `Hello world
`
// The API returns the raw selected text without markup.
comments := makeComments("world", "uuid-nested")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello world
`, result)
}
// TestMergeComments_EmptySelection verifies that a comment with an empty
// OriginalSelection is skipped without panicking and the body is returned
// unchanged.
func TestMergeComments_EmptySelection(t *testing.T) {
body := "Hello world
"
comments := makeComments("", "uuid-empty")
result, err := mergeComments(body, body, comments)
assert.NoError(t, err)
assert.Equal(t, body, result)
}
// TestMergeComments_DuplicateMarkerRef verifies that multiple comment results
// sharing the same MarkerRef (e.g. threaded replies) produce exactly one
// insertion rather than nested duplicates.
func TestMergeComments_DuplicateMarkerRef(t *testing.T) {
body := "Hello world
"
oldBody := `Hello world
`
// Two results with identical ref — simulates threaded replies.
comments := makeComments("world", "uuid-dup", "world", "uuid-dup")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello world
`, result)
}
// ---------------------------------------------------------------------------
// Additional mergeComments scenario tests
// ---------------------------------------------------------------------------
// TestMergeComments_MultipleComments verifies that two non-overlapping comments
// are both correctly re-embedded via back-to-front replacement.
func TestMergeComments_MultipleComments(t *testing.T) {
body := "Hello world and foo bar
"
oldBody := `Hello world and foo bar
`
comments := makeComments("world", "uuid-1", "bar", "uuid-2")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `Hello world and foo bar
`, result)
}
// TestMergeComments_EmptyResults verifies that an InlineComments value with a
// non-nil but empty Results slice is handled gracefully.
func TestMergeComments_EmptyResults(t *testing.T) {
body := "Hello world
"
result, err := mergeComments(body, body, &confluence.InlineComments{})
assert.NoError(t, err)
assert.Equal(t, body, result)
}
// TestMergeComments_NonInlineLocation verifies that page-level comments
// (location != "inline") are silently skipped and the body is unchanged.
func TestMergeComments_NonInlineLocation(t *testing.T) {
body := "Hello world
"
comments := &confluence.InlineComments{
Results: []confluence.InlineCommentResult{
{
Extensions: confluence.InlineCommentExtensions{
Location: "page",
InlineProperties: confluence.InlineCommentProperties{
OriginalSelection: "Hello",
MarkerRef: "uuid-page",
},
},
},
},
}
result, err := mergeComments(body, body, comments)
assert.NoError(t, err)
assert.Equal(t, body, result)
}
// TestMergeComments_NoContext verifies that when a comment's MarkerRef has no
// corresponding marker in oldBody (no context available) the first occurrence
// of the selection in the new body is used.
func TestMergeComments_NoContext(t *testing.T) {
body := "foo bar foo
"
oldBody := "foo bar foo
" // no markers → no context
comments := makeComments("foo", "uuid-noctx")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// First occurrence of "foo" is at position 3.
assert.Equal(t, `foo bar foo
`, result)
}
// TestMergeComments_UTF8 verifies that selections and bodies containing
// multibyte UTF-8 characters are handled correctly.
func TestMergeComments_UTF8(t *testing.T) {
body := "こんにちは世界
"
oldBody := `こんにちは世界
`
comments := makeComments("世界", "uuid-jp")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `こんにちは世界
`, result)
}
// TestMergeComments_SelectionWithQuotes verifies that a selection containing
// apostrophes or double-quotes is found correctly in the new body even though
// html.EscapeString would encode those characters. Only &, <, > should be
// escaped when searching.
func TestMergeComments_SelectionWithQuotes(t *testing.T) {
body := `It's a "test" page
`
oldBody := `It's a "test" page
`
comments := makeComments(`"test"`, "uuid-q")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `It's a "test" page
`, result)
}
// TestMergeComments_DuplicateMarkerRefDropped verifies that when multiple
// comment results share the same MarkerRef and the selection cannot be found,
// only a single warning is emitted (not one per result).
func TestMergeComments_DuplicateMarkerRefDropped(t *testing.T) {
body := "Hello world
"
// Duplicate refs, but selection "gone" is not present in body or oldBody.
comments := makeComments("gone", "uuid-dup2", "gone", "uuid-dup2")
result, err := mergeComments(body, body, comments)
assert.NoError(t, err)
assert.Equal(t, body, result) // body unchanged, single warning logged
}
// TestMergeComments_CDATASelection verifies that a selection inside a
// CDATA-backed macro body (e.g. ac:code) is matched even though < and > are
// stored as raw characters rather than HTML entities. The raw form is tried as
// a fallback when the escaped form is not found.
func TestMergeComments_CDATASelection(t *testing.T) {
// New body contains a code macro with CDATA — raw < and > in the content.
body := ` }]]>`
// Old body has the marker around the raw selection inside CDATA.
oldBody := ` }]]>`
// The API returns the raw (unescaped) selection.
comments := makeComments("", "uuid-cdata")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// The raw selection "" should be found and wrapped with a marker.
assert.Equal(t, ` }]]>`, result)
}