package converter
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// === Markdown to Confluence XML tests ===
func TestMarkdownToConfluence_Heading(t *testing.T) {
result, err := MarkdownToConfluence([]byte("# Hello World"))
require.NoError(t, err)
assert.Contains(t, result, "<h1>Hello World</h1>")
}
func TestMarkdownToConfluence_AllHeadingLevels(t *testing.T) {
for i, tag := range []string{"h1", "h2", "h3", "h4", "h5", "h6"} {
prefix := strings.Repeat("#", i+1)
result, err := MarkdownToConfluence([]byte(prefix + " Heading"))
require.NoError(t, err)
assert.Contains(t, result, "<"+tag+">Heading</"+tag+">", "level %d", i+1)
}
}
func TestMarkdownToConfluence_Bold(t *testing.T) {
result, err := MarkdownToConfluence([]byte("**bold text**"))
require.NoError(t, err)
assert.Contains(t, result, "<strong>bold text</strong>")
}
func TestMarkdownToConfluence_Italic(t *testing.T) {
result, err := MarkdownToConfluence([]byte("*italic text*"))
require.NoError(t, err)
assert.Contains(t, result, "<em>italic text</em>")
}
func TestMarkdownToConfluence_Strikethrough(t *testing.T) {
result, err := MarkdownToConfluence([]byte("~~deleted~~"))
require.NoError(t, err)
assert.Contains(t, result, "<del>deleted</del>")
}
func TestMarkdownToConfluence_InlineCode(t *testing.T) {
result, err := MarkdownToConfluence([]byte("`code`"))
require.NoError(t, err)
assert.Contains(t, result, "<code>code</code>")
}
func TestMarkdownToConfluence_CodeBlock(t *testing.T) {
input := "```go\nfmt.Println(\"hello\")\n```"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="code"`)
assert.Contains(t, result, `ac:name="language">go`)
assert.Contains(t, result, `<![CDATA[fmt.Println("hello")]]>`)
}
func TestMarkdownToConfluence_CodeBlockNoLanguage(t *testing.T) {
input := "```\nsome code\n```"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="code"`)
assert.NotContains(t, result, `ac:name="language"`)
}
func TestMarkdownToConfluence_Table(t *testing.T) {
input := "| A | B |\n|---|---|\n| 1 | 2 |"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "<table>")
assert.Contains(t, result, "<th><p>A</p></th>")
assert.Contains(t, result, "<td><p>1</p></td>")
}
func TestMarkdownToConfluence_Blockquote(t *testing.T) {
result, err := MarkdownToConfluence([]byte("> Important note"))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="info"`)
assert.Contains(t, result, "Important note")
}
func TestMarkdownToConfluence_Image(t *testing.T) {
result, err := MarkdownToConfluence([]byte(""))
require.NoError(t, err)
assert.Contains(t, result, "<ac:image")
assert.Contains(t, result, `ri:value="https://example.com/img.png"`)
assert.Contains(t, result, `ac:alt="alt text"`)
}
func TestMarkdownToConfluence_Link(t *testing.T) {
result, err := MarkdownToConfluence([]byte("[Click here](https://example.com)"))
require.NoError(t, err)
assert.Contains(t, result, `<a href="https://example.com">Click here</a>`)
}
func TestMarkdownToConfluence_UnorderedList(t *testing.T) {
input := "- One\n- Two\n- Three"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "<ul>")
assert.Contains(t, result, "<li>")
}
func TestMarkdownToConfluence_OrderedList(t *testing.T) {
input := "1. First\n2. Second\n3. Third"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "<ol>")
}
func TestMarkdownToConfluence_NestedList(t *testing.T) {
input := "- Parent\n - Child"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Equal(t, 2, strings.Count(result, "<ul>"), "expected 2 ul tags for nested list")
}
func TestMarkdownToConfluence_HorizontalRule(t *testing.T) {
result, err := MarkdownToConfluence([]byte("---"))
require.NoError(t, err)
assert.Contains(t, result, "<hr />")
}
func TestMarkdownToConfluence_TaskList(t *testing.T) {
input := "- [x] Done\n- [ ] Todo"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "<ac:task-list>")
assert.Contains(t, result, "<ac:task>")
assert.Contains(t, result, "<ac:task-id>")
assert.Contains(t, result, "<ac:task-status>complete</ac:task-status>")
assert.Contains(t, result, "<ac:task-status>incomplete</ac:task-status>")
assert.Contains(t, result, "<ac:task-body>")
assert.Contains(t, result, "</ac:task-body>")
}
func TestMarkdownToConfluence_Paragraph(t *testing.T) {
result, err := MarkdownToConfluence([]byte("Hello world"))
require.NoError(t, err)
assert.Contains(t, result, "<p>Hello world</p>")
}
func TestMarkdownToConfluence_HardLineBreak(t *testing.T) {
result, err := MarkdownToConfluence([]byte("Line one \nLine two"))
require.NoError(t, err)
assert.Contains(t, result, "<br/>")
}
func TestMarkdownToConfluence_InlineCommentMarker(t *testing.T) {
input := `Before <span data-inline-comment="abc-123">commented text</span> after`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `<ac:inline-comment-marker ac:ref="abc-123">`)
assert.Contains(t, result, "</ac:inline-comment-marker>")
assert.Contains(t, result, "commented text")
}
// === Confluence XML to Markdown tests ===
func TestConfluenceToMarkdown_Heading(t *testing.T) {
result, err := ConfluenceToMarkdown("<h1>Hello World</h1>")
require.NoError(t, err)
assert.Contains(t, result, "# Hello World")
}
func TestConfluenceToMarkdown_Bold(t *testing.T) {
result, err := ConfluenceToMarkdown("<p><strong>bold</strong></p>")
require.NoError(t, err)
assert.Contains(t, result, "**bold**")
}
func TestConfluenceToMarkdown_Italic(t *testing.T) {
result, err := ConfluenceToMarkdown("<p><em>italic</em></p>")
require.NoError(t, err)
assert.Contains(t, result, "*italic*")
}
func TestConfluenceToMarkdown_Strikethrough(t *testing.T) {
result, err := ConfluenceToMarkdown("<p><del>deleted</del></p>")
require.NoError(t, err)
assert.Contains(t, result, "~~deleted~~")
}
func TestConfluenceToMarkdown_InlineCode(t *testing.T) {
result, err := ConfluenceToMarkdown("<p><code>code</code></p>")
require.NoError(t, err)
assert.Contains(t, result, "`code`")
}
func TestConfluenceToMarkdown_CodeBlock(t *testing.T) {
input := `<ac:structured-macro ac:name="code" ac:schema-version="1"><ac:parameter ac:name="language">python</ac:parameter><ac:plain-text-body><![CDATA[print("hello")]]></ac:plain-text-body></ac:structured-macro>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "```python")
assert.Contains(t, result, `print("hello")`)
}
func TestConfluenceToMarkdown_Link(t *testing.T) {
result, err := ConfluenceToMarkdown(`<p><a href="https://example.com">Click</a></p>`)
require.NoError(t, err)
assert.Contains(t, result, "[Click](https://example.com)")
}
func TestConfluenceToMarkdown_UnorderedList(t *testing.T) {
result, err := ConfluenceToMarkdown("<ul>\n<li>One</li>\n<li>Two</li>\n</ul>")
require.NoError(t, err)
assert.Contains(t, result, "- One")
assert.Contains(t, result, "- Two")
}
func TestConfluenceToMarkdown_OrderedList(t *testing.T) {
result, err := ConfluenceToMarkdown("<ol>\n<li>First</li>\n<li>Second</li>\n</ol>")
require.NoError(t, err)
assert.Contains(t, result, "1. First")
assert.Contains(t, result, "2. Second")
}
func TestConfluenceToMarkdown_Table(t *testing.T) {
input := `<table><tbody><tr><th><p>Name</p></th><th><p>Age</p></th></tr><tr><td><p>Alice</p></td><td><p>30</p></td></tr></tbody></table>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "| Name |")
assert.Contains(t, result, "| Alice |")
assert.Contains(t, result, "|---|---|")
}
func TestConfluenceToMarkdown_InfoPanel(t *testing.T) {
input := `<ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body><p>Important note</p></ac:rich-text-body></ac:structured-macro>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "> Important note")
}
func TestConfluenceToMarkdown_HorizontalRule(t *testing.T) {
result, err := ConfluenceToMarkdown("<hr/>")
require.NoError(t, err)
assert.Contains(t, result, "---")
}
func TestConfluenceToMarkdown_Emoticons(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"plus", `<ac:emoticon ac:name="plus"/>`, "(+)"},
{"minus", `<ac:emoticon ac:name="minus"/>`, "(-)"},
{"question", `<ac:emoticon ac:name="question"/>`, "(?)"},
{"tick", `<ac:emoticon ac:name="tick"/>`, "(v)"},
{"cross", `<ac:emoticon ac:name="cross"/>`, "(x)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := ConfluenceToMarkdown(tc.input)
require.NoError(t, err)
assert.Contains(t, result, tc.expected)
})
}
}
func TestConfluenceToMarkdown_InlineCommentMarker(t *testing.T) {
input := `<p>Before <ac:inline-comment-marker ac:ref="abc-123">commented text</ac:inline-comment-marker> after</p>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `<span data-inline-comment="abc-123">`)
assert.Contains(t, result, "commented text")
assert.Contains(t, result, "</span>")
}
// === User reference tests ===
func TestConfluenceToMarkdown_UserReference(t *testing.T) {
input := `<p>Author: <ac:link><ri:user ri:userkey="3cddbcec40cb91700140cb9345ed0b5c"/></ac:link></p>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `<span data-user-key="3cddbcec40cb91700140cb9345ed0b5c"/>`)
}
func TestMarkdownToConfluence_UserReference(t *testing.T) {
input := `Author: <span data-user-key="3cddbcec40cb91700140cb9345ed0b5c"/>`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `<ac:link><ri:user ri:userkey="3cddbcec40cb91700140cb9345ed0b5c"/></ac:link>`)
}
func TestRoundTrip_UserReference(t *testing.T) {
xmlInput := `<p>Author: <ac:link><ri:user ri:userkey="abc123"/></ac:link> wrote this</p>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-user-key="abc123"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ri:userkey="abc123"`)
}
// === Attachment image tests ===
func TestConfluenceToMarkdown_AttachmentImage(t *testing.T) {
input := `<p><ac:image><ri:attachment ri:filename="screenshot.png"/></ac:image></p>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `<span data-attachment="screenshot.png"/>`)
}
func TestConfluenceToMarkdown_AttachmentImageWithAlt(t *testing.T) {
input := `<p><ac:image ac:alt="My Screenshot"><ri:attachment ri:filename="screenshot.png"/></ac:image></p>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `data-attachment="screenshot.png"`)
assert.Contains(t, result, `data-alt="My Screenshot"`)
}
func TestMarkdownToConfluence_AttachmentImage(t *testing.T) {
input := `Image: <span data-attachment="screenshot.png"/>`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `<ac:image><ri:attachment ri:filename="screenshot.png"/></ac:image>`)
}
func TestMarkdownToConfluence_AttachmentImageWithAlt(t *testing.T) {
input := `<span data-attachment="screenshot.png" data-alt="My Shot"/>`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:alt="My Shot"`)
assert.Contains(t, result, `ri:filename="screenshot.png"`)
}
func TestRoundTrip_AttachmentImage(t *testing.T) {
xmlInput := `<p><ac:image><ri:attachment ri:filename="Pasted image 20260325004147.png"/></ac:image></p>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-attachment="Pasted image 20260325004147.png"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ri:filename="Pasted image 20260325004147.png"`)
}
// === Inline spacing tests ===
func TestConfluenceToMarkdown_SpaceAfterBold(t *testing.T) {
input := `<ul><li><strong>ATE:</strong> Запуск плейбуков</li></ul>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "**ATE:** Запуск плейбуков")
}
func TestConfluenceToMarkdown_SpaceBetweenInlineElements(t *testing.T) {
input := `<ul><li><strong>Bold</strong> then <em>italic</em> text</li></ul>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "**Bold** then *italic* text")
}
// === Layout tests ===
func TestConfluenceToMarkdown_Layout(t *testing.T) {
input := `<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell><p>Content</p></ac:layout-cell></ac:layout-section></ac:layout>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "<!-- ac:layout -->")
assert.Contains(t, result, `<!-- ac:layout-section type="single" -->`)
assert.Contains(t, result, "<!-- ac:layout-cell -->")
assert.Contains(t, result, "Content")
assert.Contains(t, result, "<!-- /ac:layout-cell -->")
assert.Contains(t, result, "<!-- /ac:layout-section -->")
assert.Contains(t, result, "<!-- /ac:layout -->")
}
func TestConfluenceToMarkdown_LayoutTwoColumn(t *testing.T) {
input := `<ac:layout><ac:layout-section ac:type="two_equal"><ac:layout-cell><p>Left</p></ac:layout-cell><ac:layout-cell><p>Right</p></ac:layout-cell></ac:layout-section></ac:layout>`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `type="two_equal"`)
assert.Contains(t, result, "Left")
assert.Contains(t, result, "Right")
assert.Equal(t, 2, strings.Count(result, "<!-- ac:layout-cell -->"))
assert.Equal(t, 2, strings.Count(result, "<!-- /ac:layout-cell -->"))
}
func TestMarkdownToConfluence_Layout(t *testing.T) {
input := `<!-- ac:layout -->
<!-- ac:layout-section type="single" -->
<!-- ac:layout-cell -->
Hello world
<!-- /ac:layout-cell -->
<!-- /ac:layout-section -->
<!-- /ac:layout -->
`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "<ac:layout>")
assert.Contains(t, result, `<ac:layout-section ac:type="single">`)
assert.Contains(t, result, "<ac:layout-cell>")
assert.Contains(t, result, "Hello world")
assert.Contains(t, result, "</ac:layout-cell>")
assert.Contains(t, result, "</ac:layout-section>")
assert.Contains(t, result, "</ac:layout>")
}
func TestRoundTrip_Layout(t *testing.T) {
xmlInput := `<ac:layout><ac:layout-section ac:type="two_equal"><ac:layout-cell><p>Left column</p></ac:layout-cell><ac:layout-cell><p>Right column</p></ac:layout-cell></ac:layout-section></ac:layout>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, "<ac:layout>")
assert.Contains(t, xmlOutput, `ac:type="two_equal"`)
assert.Contains(t, xmlOutput, "<ac:layout-cell>")
assert.Contains(t, xmlOutput, "Left column")
assert.Contains(t, xmlOutput, "Right column")
assert.Contains(t, xmlOutput, "</ac:layout-cell>")
assert.Contains(t, xmlOutput, "</ac:layout-section>")
assert.Contains(t, xmlOutput, "</ac:layout>")
}
// === Round-trip tests ===
func TestRoundTrip_Basic(t *testing.T) {
input := `# Test
Some **bold** and *italic* text.
## Code
` + "```go\nfunc main() {}\n```" + `
## List
- One
- Two
- Three
| H1 | H2 |
|----|-----|
| A | B |
`
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
for _, check := range []string{"# Test", "**bold**", "*italic*", "```go", "func main() {}", "- One", "| H1", "| A"} {
assert.Contains(t, md, check, "round-trip should preserve %q", check)
}
}
func TestRoundTrip_Blockquote(t *testing.T) {
input := "> This is a blockquote\n"
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
assert.Contains(t, md, "> This is a blockquote")
}
func TestRoundTrip_Link(t *testing.T) {
input := "[Example](https://example.com)\n"
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
assert.Contains(t, md, "[Example](https://example.com)")
}
func TestRoundTrip_InlineCommentMarker(t *testing.T) {
xmlInput := `<p>Hello <ac:inline-comment-marker ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca">important text</ac:inline-comment-marker> world</p>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-inline-comment="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `<ac:inline-comment-marker ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca">`)
assert.Contains(t, xmlOutput, "</ac:inline-comment-marker>")
assert.Contains(t, xmlOutput, "important text")
}
func TestConfluenceToMarkdown_AdjacentCodeMerge(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "directly adjacent code elements merged",
input: `<p><code>hello</code><code>world</code></p>`,
expected: "`helloworld`",
},
{
name: "adjacent code with span inside",
input: `<p><code>plan<span> : </span></code><code>vclock</code></p>`,
expected: "`plan : vclock`",
},
{
name: "single code element unchanged",
input: `<p><code>single</code></p>`,
expected: "`single`",
},
{
name: "non-adjacent code elements stay separate",
input: `<p><code>a</code> text <code>b</code></p>`,
expected: "`a` text `b`",
},
{
// Regression: whitespace-only text between two <code> elements
// previously caused the merge logic to swallow the gap and
// concatenate the spans.
name: "whitespace-separated code elements stay separate",
input: `<p>Благодаря <code>previous_backup_id</code> <code>tt restore plan</code> видит разрыв.</p>`,
expected: "`previous_backup_id` `tt restore plan`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ConfluenceToMarkdown(tt.input)
require.NoError(t, err)
assert.Contains(t, result, tt.expected)
})
}
}
func TestRoundTrip_ListNoTrailingWhitespace(t *testing.T) {
xmlInput := `<ul><li>item 1</li><li>item 2</li><li>item 3</li></ul>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, "<li>item 1</li>")
assert.Contains(t, xmlOutput, "<li>item 2</li>")
assert.Contains(t, xmlOutput, "<li>item 3</li>")
assert.NotContains(t, xmlOutput, "item 1\n</li>")
assert.NotContains(t, xmlOutput, "item 1 </li>")
}
func TestRoundTrip_InlineCommentFromRealXML(t *testing.T) {
xmlInput := `<p>Товарищ! <ac:inline-comment-marker ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca">Не майся дурью, копируй этот шаблон и редактируй его!</ac:inline-comment-marker> Ускоришь написание RFC!</p>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca"`)
assert.Contains(t, xmlOutput, "Не майся дурью")
}
// TestRoundTrip_TablePipeInCode locks in the fix for table cells whose <code>
// contents include a literal `|`. Without escaping, the pipe was treated as a
// column separator at re-parse time, splitting a single cell across columns
// and dropping content. Cells must escape `|` as `\|` per the GFM spec.
func TestRoundTrip_TablePipeInCode(t *testing.T) {
xmlInput := `<table><tbody>` +
`<tr><th><p>Команда</p></th><th><p>Где</p></th><th><p>Действие</p></th></tr>` +
`<tr><td><p><code>mode --target=<incremental|full> --storage=<config></code></p></td>` +
`<td><p>менеджер</p></td>` +
`<td><p>Возвращает <code>mode</code> + <code>from_vclock</code>.</p></td></tr>` +
`</tbody></table>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
// The pipe inside the code span must be escaped in the markdown so the
// table parser does not treat it as a column boundary.
assert.Contains(t, md, `\|`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
// All three columns must survive the round-trip.
assert.Contains(t, xmlOutput, `<code>mode --target=<incremental|full> --storage=<config></code>`)
assert.Contains(t, xmlOutput, "менеджер")
assert.Contains(t, xmlOutput, "<code>from_vclock</code>")
}
// TestEscapeTablePipes_NoDoubleEscape guards the helper against doubling up
// already-escaped pipes (e.g. when a cell's rendered markdown was produced by
// an upstream that already escaped some pipes itself).
func TestEscapeTablePipes_NoDoubleEscape(t *testing.T) {
cases := map[string]string{
"": "",
"plain": "plain",
"a|b": `a\|b`,
`already\|done`: `already\|done`,
"`a|b`": "`a\\|b`",
}
for in, want := range cases {
got := escapeTablePipes(in)
assert.Equal(t, want, got, "input=%q", in)
}
}
// TestRoundTrip_PageLink locks in the fix for <ac:link><ri:page …/></ac:link>.
// These page references have no plain markdown equivalent and previously got
// dropped on the way to MD, leaving an empty paragraph after a round-trip.
func TestRoundTrip_PageLink(t *testing.T) {
xmlInput := `<p>Основной документ: <ac:link><ri:page ri:space-key="TSC" ri:content-title="Бекапы и Point-in-time recovery в кластере Tarantool" /></ac:link></p>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-page-link`)
require.Contains(t, md, `data-space-key="TSC"`)
require.Contains(t, md, `data-content-title="Бекапы и Point-in-time recovery в кластере Tarantool"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `<ac:link><ri:page ri:space-key="TSC" ri:content-title="Бекапы и Point-in-time recovery в кластере Tarantool"/></ac:link>`)
assert.Contains(t, xmlOutput, "Основной документ:")
}
// TestConfluenceToMarkdown_CommentInHeading guards against the regression
// where an HTML comment that is the sole content of a heading — e.g. a TOC
// macro marker md2xml emits as `<h1><!-- ac:toc macro-id="..." --></h1>` —
// was silently dropped by xml2md. A CommentNode is neither a TextNode nor an
// ElementNode, so it used to fall through to walkChildren (a comment has no
// children) and vanish, collapsing `# <!-- ac:toc ... -->` to a bare `#`.
func TestConfluenceToMarkdown_CommentInHeading(t *testing.T) {
xmlInput := `<h1><!-- ac:toc macro-id="f9579fa5-2198-4556-92de-04db447accec" --></h1>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
assert.Contains(t, md, `# <!-- ac:toc macro-id="f9579fa5-2198-4556-92de-04db447accec" -->`)
// And the comment survives a full markdown round-trip.
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
mdBack, err := ConfluenceToMarkdown(xmlOutput)
require.NoError(t, err)
assert.Equal(t, strings.TrimSpace(md), strings.TrimSpace(mdBack))
}
// TestRoundTrip_LiteralAngleBracketText guards against the catastrophic
// regression where literal angle-bracket text stored as `<table>` (e.g.
// the function signature `box.backup.start(<table>)`) round-tripped into a real
// `<table>` element: xml2md emitted a bare `<table>`, goldmark parsed it as raw
// HTML, and the phantom table swallowed the rest of the document. xml2md now
// backslash-escapes a tag-like `<`, and renderText unescapes it back to `<`.
func TestRoundTrip_LiteralAngleBracketText(t *testing.T) {
for _, where := range []string{
`<p>call box.backup.start(<table>) now</p>`,
`<ul><li>call box.backup.start(<table>) now</li></ul>`,
} {
md, err := ConfluenceToMarkdown(where)
require.NoError(t, err)
// The '<' must be escaped so goldmark won't parse it as a table tag.
assert.Contains(t, md, `\<table>`, "input %q", where)
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "box.backup.start(<table>)", "input %q", where)
assert.NotContains(t, xmlOut, "<table>", "literal text must not become a table element")
}
// A non-tag '<' (comparison operator) is left unescaped and still survives.
md, err := ConfluenceToMarkdown(`<p>t <= time_end</p>`)
require.NoError(t, err)
assert.NotContains(t, md, `\<`, "a non-tag '<' should not be escaped")
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "t <= time_end")
}
// TestRoundTrip_EmphasisInteriorSpaces guards against `<strong> x </strong>`
// (whitespace just inside the tag) round-tripping to literal `** x **` asterisks
// — CommonMark does not treat a whitespace-flanked run as emphasis. xml2md now
// moves the spaces outside the markers (` **x** `).
func TestRoundTrip_EmphasisInteriorSpaces(t *testing.T) {
md, err := ConfluenceToMarkdown(`<p>Вызов<strong> box.backup.start() </strong>будет</p>`)
require.NoError(t, err)
assert.Contains(t, md, "**box.backup.start()**", "spaces must move outside the markers")
assert.NotContains(t, md, "** box.backup.start", "no whitespace just inside the markers")
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "<strong>box.backup.start()</strong>")
assert.NotContains(t, xmlOut, "**", "asterisks must not leak as literal text")
}
// TestRoundTrip_TocMacroInHeading guards against the TOC macro that lives inside
// a heading round-tripping lossily. xml2md emits it as `# <!-- ac:toc … -->`;
// goldmark parses that comment as *inline* RawHTML, so md2xml must reconstruct
// the structured-macro from the inline path (renderRawHTML), not only the block
// path — otherwise it leaks back out as a literal comment instead of a macro.
func TestRoundTrip_TocMacroInHeading(t *testing.T) {
xmlIn := `<h1><ac:structured-macro ac:macro-id="f9579fa5-2198-4556-92de-04db447accec" ac:name="toc" ac:schema-version="1" /></h1>`
md, err := ConfluenceToMarkdown(xmlIn)
require.NoError(t, err)
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, `ac:name="toc"`, "TOC macro must be reconstructed, not left as a comment")
assert.Contains(t, xmlOut, `ac:macro-id="f9579fa5-2198-4556-92de-04db447accec"`)
assert.NotContains(t, xmlOut, "<!-- ac:toc", "comment marker must not leak into the XML")
}
// TestRoundTrip_EmphasisNbspOnly guards against bold/italic applied to a lone
// non-breaking space (`<strong> </strong>`, Confluence copy-paste cruft)
// round-tripping as literal `** **`: nbsp must count as whitespace, so the
// emphasis markers are dropped entirely.
func TestRoundTrip_EmphasisNbspOnly(t *testing.T) {
md, err := ConfluenceToMarkdown("<p>a<strong>\u00a0</strong>b</p>")
require.NoError(t, err)
assert.NotContains(t, md, "**", "nbsp-only bold must not emit asterisks")
}
// TestRoundTrip_NestedOrderedList guards against the regression where a
// nested <ol> inside an outer <ol><li> was flattened into a single flat list
// because the markdown was emitted with a 2-space indent (insufficient for
// ordered lists, which need ≥3) and a blank line that turned the list loose.
func TestRoundTrip_NestedOrderedList(t *testing.T) {
xmlInput := `<ol>` +
`<li>Outer one.<ol>` +
`<li>Inner one.</li>` +
`<li>Inner two.</li>` +
`</ol></li>` +
`<li>Outer two.</li>` +
`<li>Outer three.</li>` +
`</ol>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
// Nested <ol> must survive — a flattened version would have 5 sibling <li>s.
// Whitespace between closing tags is normalized away in this assertion.
noWS := strings.Join(strings.Fields(xmlOutput), "")
assert.Contains(t, noWS, "<li>Outerone.<ol>")
assert.Contains(t, xmlOutput, "<li>Inner one.</li>")
assert.Contains(t, xmlOutput, "<li>Inner two.</li>")
assert.Contains(t, noWS, "</ol></li>")
assert.Contains(t, xmlOutput, "<li>Outer two.</li>")
// List must remain tight — no <p> wrappers around items.
assert.NotContains(t, xmlOutput, "<li><p>Outer one.")
assert.NotContains(t, xmlOutput, "<li><p>Inner one.")
}
// TestRoundTrip_NestedMixedList guards the same fix for an OL containing a
// nested UL, which is what the tt.xml mismatch exercised.
func TestRoundTrip_NestedMixedList(t *testing.T) {
xmlInput := `<ol>` +
`<li>Outer one.<ul>` +
`<li>Bullet one.</li>` +
`<li>Bullet two.</li>` +
`</ul></li>` +
`<li>Outer two.</li>` +
`</ol>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
noWS := strings.Join(strings.Fields(xmlOutput), "")
assert.Contains(t, noWS, "<li>Outerone.<ul>")
assert.Contains(t, xmlOutput, "<li>Bullet one.</li>")
assert.Contains(t, xmlOutput, "<li>Bullet two.</li>")
assert.Contains(t, noWS, "</ul></li>")
assert.Contains(t, xmlOutput, "<li>Outer two.</li>")
assert.NotContains(t, xmlOutput, "<li><p>Outer one.")
}
// TestRoundTrip_DoubleNestedUnorderedList covers a UL nested inside another
// UL — pre-fix the cumulative indent computation didn't account for the
// parent list, so deeply nested items collapsed.
func TestRoundTrip_DoubleNestedUnorderedList(t *testing.T) {
xmlInput := `<ul>` +
`<li>Top one.<ul>` +
`<li>Mid one.<ul>` +
`<li>Deep one.</li>` +
`<li>Deep two.</li>` +
`</ul></li>` +
`<li>Mid two.</li>` +
`</ul></li>` +
`</ul>`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
noWS := strings.Join(strings.Fields(xmlOutput), "")
assert.Contains(t, noWS, "<li>Topone.<ul>")
assert.Contains(t, noWS, "<li>Midone.<ul>")
assert.Contains(t, xmlOutput, "<li>Deep one.</li>")
assert.Contains(t, xmlOutput, "<li>Deep two.</li>")
assert.Contains(t, xmlOutput, "<li>Mid two.</li>")
// Three nesting levels collapse to three closing </ul>s.
assert.Equal(t, 3, strings.Count(xmlOutput, "</ul>"))
}