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