-
Notifications
You must be signed in to change notification settings - Fork 216
Expand file tree
/
Copy pathmark_test.go
More file actions
369 lines (320 loc) · 16.4 KB
/
mark_test.go
File metadata and controls
369 lines (320 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
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 := "<p>Hello world</p>"
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-123">world</ac:inline-comment-marker></p>`
comments := makeComments("world", "uuid-123")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-123">world</ac:inline-comment-marker></p>`, result)
}
func TestMergeComments_Escaping(t *testing.T) {
body := "<p>Hello & world</p>"
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-456">&</ac:inline-comment-marker> world</p>`
comments := makeComments("&", "uuid-456")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-456">&</ac:inline-comment-marker> world</p>`, result)
}
func TestMergeComments_Disambiguation(t *testing.T) {
body := "<p>Item one. Item two. Item one.</p>"
// Comment is on the second "Item one."
oldBody := `<p>Item one. Item two. <ac:inline-comment-marker ac:ref="uuid-1">Item one.</ac:inline-comment-marker></p>`
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, `<p>Item one. Item two. <ac:inline-comment-marker ac:ref="uuid-1">Item one.</ac:inline-comment-marker></p>`, 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 := "<p>Completely different content</p>"
oldBody := `<p><ac:inline-comment-marker ac:ref="uuid-gone">old text</ac:inline-comment-marker></p>`
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 := "<p>foo bar baz</p>"
// Neither comment has a marker in oldBody, so no positional context is
// available; the algorithm falls back to a plain string search.
oldBody := "<p>foo bar baz</p>"
// "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, `<p>foo <ac:inline-comment-marker ac:ref="uuid-B">bar baz</ac:inline-comment-marker></p>`, 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 := "<p>Hello world</p>"
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 := `<p>Hello <world> it's me</p>`
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-ent"><world></ac:inline-comment-marker> it's me</p>`
// The API returns the raw (unescaped) selection text.
comments := makeComments("<world>", "uuid-ent")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-ent"><world></ac:inline-comment-marker> it's me</p>`, 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 := `<p>Hello <world> it's me</p>`
// Old body has the comment marker around a selection that includes an apostrophe.
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-apos-enc"><world> it's</ac:inline-comment-marker> me</p>`
// The API returns the raw unescaped selection including a literal apostrophe.
comments := makeComments("<world> 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 := `<p>Hello it's a test</p>`
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-apos">it's</ac:inline-comment-marker> a test</p>`
// 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, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-apos">it's</ac:inline-comment-marker> a test</p>`, result)
}
// TestMergeComments_NestedTags verifies that a marker whose stored content
// contains nested inline tags (e.g. <strong>) 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 := "<p>Hello <strong>world</strong></p>"
// The old body already has the marker wrapping the bold tag.
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-nested"><strong>world</strong></ac:inline-comment-marker></p>`
// 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, `<p>Hello <strong><ac:inline-comment-marker ac:ref="uuid-nested">world</ac:inline-comment-marker></strong></p>`, 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 := "<p>Hello world</p>"
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
// <ac:inline-comment-marker> insertion rather than nested duplicates.
func TestMergeComments_DuplicateMarkerRef(t *testing.T) {
body := "<p>Hello world</p>"
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-dup">world</ac:inline-comment-marker></p>`
// 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, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-dup">world</ac:inline-comment-marker></p>`, 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 := "<p>Hello world and foo bar</p>"
oldBody := `<p>Hello <ac:inline-comment-marker ac:ref="uuid-1">world</ac:inline-comment-marker> and foo <ac:inline-comment-marker ac:ref="uuid-2">bar</ac:inline-comment-marker></p>`
comments := makeComments("world", "uuid-1", "bar", "uuid-2")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>Hello <ac:inline-comment-marker ac:ref="uuid-1">world</ac:inline-comment-marker> and foo <ac:inline-comment-marker ac:ref="uuid-2">bar</ac:inline-comment-marker></p>`, 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 := "<p>Hello world</p>"
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 := "<p>Hello world</p>"
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 := "<p>foo bar foo</p>"
oldBody := "<p>foo bar foo</p>" // 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, `<p><ac:inline-comment-marker ac:ref="uuid-noctx">foo</ac:inline-comment-marker> bar foo</p>`, result)
}
// TestMergeComments_UTF8 verifies that selections and bodies containing
// multibyte UTF-8 characters are handled correctly.
func TestMergeComments_UTF8(t *testing.T) {
body := "<p>こんにちは世界</p>"
oldBody := `<p>こんにちは<ac:inline-comment-marker ac:ref="uuid-jp">世界</ac:inline-comment-marker></p>`
comments := makeComments("世界", "uuid-jp")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>こんにちは<ac:inline-comment-marker ac:ref="uuid-jp">世界</ac:inline-comment-marker></p>`, 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 := `<p>It's a "test" page</p>`
oldBody := `<p>It's a <ac:inline-comment-marker ac:ref="uuid-q">"test"</ac:inline-comment-marker> page</p>`
comments := makeComments(`"test"`, "uuid-q")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
assert.Equal(t, `<p>It's a <ac:inline-comment-marker ac:ref="uuid-q">"test"</ac:inline-comment-marker> page</p>`, 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 := "<p>Hello world</p>"
// 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 := `<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[func foo() { return <nil> }]]></ac:plain-text-body></ac:structured-macro>`
// Old body has the marker around the raw selection inside CDATA.
oldBody := `<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[func foo() { return <ac:inline-comment-marker ac:ref="uuid-cdata"><nil></ac:inline-comment-marker> }]]></ac:plain-text-body></ac:structured-macro>`
// The API returns the raw (unescaped) selection.
comments := makeComments("<nil>", "uuid-cdata")
result, err := mergeComments(body, oldBody, comments)
assert.NoError(t, err)
// The raw selection "<nil>" should be found and wrapped with a marker.
assert.Equal(t, `<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[func foo() { return <ac:inline-comment-marker ac:ref="uuid-cdata"><nil></ac:inline-comment-marker> }]]></ac:plain-text-body></ac:structured-macro>`, result)
}