use mdwire::{render, Channel, Streamer};
fn one(input: &str, channel: Channel) -> String {
let parts = render(input, channel);
assert!(parts.len() <= 1, "이 입력은 나뉠 길이가 아니다: {parts:?}");
parts.into_iter().next().unwrap_or_default()
}
fn tg(input: &str) -> String {
one(input, Channel::TelegramHtml)
}
#[test]
fn emphasis_spanning_a_line_break_is_one_range() {
assert_eq!(
tg("이번 주 결론은 **배포를 금요일에\n하지 않는다** 이고, 예외는 없다."),
"이번 주 결론은 <b>배포를 금요일에\n하지 않는다</b> 이고, 예외는 없다."
);
assert_eq!(
tg("공개 채널**이다. 글 내용이 아니라\n**신분 공개 + 시점의 조합**이 판단 대상"),
"공개 채널이다. 글 내용이 아니라\n<b>신분 공개 + 시점의 조합</b>이 판단 대상"
);
}
#[test]
fn unclosed_emphasis_closes_at_block_end() {
assert_eq!(tg("앞말 **굵은 꼬리"), "앞말 <b>굵은 꼬리</b>");
assert_eq!(tg("**열고 끝\n\n다음 문단"), "<b>열고 끝</b>\n\n다음 문단");
}
#[test]
fn unclosed_code_fence_closes() {
assert_eq!(tg("```\n코드 한 줄"), "<pre>코드 한 줄</pre>");
}
#[test]
fn lone_asterisks_stay_literal() {
assert_eq!(tg("2 ** 3 은 곱셈이 아니다"), "2 ** 3 은 곱셈이 아니다");
assert_eq!(tg("참고*"), "참고*");
}
#[test]
fn an_orphaned_double_marker_is_dropped_but_a_single_stays() {
assert_eq!(tg("온다* 뒤에 더 있다"), "온다* 뒤에 더 있다");
assert_eq!(tg("꼬리**"), "꼬리");
assert_eq!(tg("underfront.* (4개), minjunkim.* (3개)"), "underfront.* (4개), minjunkim.* (3개)");
assert_eq!(tg("/* a */ 다음 *z* 끝"), "/* a */ 다음 <i>z</i> 끝");
assert_eq!(tg("a*** **x"), "a* <b>x</b>");
assert_eq!(
tg("문단에서 *기울임이 열리고\n> 인용 줄이 온다* 뒤"),
"문단에서 <i>기울임이 열리고</i>\n<blockquote>인용 줄이 온다* 뒤</blockquote>"
);
}
#[test]
fn underscores_inside_identifiers_survive() {
assert_eq!(tg("plain_text_name 은 그대로다"), "plain_text_name 은 그대로다");
}
#[test]
fn a_code_span_closes_on_a_run_of_the_same_length() {
assert_eq!(tg("` foo `` bar `"), "<code>foo `` bar</code>");
assert_eq!(tg("` `` `"), "<code>``</code>");
assert_eq!(tg("``백틱 ` 포함`` 밖"), "<code>백틱 ` 포함</code> 밖");
}
#[test]
fn markers_inside_code_are_text() {
assert_eq!(tg("`**굵지 않다**` 그리고 밖"), "<code>**굵지 않다**</code> 그리고 밖");
}
#[test]
fn no_emphasis_marker_survives_into_tag_channels() {
for input in [
"공개 채널**이다. 글 내용이 아니라\n**신분 공개 + 시점의 조합**이 판단 대상",
"**열기만** 하고 **닫지 않으면",
"- **항목** 하나\n- 항목 **둘",
"> 인용 안의 **강조**도",
"# 헤딩의 **강조**",
] {
for channel in [Channel::TelegramHtml, Channel::Plain] {
let out = one(input, channel);
assert!(!out.contains("**"), "{}: {out}", channel.name());
}
}
}
#[test]
fn emphasis_closes_even_when_a_punctuation_precedes_the_marker() {
assert_eq!(tg("**(중요)**이다"), "<b>(중요)</b>이다");
assert_eq!(tg("**끝.**이라서 그렇다"), "<b>끝.</b>이라서 그렇다");
assert_eq!(tg("**코드 `a`**였다"), "<b>코드 <code>a</code></b>였다");
assert_eq!(
tg("- 실패의 **99%가 `TIMEOUT`**이었고, 나머지는 달랐다"),
"• 실패의 <b>99%가 <code>TIMEOUT</code></b>이었고, 나머지는 달랐다"
);
}
#[test]
fn a_marker_after_whitespace_still_never_closes() {
assert_eq!(tg("채널**이다.\n**신분 공개**가 대상"), "채널이다.\n<b>신분 공개</b>가 대상");
assert_eq!(tg("앞말 **강조**"), "앞말 <b>강조</b>");
}
#[test]
fn emphasis_spans_a_wrapped_list_item() {
assert_eq!(
tg("- 앞말 **굵은 것이\n 다음 줄로 이어진다** 끝\n- 다음 항목"),
"• 앞말 <b>굵은 것이\n 다음 줄로 이어진다</b> 끝\n• 다음 항목"
);
assert_eq!(
tg("- 열고 **안 닫음\n- 새 항목"),
"• 열고 <b>안 닫음</b>\n• 새 항목"
);
}
#[test]
fn a_lone_tilde_is_text_not_strikethrough() {
assert_eq!(tg("주행 ~40km 까지 ~22km 부족하다"), "주행 ~40km 까지 ~22km 부족하다");
assert_eq!(tg("충전은 5~6월이다"), "충전은 5~6월이다");
assert_eq!(tg("~~진짜 취소선~~ 이다"), "<s>진짜 취소선</s> 이다");
}
#[test]
fn strikethrough_uses_each_channels_own_syntax() {
let input = "~~지난 계획~~ 은 접는다";
assert_eq!(tg(input), "<s>지난 계획</s> 은 접는다");
assert_eq!(one(input, Channel::SlackMarkdown), "~~지난 계획~~ 은 접는다");
assert_eq!(one(input, Channel::Plain), "지난 계획 은 접는다");
assert_eq!(tg("~~취소선 안의 **굵게**~~"), "<s>취소선 안의 <b>굵게</b></s>");
}
#[test]
fn an_oversized_fence_keeps_its_tags_across_parts() {
let body = (0..400).map(|i| format!("줄 {i} 내용이 길게 이어진다\n")).collect::<String>();
let input = format!("```markdown\n{body}```\n");
let parts = render(&input, Channel::TelegramHtml);
assert!(parts.len() > 1);
for part in &parts {
assert!(part.chars().count() <= Channel::TelegramHtml.limit(), "{}자", part.chars().count());
assert!(balanced(part), "조각이 깨졌다");
assert!(part.starts_with("<pre>"), "이어지는 조각은 펜스를 다시 열어야 한다");
}
}
#[test]
fn a_table_can_start_right_after_any_block() {
let table = "| | 값 |\n|---|---|\n| 가 | 1 |";
let out = tg(&format!("> 인용이 먼저다.\n{table}"));
assert!(out.starts_with("<blockquote>인용이 먼저다.</blockquote>"), "{out}");
assert!(out.contains("<pre>"), "{out}");
assert_eq!(out.matches("<blockquote>").count(), out.matches("</blockquote>").count());
assert!(tg(&format!("- 항목\n{table}")).contains("<pre>"));
assert!(tg(&format!("문단이 먼저다.\n{table}")).contains("<pre>"));
assert_eq!(one("문단 a | b 가 있다", Channel::Plain), "문단 a | b 가 있다");
}
#[test]
fn a_run_of_three_markers_leaves_nothing_behind() {
assert_eq!(tg("***세 개 별표***"), "<b><i>세 개 별표</i></b>");
assert_eq!(one("***세 개 별표***", Channel::SlackMarkdown), "***세 개 별표***");
assert_eq!(tg("_**둘 다**_"), "<i><b>둘 다</b></i>");
assert_eq!(tg("***여는 쪽만 셋**"), "<b><i>여는 쪽만 셋</i></b>");
assert_eq!(tg("**닫는 쪽만 셋***"), "<b>닫는 쪽만 셋</b>");
}
#[test]
fn crlf_input_does_not_leak_carriage_returns() {
let out = tg("윈도우 줄바꿈\r\n**굵게**\r\n");
assert!(!out.contains('\r'), "{out:?}");
assert_eq!(out, "윈도우 줄바꿈\n<b>굵게</b>");
}
#[test]
fn checkboxes_are_not_dressed_up() {
assert_eq!(tg("- [ ] 안 한 일\n- [x] 한 일"), "• [ ] 안 한 일\n• [x] 한 일");
}
#[test]
fn emphasis_closes_next_to_cjk_punctuation() {
assert_eq!(
tg("**強調記号として認識されない。**この文のせいで。"),
"<b>強調記号として認識されない。</b>この文のせいで。"
);
assert_eq!(tg("**「引用符」**です。"), "<b>「引用符」</b>です。");
assert_eq!(tg("**(注釈)**は重要だ。"), "<b>(注釈)</b>は重要だ。");
assert_eq!(tg("**中文加粗,**后面接着中文。"), "<b>中文加粗,</b>后面接着中文。");
assert_eq!(tg("**끝.**이라서 그렇다."), "<b>끝.</b>이라서 그렇다.");
}
#[test]
fn emphasis_closes_after_a_quote_mark() {
assert_eq!(
tg(r#"잣대를 **"저자가 쓴 게 얼마나 살아남느냐"**로 둔다"#),
r#"잣대를 <b>"저자가 쓴 게 얼마나 살아남느냐"</b>로 둔다"#
);
assert_eq!(tg("**“곡선”**이라고"), "<b>“곡선”</b>이라고");
assert_eq!(tg("**'홑'**을"), "<b>'홑'</b>을");
assert_eq!(tg(r#"**"끝"**."#), r#"<b>"끝"</b>."#);
}
#[test]
fn the_repair_is_not_language_specific() {
assert_eq!(tg(r#"the mark is **"what survives"**and not ours"#),
r#"the mark is <b>"what survives"</b>and not ours"#);
assert_eq!(tg("a **(note)**follows"), "a <b>(note)</b>follows");
}
#[test]
fn headings_map_per_channel() {
assert_eq!(tg("## 제목"), "<b>제목</b>");
assert_eq!(one("## 제목", Channel::SlackMarkdown), "## 제목");
assert_eq!(one("## 제목", Channel::Plain), "제목");
}
#[test]
fn heading_levels_do_not_jump() {
assert_eq!(
one("# 하나\n\n#### 넷", Channel::SlackMarkdown),
"# 하나\n\n## 넷"
);
}
#[test]
fn list_markers_are_unified() {
assert_eq!(one("* 하나\n+ 둘\n- 셋", Channel::SlackMarkdown), "- 하나\n- 둘\n- 셋");
assert_eq!(tg("* 하나\n+ 둘"), "• 하나\n• 둘");
assert_eq!(one("3. 셋\n4. 넷", Channel::Plain), "3. 셋\n4. 넷");
}
#[test]
fn quotes_wrap_once_not_per_line() {
assert_eq!(tg("> 한 줄\n> 두 줄"), "<blockquote>한 줄\n두 줄</blockquote>");
assert_eq!(one("> 한 줄\n> 두 줄", Channel::Plain), "> 한 줄\n> 두 줄");
}
#[test]
fn fence_info_becomes_a_language_class() {
assert_eq!(
tg("```rust\nlet a = 1 < 2;\n```"),
"<pre><code class=\"language-rust\">let a = 1 < 2;</code></pre>"
);
}
#[test]
fn links_render_per_channel() {
let input = "[문서](https://example.com/a?b=1&c=2) 참고";
assert_eq!(tg(input), "<a href=\"https://example.com/a?b=1&c=2\">문서</a> 참고");
assert_eq!(one(input, Channel::SlackMarkdown), input);
assert_eq!(one(input, Channel::Plain), "문서 (https://example.com/a?b=1&c=2) 참고");
}
#[test]
fn html_special_characters_are_escaped() {
assert_eq!(tg("a < b & c > d"), "a < b & c > d");
}
#[test]
fn extra_blank_lines_collapse_to_one() {
assert_eq!(tg("첫 문단\n\n\n\n\n둘째 문단"), "첫 문단\n\n둘째 문단");
}
#[test]
fn leading_and_trailing_blank_lines_go_away() {
assert_eq!(tg("\n\n본문\n\n\n"), "본문");
}
#[test]
fn nothing_is_padded_next_to_cjk() {
let input = "채널**이다** 그리고 **강조**アイウ 와 `코드`글";
for channel in Channel::all() {
let out = render(input, channel).join("");
assert!(!out.contains('\u{200b}'), "{}: {out:?}", channel.name());
}
assert_eq!(one("채널**이다", Channel::SlackMarkdown), "채널**이다**");
assert_eq!(one("채널**이다", Channel::TelegramHtml), "채널<b>이다</b>");
}
#[test]
fn slack_markdown_text_italic_uses_asterisk() {
assert_eq!(one("_진료_가 있다", Channel::SlackMarkdown), "*진료*가 있다");
assert_eq!(one("*진료*가 있다", Channel::SlackMarkdown), "*진료*가 있다");
assert_eq!(one("~~취소~~가 있다", Channel::SlackMarkdown), "~~취소~~가 있다");
assert_eq!(one("_진료_가 있다", Channel::TelegramHtml), "<i>진료</i>가 있다");
}
#[test]
fn parts_stay_within_the_limit_and_keep_markup_whole() {
let block = "**굵은 문단**이 하나 있고 `코드`도 있다. 이 문단은 길이 한도를 넘기려고 반복된다.\n\n";
let input = block.repeat(80);
let parts = render(&input, Channel::TelegramHtml);
assert!(parts.len() > 1, "한도를 넘겼는데 안 나뉘었다");
for part in &parts {
assert!(part.chars().count() <= Channel::TelegramHtml.limit(), "조각이 한도를 넘는다");
assert!(!part.contains("**"), "마커가 남았다");
assert_eq!(part.matches("<b>").count(), part.matches("</b>").count(), "태그 짝이 안 맞는다");
assert_eq!(part.matches("<code>").count(), part.matches("</code>").count());
assert!(!part.starts_with('\n') && !part.ends_with('\n'), "조각 끝에 빈 줄이 남았다");
}
}
#[test]
fn a_single_oversized_block_is_cut_with_tags_closed_and_reopened() {
let input = format!("> **{}**", "한글 ".repeat(3000));
let parts = render(&input, Channel::TelegramHtml);
assert!(parts.len() > 1);
for part in &parts {
assert!(part.chars().count() <= Channel::TelegramHtml.limit());
assert_eq!(part.matches("<blockquote>").count(), part.matches("</blockquote>").count());
assert_eq!(part.matches("<b>").count(), part.matches("</b>").count());
}
}
#[test]
fn chunk_boundary_inside_markup_does_not_leak() {
let mut s = Streamer::new(Channel::TelegramHtml);
let mut out = String::new();
out.push_str(s.push("앞말 **굵"));
assert_eq!(out, "앞말 ");
out.push_str(s.push("게** 뒷말"));
out.push_str(s.finish());
assert_eq!(out, "앞말 <b>굵게</b> 뒷말");
}
#[test]
fn streaming_never_emits_half_a_tag() {
let input = "# 제목\n\n**굵게** 있는 문단과 `코드` 그리고\n이어지는 **줄 넘는 강조**다.\n";
for size in 1..=12 {
let mut s = Streamer::new(Channel::TelegramHtml);
let mut seen = String::new();
for chunk in input.as_bytes().chunks(size) {
let _ = chunk;
}
let mut buf = String::new();
for c in input.chars() {
buf.push(c);
if buf.chars().count() == size {
let piece = s.push(&buf);
assert!(!piece.contains('<') || piece.matches('<').count() == piece.matches('>').count());
seen.push_str(piece);
buf.clear();
}
}
seen.push_str(s.push(&buf));
seen.push_str(s.finish());
assert_eq!(seen, render(input, Channel::TelegramHtml).join(""));
}
}
#[test]
fn any_mid_stream_snapshot_is_sendable() {
let input = "# 제목\n\n> 인용 안의 **강조**와\n> 이어지는 줄\n\n```rust\nlet a = 1;\nlet b = 2;\n```\n\n| 열 | 값 |\n|---|---|\n| 가 | 1 |\n\n끝 문단 **굵게**";
let mut s = Streamer::new(Channel::TelegramHtml);
let mut acc = String::new();
let mut checked = 0;
let mut raw_broken = 0;
for c in input.chars() {
let mut piece = [0u8; 4];
s.push_into(c.encode_utf8(&mut piece), &mut acc);
if !balanced(&acc) {
raw_broken += 1;
}
let mut snapshot = acc.clone();
s.close_open(&mut snapshot);
assert!(balanced(&snapshot), "여기서 끊으면 못 보낸다: {snapshot:?}");
checked += 1;
}
s.finish_into(&mut acc);
assert!(balanced(&acc));
assert!(checked > 50, "충분히 많은 지점을 봐야 한다");
assert!(raw_broken > 0, "누적본이 늘 균형이 맞다면 close_open 은 필요 없는 API 다");
}
fn balanced(html: &str) -> bool {
let mut stack: Vec<String> = Vec::new();
let ch: Vec<char> = html.chars().collect();
let mut i = 0;
while i < ch.len() {
if ch[i] != '<' {
i += 1;
continue;
}
let closing = ch.get(i + 1) == Some(&'/');
let from = if closing { i + 2 } else { i + 1 };
let mut j = from;
while j < ch.len() && (ch[j].is_ascii_alphanumeric() || ch[j] == '-') {
j += 1;
}
let name: String = ch[from..j].iter().collect();
while j < ch.len() && ch[j] != '>' {
j += 1;
}
if j >= ch.len() {
return false; }
if closing {
if stack.pop().as_deref() != Some(name.as_str()) {
return false;
}
} else {
stack.push(name);
}
i = j + 1;
}
stack.is_empty()
}
#[test]
fn empty_input_produces_nothing() {
assert!(render("", Channel::TelegramHtml).is_empty());
assert!(render(" \n\n \n", Channel::TelegramHtml).is_empty());
}
#[test]
fn an_address_longer_than_the_limit_becomes_text() {
let url = format!("https://example.com/{}", "x".repeat(5000));
let parts = render(&format!("[아주 긴 링크]({url}) 뒤에 오는 글"), Channel::TelegramHtml);
let joined = parts.join("");
let preview: String = joined.chars().take(80).collect();
assert!(!joined.contains("<a "), "링크로 내면 조각이 성립하지 않는다: {preview}");
assert!(joined.contains("아주 긴 링크"), "링크 텍스트가 사라졌다");
assert!(joined.contains("뒤에 오는 글"), "뒤쪽 내용이 사라졌다");
for (i, p) in parts.iter().enumerate() {
assert!(p.chars().count() <= Channel::TelegramHtml.limit(), "조각 {i} 가 한도를 넘었다");
assert!(balanced(p), "조각 {i} 의 태그가 안 맞는다");
}
let ok = render("[링크](https://example.com/x) 뒤", Channel::TelegramHtml);
assert!(ok[0].contains("<a href=\"https://example.com/x\">링크</a>"), "{:?}", ok[0]);
}
#[test]
fn an_over_limit_bare_link_is_not_written_twice() {
let url = format!("https://example.com/?q={}&x=1", "y".repeat(5000));
let parts = render(&format!("[{url}]({url})"), Channel::TelegramHtml);
let joined = parts.join("");
assert_eq!(joined.matches("&x=1").count(), 1, "주소가 두 번 나왔다");
}
#[test]
fn an_unpaired_backtick_run_is_text_and_what_follows_still_emphasizes() {
let got = one("앞부분 ``` 뒤에 **굵게** 가 온다", Channel::TelegramHtml);
assert_eq!(got, "앞부분 ``` 뒤에 <b>굵게</b> 가 온다");
let got = one("앞 `코드` 와 **굵게**", Channel::TelegramHtml);
assert_eq!(got, "앞 <code>코드</code> 와 <b>굵게</b>");
let got = one("앞 ``코드 `안` 에`` 와 **굵게**", Channel::TelegramHtml);
assert_eq!(got, "앞 <code>코드 `안` 에</code> 와 <b>굵게</b>");
}
#[test]
fn replaying_an_unpaired_code_span_keeps_line_breaks() {
let got = one("앞 `foo\nbar **굵게**", Channel::TelegramHtml);
assert_eq!(got, "앞 `foo\nbar <b>굵게</b>");
let got = one("> 인용 `foo\n> bar 뒤", Channel::SlackMarkdown);
assert_eq!(got, "> 인용 `foo\n> bar 뒤");
let got = one("- 항목 `foo\n bar 뒤", Channel::Plain);
assert_eq!(got, "• 항목 `foo\n bar 뒤");
}
#[test]
fn a_row_with_extra_cells_keeps_them() {
let input = "| A | B |\n| --- | --- |\n| `x|y` | 뒤쪽 내용 |\n";
let got = one(input, Channel::Plain);
assert!(got.contains("뒤쪽 내용"), "넘친 칸이 사라졌다: {got}");
let plain = one("| A | B |\n| --- | --- |\n| 하나 | 둘 |\n", Channel::Plain);
assert!(plain.contains("하나") && plain.contains("둘"), "{plain}");
}
#[test]
fn a_backslash_escape_makes_the_next_character_plain() {
assert_eq!(one(r"TEST\_VCLEFT\_FRONT", Channel::TelegramHtml), "TEST_VCLEFT_FRONT");
assert_eq!(one(r"2 \* 3 과 **굵게**", Channel::TelegramHtml), "2 * 3 과 <b>굵게</b>");
assert_eq!(one(r"new createError\[code\](\[msg\])", Channel::Plain), "new createError[code]([msg])");
assert_eq!(one(r"`코드\_안`", Channel::TelegramHtml), r"<code>코드\_안</code>");
assert_eq!(one(r"2 \* 3", Channel::SlackMarkdown), r"2 \* 3");
}
#[test]
fn a_code_span_holding_a_backtick_widens_its_fence() {
let got = one("백틱(`` ` ``)으로", Channel::SlackMarkdown);
assert_eq!(got, "백틱(`` ` ``)으로");
assert_eq!(one("백틱(`` ` ``)으로", Channel::TelegramHtml), "백틱(<code>`</code>)으로");
assert_eq!(one("백틱(`` ` ``)으로", Channel::Plain), "백틱( ` )으로");
}
#[test]
fn a_tab_only_body_still_loses_its_padding_spaces() {
assert_eq!(one("가 ` \t ` 나", Channel::TelegramHtml), "가 <code>\t</code> 나");
assert_eq!(one("가 ` ` 나", Channel::TelegramHtml), "가 <code> </code> 나");
}
#[test]
fn slack_markdown_text_keeps_tables_as_gfm() {
let input = "| 항목 | 값 | 비고 |\n|:--|--:|:-:|\n| **마통** | 0원 | a\\|b |\n";
assert_eq!(
one(input, Channel::SlackMarkdown),
"| 항목 | 값 | 비고 |\n| --- | ---: | :---: |\n| **마통** | 0원 | a\\|b |"
);
assert!(one(input, Channel::TelegramHtml).starts_with("<pre>"));
}
#[test]
fn angle_autolink_becomes_a_link() {
let input = "주소는 <https://a.com/x_y_z?q=1&r=2> 다";
assert_eq!(
one(input, Channel::TelegramHtml),
"주소는 <a href=\"https://a.com/x_y_z?q=1&r=2\">https://a.com/x_y_z?q=1&r=2</a> 다"
);
assert_eq!(one(input, Channel::SlackMarkdown), "주소는 <https://a.com/x_y_z?q=1&r=2> 다");
assert_eq!(one(input, Channel::Plain), "주소는 https://a.com/x_y_z?q=1&r=2 다");
assert_eq!(one("<https://a.com/x y>", Channel::Plain), "<https://a.com/x y>");
let legacy = "<https://a.com/p|문서 보기> 참고";
assert_eq!(one(legacy, Channel::SlackMarkdown), "[문서 보기](https://a.com/p) 참고");
assert_eq!(one(legacy, Channel::TelegramHtml), "<a href=\"https://a.com/p\">문서 보기</a> 참고");
assert_eq!(one(legacy, Channel::Plain), "문서 보기 (https://a.com/p) 참고");
}
#[test]
fn known_html_tags_are_stripped_and_unknown_angles_kept() {
let input = "H<sub>2</sub>O 와 <B>굵</B> Vec<T> 1 < 2 <!-- 주석 --> 끝";
assert_eq!(one(input, Channel::TelegramHtml), "H2O 와 굵 Vec<T> 1 < 2 끝");
assert_eq!(one(input, Channel::SlackMarkdown), "H2O 와 굵 Vec<T> 1 < 2 끝");
assert_eq!(one("첫째<br>둘째<br/>셋째", Channel::TelegramHtml), "첫째\n둘째\n셋째");
assert_eq!(one("<a href=\"u\">t</a>", Channel::SlackMarkdown), "<a href=\"u\">t</a>");
}
#[test]
fn spaced_bold_markers_stay_as_text() {
assert_eq!(one("** 배포 ** 는 금지", Channel::SlackMarkdown), "** 배포 ** 는 금지");
assert_eq!(one("** 배포 ** 는 금지", Channel::TelegramHtml), "** 배포 ** 는 금지");
assert_eq!(one("**굵게 ** 끝", Channel::TelegramHtml), "<b>굵게 </b> 끝");
}
#[test]
fn slack_parts_close_and_reopen_spans() {
let filler = "가나다 ".repeat(2997); let tail = "가".repeat(40);
for span in [format!("`main → {tail}`"), format!("**굵은 말 {tail}**"), format!("~~취소 {tail}~~")] {
let input = format!("{filler}{span}\n");
let parts = render(&input, Channel::SlackMarkdown);
assert!(parts.len() >= 2, "{span}: 나뉘어야 한다");
for p in &parts {
assert!(p.chars().count() <= Channel::SlackMarkdown.limit());
assert_eq!(p.matches('`').count() % 2, 0, "백틱이 홀로 남았다: …{}", &p[p.len().saturating_sub(30)..]);
assert_eq!(p.matches("**").count() % 2, 0, "`**` 가 홀로 남았다: …{}", &p[p.len().saturating_sub(30)..]);
assert_eq!(p.matches("~~").count() % 2, 0, "`~~` 가 홀로 남았다: …{}", &p[p.len().saturating_sub(30)..]);
for m in ["**", "~~", "`"] {
assert!(!p.ends_with(&format!(" {m}")), "공백 뒤에 닫았다: …{}", &p[p.len().saturating_sub(30)..]);
assert!(!p.starts_with(&format!("{m} ")), "공백 앞에서 열었다: {}…", &p[..p.len().min(30)]);
}
}
}
}
#[test]
fn a_long_space_free_span_is_cut_within_the_limit() {
let input = format!("**{}**", "a".repeat(20_000));
for channel in Channel::all() {
let parts = render(&input, channel);
assert!(parts.len() > 1, "{}: 나뉘어야 한다", channel.name());
for p in &parts {
let n = p.chars().count();
assert!(n <= channel.limit(), "{}: {n}자 조각 — 한도 {}", channel.name(), channel.limit());
}
}
}
fn mrkdwn(input: &str, channel: Channel) -> String {
let out = mdwire::render_with(input, channel, mdwire::Options { from: mdwire::Dialect::SlackMrkdwn });
out.parts.join("")
}
#[test]
fn slack_mrkdwn_input_reads_single_markers_as_bold_and_strike() {
let input = "*상품 상세 화면*은 `web-app` 에 있어요. ~예전 방식~ 대신 <https://x.io|서비스웹> 으로.";
assert_eq!(
mrkdwn(input, Channel::SlackMarkdown),
"**상품 상세 화면**은 `web-app` 에 있어요. ~~예전 방식~~ 대신 [서비스웹](https://x.io) 으로."
);
assert_eq!(
mrkdwn(input, Channel::TelegramHtml),
"<b>상품 상세 화면</b>은 <code>web-app</code> 에 있어요. <s>예전 방식</s> 대신 <a href=\"https://x.io\">서비스웹</a> 으로."
);
assert_eq!(mrkdwn("**굵게** 와 ~~취소~~ 와 _기울임_", Channel::TelegramHtml), "<b>굵게</b> 와 <s>취소</s> 와 <i>기울임</i>");
assert_eq!(mrkdwn("`코드 안 *별표*`", Channel::SlackMarkdown), "`코드 안 *별표*`");
}
#[test]
fn slack_mrkdwn_keeps_korean_tildes_literal() {
assert_eq!(mrkdwn("약 ~40km, 5~6월 이동 ~취소~가 된다", Channel::SlackMarkdown), "약 ~40km, 5~6월 이동 ~~취소~~가 된다");
assert_eq!(mrkdwn("1~2일, ~3시간", Channel::TelegramHtml), "1~2일, ~3시간");
assert_eq!(mrkdwn("가격 ~만원 할인", Channel::TelegramHtml), "가격 ~만원 할인");
}
#[test]
fn slack_mrkdwn_bold_spans_lines_but_not_paragraphs() {
assert_eq!(mrkdwn("*첫 줄\n둘째 줄*입니다", Channel::SlackMarkdown), "**첫 줄\n둘째 줄**입니다");
let out = mdwire::render_with(
"*굵게 시작\n\n다른 문단*",
Channel::SlackMarkdown,
mdwire::Options { from: mdwire::Dialect::SlackMrkdwn },
);
assert_eq!(out.parts.join(""), "**굵게 시작**\n\n다른 문단*");
assert_eq!(out.repairs.closed_emphasis, 1, "{:?}", out.repairs);
}
#[test]
fn repairs_count_what_the_normalizer_fixed() {
let out = mdwire::render_with("**영향 범위\n```ts\nconst a = `b;\n", Channel::TelegramHtml, mdwire::Options::default());
assert_eq!(out.repairs.closed_emphasis, 1, "{:?}", out.repairs);
assert_eq!(out.repairs.closed_fence, 1, "{:?}", out.repairs);
let out = mdwire::render_with("앞 ` 뒤 **굵게** 와 꼬리**", Channel::TelegramHtml, mdwire::Options::default());
assert_eq!(out.repairs.reverted_code_span, 1, "{:?}", out.repairs);
assert_eq!(out.repairs.dropped_marker, 1, "{:?}", out.repairs);
let out = mdwire::render_with("**굵게** 와 `코드`\n```\n펜스\n```", Channel::SlackMarkdown, mdwire::Options::default());
assert!(!out.repairs.any(), "{:?}", out.repairs);
for ch in [Channel::SlackMarkdown, Channel::TelegramHtml] {
let out = mdwire::render_with("| a |\n|---|\n| **x |", ch, mdwire::Options::default());
assert_eq!(out.repairs.closed_emphasis, 1, "{ch:?} {:?}", out.repairs);
}
}
#[test]
fn streamer_counts_the_same_repairs() {
let input = "**영향 범위\n다음 줄\n\n앞 ` 뒤 꼬리**\n```ts\nconst a = 1;";
let batch = mdwire::render_with(input, Channel::TelegramHtml, mdwire::Options::default());
for size in [1usize, 3, 64] {
let mut s = Streamer::new(Channel::TelegramHtml);
let mut got = String::new();
let chars: Vec<char> = input.chars().collect();
for chunk in chars.chunks(size) {
s.push_into(&chunk.iter().collect::<String>(), &mut got);
}
s.finish_into(&mut got);
assert_eq!(got, batch.parts.join(""), "조각 {size}");
assert_eq!(s.repairs(), batch.repairs, "조각 {size}");
}
}