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, "

Hello World

") } 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", "level %d", i+1) } } func TestMarkdownToConfluence_Bold(t *testing.T) { result, err := MarkdownToConfluence([]byte("**bold text**")) require.NoError(t, err) assert.Contains(t, result, "bold text") } func TestMarkdownToConfluence_Italic(t *testing.T) { result, err := MarkdownToConfluence([]byte("*italic text*")) require.NoError(t, err) assert.Contains(t, result, "italic text") } func TestMarkdownToConfluence_Strikethrough(t *testing.T) { result, err := MarkdownToConfluence([]byte("~~deleted~~")) require.NoError(t, err) assert.Contains(t, result, "deleted") } func TestMarkdownToConfluence_InlineCode(t *testing.T) { result, err := MarkdownToConfluence([]byte("`code`")) require.NoError(t, err) assert.Contains(t, result, "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, ``) } 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, "") assert.Contains(t, result, "") assert.Contains(t, result, "") } 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("![alt text](https://example.com/img.png)")) require.NoError(t, err) assert.Contains(t, result, "Click here`) } 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, "

A

1

Name

Age

Alice

30

` 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 := `

Important note

` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, "> Important note") } func TestConfluenceToMarkdown_HorizontalRule(t *testing.T) { result, err := ConfluenceToMarkdown("
") require.NoError(t, err) assert.Contains(t, result, "---") } func TestConfluenceToMarkdown_Emoticons(t *testing.T) { tests := []struct { name string input string expected string }{ {"plus", ``, "(+)"}, {"minus", ``, "(-)"}, {"question", ``, "(?)"}, {"tick", ``, "(v)"}, {"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 := `

Before commented text after

` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, ``) assert.Contains(t, result, "commented text") assert.Contains(t, result, "") } // === User reference tests === func TestConfluenceToMarkdown_UserReference(t *testing.T) { input := `

Author:

` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, ``) } func TestMarkdownToConfluence_UserReference(t *testing.T) { input := `Author: ` result, err := MarkdownToConfluence([]byte(input)) require.NoError(t, err) assert.Contains(t, result, ``) } func TestRoundTrip_UserReference(t *testing.T) { xmlInput := `

Author: wrote this

` 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 := `

` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, ``) } func TestConfluenceToMarkdown_AttachmentImageWithAlt(t *testing.T) { input := `

` 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: ` result, err := MarkdownToConfluence([]byte(input)) require.NoError(t, err) assert.Contains(t, result, ``) } func TestMarkdownToConfluence_AttachmentImageWithAlt(t *testing.T) { input := `` 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 := `

` 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 := `
  • ATE: Запуск плейбуков
` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, "**ATE:** Запуск плейбуков") } func TestConfluenceToMarkdown_SpaceBetweenInlineElements(t *testing.T) { input := `
  • Bold then italic text
` 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 := `

Content

` result, err := ConfluenceToMarkdown(input) require.NoError(t, err) assert.Contains(t, result, "") assert.Contains(t, result, ``) assert.Contains(t, result, "") assert.Contains(t, result, "Content") assert.Contains(t, result, "") assert.Contains(t, result, "") assert.Contains(t, result, "") } func TestConfluenceToMarkdown_LayoutTwoColumn(t *testing.T) { input := `

Left

Right

` 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, "")) assert.Equal(t, 2, strings.Count(result, "")) } func TestMarkdownToConfluence_Layout(t *testing.T) { input := ` Hello world ` result, err := MarkdownToConfluence([]byte(input)) require.NoError(t, err) assert.Contains(t, result, "") assert.Contains(t, result, ``) assert.Contains(t, result, "") assert.Contains(t, result, "Hello world") assert.Contains(t, result, "") assert.Contains(t, result, "") assert.Contains(t, result, "") } func TestRoundTrip_Layout(t *testing.T) { xmlInput := `

Left column

Right column

` md, err := ConfluenceToMarkdown(xmlInput) require.NoError(t, err) xmlOutput, err := MarkdownToConfluence([]byte(md)) require.NoError(t, err) assert.Contains(t, xmlOutput, "") assert.Contains(t, xmlOutput, `ac:type="two_equal"`) assert.Contains(t, xmlOutput, "") assert.Contains(t, xmlOutput, "Left column") assert.Contains(t, xmlOutput, "Right column") assert.Contains(t, xmlOutput, "") assert.Contains(t, xmlOutput, "") assert.Contains(t, xmlOutput, "") } // === 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 := `

Hello important text world

` 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, ``) assert.Contains(t, xmlOutput, "") 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: `

helloworld

`, expected: "`helloworld`", }, { name: "adjacent code with span inside", input: `

plan : vclock

`, expected: "`plan : vclock`", }, { name: "single code element unchanged", input: `

single

`, expected: "`single`", }, { name: "non-adjacent code elements stay separate", input: `

a text b

`, expected: "`a` text `b`", }, { // Regression: whitespace-only text between two elements // previously caused the merge logic to swallow the gap and // concatenate the spans. name: "whitespace-separated code elements stay separate", input: `

Благодаря previous_backup_id tt restore plan видит разрыв.

`, 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 := `
  • item 1
  • item 2
  • item 3
` md, err := ConfluenceToMarkdown(xmlInput) require.NoError(t, err) xmlOutput, err := MarkdownToConfluence([]byte(md)) require.NoError(t, err) assert.Contains(t, xmlOutput, "
  • item 1
  • ") assert.Contains(t, xmlOutput, "
  • item 2
  • ") assert.Contains(t, xmlOutput, "
  • item 3
  • ") assert.NotContains(t, xmlOutput, "item 1\n") assert.NotContains(t, xmlOutput, "item 1 ") } func TestRoundTrip_InlineCommentFromRealXML(t *testing.T) { xmlInput := `

    Товарищ! Не майся дурью, копируй этот шаблон и редактируй его! Ускоришь написание RFC!

    ` 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 // 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 := `` + `` + `` + `` + `` + `

    Команда

    Где

    Действие

    mode --target=<incremental|full> --storage=<config>

    менеджер

    Возвращает mode + from_vclock.

    ` 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, `mode --target=<incremental|full> --storage=<config>`) assert.Contains(t, xmlOutput, "менеджер") assert.Contains(t, xmlOutput, "from_vclock") } // 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 . // 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 := `

    Основной документ:

    ` 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, ``) 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 `

    ` — // 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 `# ` to a bare `#`. func TestConfluenceToMarkdown_CommentInHeading(t *testing.T) { xmlInput := `

    ` md, err := ConfluenceToMarkdown(xmlInput) require.NoError(t, err) assert.Contains(t, md, `# `) // 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()`) round-tripped into a real // `
    ` element: xml2md emitted a bare `
    `, 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{ `

    call box.backup.start(<table>) now

    `, `
    • call box.backup.start(<table>) now
    `, } { 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, `\
    `, "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, "
    ", "literal text must not become a table element") } // A non-tag '<' (comparison operator) is left unescaped and still survives. md, err := ConfluenceToMarkdown(`

    t <= time_end

    `) 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 ` x ` // (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(`

    Вызов box.backup.start() будет

    `) 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, "box.backup.start()") 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 `# `; // 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 := `

    ` 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, "