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("본인 4***-****-****-003* 확인"), "본인 4***-****-****-003* 확인");
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 github_escapes_tilde_and_angle_outside_code() {
let g = |s| one(s, Channel::GithubMarkdown);
assert_eq!(g("약 ~40km, 5~6월 이동"), r"약 \~40km, 5\~6월 이동");
assert_eq!(g("Vec<T> 와 1 < 2"), r"Vec\<T> 와 1 \< 2");
assert_eq!(g("~~취소~~가 `a~b <T>`"), "~~취소~~가 `a~b <T>`");
assert_eq!(g("```\nx ~ <y>\n```"), "```\nx ~ <y>\n```");
assert_eq!(g(r"5\~6월 \<T>"), r"5\~6월 \<T>");
assert_eq!(g("| a~b |\n|---|\n| <T> |"), "| a\\~b |\n| --- |\n| \\<T> |");
assert_eq!(g("<https://a.com/~me>"), "<https://a.com/~me>");
assert_eq!(g("# 하나\n\n## 둘\n\n### 셋\n\n#### 넷"), "# 하나\n\n## 둘\n\n### 셋\n\n#### 넷");
}
#[test]
fn notion_escapes_what_it_eats_and_strips_tags() {
let n = |s| one(s, Channel::NotionMarkdown);
assert_eq!(n("**설정(config)**을 바꾼다"), "**설정(config)**을 바꾼다");
assert_eq!(n("H<sub>2</sub>O 와 <kbd>C</kbd>"), "H2O 와 C");
assert_eq!(n("카드 1***-****-001* 끝"), r"카드 1\*\*\*-\*\*\*\*-001\* 끝");
assert_eq!(n(r"백슬래시 \ 하나"), r"백슬래시 \\ 하나");
assert_eq!(n("약 ~40km, Vec<T>"), "약 ~40km, Vec<T>");
assert_eq!(n(r"\*별\* `a*b\c`"), r"\*별\* `a*b\c`");
assert_eq!(n("<https://a.com/x_y>"), r"[https://a.com/x\_y](https://a.com/x_y)");
assert_eq!(n("##### 다섯\n\n* 별표 목록"), "#### 다섯\n\n- 별표 목록");
assert_eq!(n("> **a<br>b** 끝"), "> **a<br>b** 끝");
assert_eq!(
n("| a |\n|---|\n| 줄<br>바꿈 |"),
"<table header-row=\"true\">\n<tr>\n<td>a</td>\n</tr>\n<tr>\n<td>줄<br>바꿈</td>\n</tr>\n</table>"
);
assert_eq!(n("foo\\\nbar"), "foo\\\\\nbar");
assert_eq!(n(r"path C:\"), r"path C:\\");
assert_eq!(n(r"\$x\$ 와 $5"), r"\$x\$ 와 $5");
assert_eq!(n("<https://a.com/*x*>"), r"[https://a.com/\*x\*](https://a.com/*x*)");
assert_eq!(n("<https://a.com/x)>"), "[https://a.com/x)](https://a.com/x%29)");
}
#[test]
fn notion_closes_emphasis_at_each_line() {
let n = |s| one(s, Channel::NotionMarkdown);
assert_eq!(n("**줄을\n넘는 굵게**와 끝"), "**줄을**\n**넘는 굵게**와 끝");
assert_eq!(n("> **인용\n> 안의** 굵게"), "> **인용**\n> **안의** 굵게");
assert_eq!(n("- **항목\n 이어짐** 끝\n- 둘"), "- **항목**\n **이어짐** 끝\n- 둘");
assert_eq!(n("*기울임 **굵게\n이어짐** 끝*"), "*기울임 **굵게***\n***이어짐** 끝*");
assert_eq!(n("`코드\n이어짐`"), "`코드`\n`이어짐`");
assert_eq!(n("`a``\nb`"), "``` a`` ```\n`b`");
assert_eq!(n("**끝 \n다음**"), "**끝**\n**다음**");
assert_eq!(n("**[링크\n이어](http://x.com)**"), "**[링크**\n**이어](http://x.com)**");
assert_eq!(one("**줄을\n넘는**", Channel::GithubMarkdown), "**줄을\n넘는**");
}
#[test]
fn notion_writes_tables_as_xml() {
let out = one("| 항목 | 비고 |\n|:--|--:|\n| **마통** | a \\| b |", Channel::NotionMarkdown);
assert_eq!(
out,
"<table header-row=\"true\">\n<tr>\n<td>항목</td>\n<td>비고</td>\n</tr>\n<tr>\n<td>**마통**</td>\n<td>a | b</td>\n</tr>\n</table>"
);
}
#[test]
fn notion_splits_long_tables_into_whole_tables() {
let mut input = String::from("| a | b |\n|---|---|\n");
for i in 0..40 {
input.push_str(&format!("| 행{i} | 값{i} 가나다라 |\n"));
}
let opts = mdwire::Options { limit: Some(300), ..Default::default() };
let parts = mdwire::render_with(&input, Channel::NotionMarkdown, opts).parts;
assert!(parts.len() > 1);
for p in &parts {
assert!(p.chars().count() <= 300, "{p}");
assert!(p.starts_with("<table header-row=\"true\">\n<tr>\n<td>a</td>") && p.ends_with("</table>"), "{p}");
}
let long = format!("| 머리 | 둘 |\n|---|---|\n| 짧음 | 가 |\n| {} | 나 |\n| 짧음2 | 다 |", "긴칸".repeat(200));
let opts = mdwire::Options { limit: Some(300), ..Default::default() };
let parts = mdwire::render_with(&long, Channel::NotionMarkdown, opts).parts;
for p in &parts {
assert!(p.chars().count() <= 300);
assert_eq!(p.matches("<table ").count(), p.matches("</table>").count(), "{p}");
}
assert!(parts.concat().contains("긴칸긴칸 | 나"));
assert_eq!(
one("| a |\n|---|\n| x </td> y <br> z |", Channel::NotionMarkdown),
"<table header-row=\"true\">\n<tr>\n<td>a</td>\n</tr>\n<tr>\n<td>x \\<\\/td\\> y <br> z</td>\n</tr>\n</table>"
);
let cells = one("| a | b |\n|---|---|\n| i<n 일 때<br>반복 | `Option<T>` 와 `x</td>y` |", Channel::NotionMarkdown);
assert!(cells.contains("<td>i<n 일 때<br>반복</td>"), "{cells}");
assert!(cells.contains(r"<td>`Option<T>` 와 `x\<\/td\>y`</td>"), "{cells}");
let only = format!("| 이름 | 설명 |\n|---|---|\n| # 제목처럼 | {} |", "가 ".repeat(200));
let opts = mdwire::Options { limit: Some(256), ..Default::default() };
let out = mdwire::render_with(&only, Channel::NotionMarkdown, opts).parts.concat();
assert!(out.starts_with("이름 | 설명\n\\# 제목처럼 | 가 가"), "{out}");
let mut s = mdwire::Streamer::new(Channel::NotionMarkdown);
let mut acc = String::new();
s.push_into(&input, &mut acc);
s.finish_into(&mut acc);
assert_eq!(acc.matches("<table ").count(), 1);
}
#[test]
fn report_counts_what_the_channel_rewrote() {
let r = |s: &str, ch| mdwire::render_with(s, ch, mdwire::Options::default()).repairs;
let gh = r(
"약 ~40km, **「설정」**가 <!-- x --> H<sub>2</sub>\n\n* 하나\n- 둘\n\n| a |\n|---|\n| 1 |\n\n| b |\n| --- |\n| 2 |",
Channel::GithubMarkdown,
);
assert_eq!(
(gh.escaped_char, gh.tag_emphasis, gh.stripped_html, gh.rewritten_bullet, gh.rewritten_table, gh.converted_marker),
(1, 1, 1, 1, 1, 0),
"{gh:?}"
);
let slack = r("_기울임_ __굵게__ <https://a.com|링크>\n• 항목", Channel::SlackMarkdown);
assert_eq!((slack.converted_marker, slack.rewritten_bullet), (3, 1), "{slack:?}");
assert!(!r("**굵게** 와 `코드`\n\n- 하나", Channel::SlackMarkdown).changed());
let tilde = r("약 ~40km", Channel::GithubMarkdown);
assert!(tilde.changed() && !tilde.any(), "{tilde:?}");
assert_eq!(r("<br>\n**x**", Channel::GithubMarkdown).stripped_html, 1);
assert_eq!(r("<https://a.com|l~x>", Channel::GithubMarkdown).escaped_char, 1);
assert_eq!(r("1) a\n2) b", Channel::SlackMarkdown).rewritten_bullet, 2);
}
#[test]
fn github_uses_tags_where_gfm_cannot_pair_markers() {
let g = |s| one(s, Channel::GithubMarkdown);
assert_eq!(g("**설정(config)**을 바꾼다"), "<strong>설정(config)</strong>을 바꾼다");
assert_eq!(g("*\"인용\"*은 · ~~(취소)~~가"), "<em>\"인용\"</em>은 · <del>(취소)</del>가");
assert_eq!(g("**`코드`**였다"), "<strong>`코드`</strong>였다");
assert_eq!(g("**마통**이 · **(중요)** 다 · 앞 **\"인용\"** 뒤"), "**마통**이 · **(중요)** 다 · 앞 **\"인용\"** 뒤");
assert_eq!(one("**설정(config)**을", Channel::SlackMarkdown), "**설정(config)\u{2060}**을");
assert_eq!(one("①**\"인용\" 끝**", Channel::SlackMarkdown), "①**\u{2060}\"인용\" 끝**");
assert_eq!(one("**마통**이 · **(중요)** 다", Channel::SlackMarkdown), "**마통**이 · **(중요)** 다");
assert_eq!(one("**설정(config)**을", Channel::NotionMarkdown), "**설정(config)**을");
assert_eq!(g("**x.**<font color=red>y</font> 끝"), "<strong>x.</strong>y 끝");
assert_eq!(g("a<!-- c -->**(x** 끝"), "a<strong>(x</strong> 끝");
}
#[test]
fn github_keeps_inline_tags_unless_the_line_is_only_tags() {
let g = |s| one(s, Channel::GithubMarkdown);
assert_eq!(g("H<sub>2</sub>O 와 <kbd>Ctrl</kbd> 줄<br>바꿈"), "H<sub>2</sub>O 와 <kbd>Ctrl</kbd> 줄<br>바꿈");
assert_eq!(g("앞 <div>블록</div> <font color=red>빨강</font>"), "앞 블록 빨강");
assert_eq!(g("본문\n\n<sub>모델 · 토큰</sub>"), "본문\n\n<sub>모델 · 토큰</sub>");
assert_eq!(g("<sub>첫머리</sub> 뒤 <sub>둘</sub>"), "<sub>첫머리</sub> 뒤 <sub>둘</sub>");
assert_eq!(g("<sub>\n둘째 줄</sub> 끝"), "둘째 줄 끝");
assert_eq!(g("앞\n<br>\n**굵게**"), "앞\n\n\n**굵게**");
assert_eq!(g("<br><!-- x -->\n**굵게**"), "**굵게**");
assert_eq!(g("<ins>새</ins> 글"), "<ins>새</ins> 글");
assert_eq!(g("<details><summary>약 ~40ms</summary>"), r"약 \~40ms");
assert_eq!(one("H<sub>2</sub>O", Channel::SlackMarkdown), "H2O");
}
#[test]
fn br_inside_a_table_cell_keeps_the_row() {
let input = "| a | 첫째<br>둘째 |\n|---|---|\n| 1 | 2 |";
assert_eq!(one(input, Channel::GithubMarkdown), "| a | 첫째<br>둘째 |\n| --- | --- |\n| 1 | 2 |");
assert_eq!(one(input, Channel::SlackMarkdown), "| a | 첫째 둘째 |\n| --- | --- |\n| 1 | 2 |");
assert_eq!(one("줄<br>바꿈", Channel::SlackMarkdown), "줄\n바꿈");
}
#[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 splits = |c: &Channel| c.limit() < usize::MAX;
let longest = Channel::all().iter().filter(|c| splits(c)).map(|c| c.limit()).max().expect("채널이 있다");
let input = format!("**{}**", "a".repeat(longest + longest / 2));
for channel in Channel::all().into_iter().filter(splits) {
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());
}
}
}
#[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}");
}
}
#[test]
fn resumed_hold_scans_agree_with_batch() {
let inputs = [
"앞 [긴 텍스트 [중첩] 계속](https://x.io/a) 뒤",
"[a](1.[b](url) 끝",
"[텍스트] 링크 아님, 그리고 [둘](u)",
"앞 <!-- 주석 - -- 아직 --> 뒤 **굵게**",
"<!-- 안 닫힌 주석 [x](y) 끝까지",
"[안 닫힌 링크 <!-- 주석 --> 끝까지",
"**굵게 [링크](u) <!--c--> 끝**",
];
for input in inputs {
for channel in [Channel::TelegramHtml, Channel::SlackMarkdown] {
let batch = render(input, channel).join("");
let chars: Vec<char> = input.chars().collect();
for size in 1..=chars.len() {
let mut s = Streamer::new(channel);
let mut got = String::new();
for chunk in chars.chunks(size) {
s.push_into(&chunk.iter().collect::<String>(), &mut got);
}
s.finish_into(&mut got);
assert_eq!(got, batch, "{input:?} · {} · 조각 {size}", channel.name());
}
}
}
}
#[test]
fn html_renders_blocks_as_tags() {
let h = |s| one(s, Channel::Html);
assert_eq!(h("## 제목"), "<h2>제목</h2>");
assert_eq!(h("문단 **굵게\n이어짐** 끝"), "<p>문단 <strong>굵게<br>\n이어짐</strong> 끝</p>");
assert_eq!(h("> 인용\n> 둘째"), "<blockquote>인용<br>\n둘째</blockquote>");
assert_eq!(h("```ts\na < b\n```"), "<pre><code class=\"language-ts\">a < b</code></pre>");
assert_eq!(h("---"), "<hr>");
assert_eq!(
h("| a | b |\n|---|--:|\n| **x** | 1 |"),
"<table>\n<thead><tr><th>a</th><th style=\"text-align:right\">b</th></tr></thead>\n\
<tbody><tr><td><strong>x</strong></td><td style=\"text-align:right\">1</td></tr></tbody>\n</table>"
);
}
#[test]
fn html_nests_lists_by_indent() {
let h = |s| one(s, Channel::Html);
assert_eq!(
h("- 하나\n - 둘\n 1. 셋\n- 넷"),
"<ul><li>하나\n<ul><li>둘\n<ol><li>셋\n</li></ol></li></ul></li><li>넷</li></ul>"
);
assert_eq!(h("3. 삼\n4. 사"), "<ol start=\"3\"><li>삼\n</li><li>사</li></ol>");
assert_eq!(h("- 가\n1. 나"), "<ul><li>가\n</li></ul><ol><li>나</li></ol>");
let loose = h("- a\n\n - b\n\n- c");
assert!(loose.starts_with("<ul><li>a") && loose.contains("<ul><li>b") && loose.ends_with("</li><li>c</li></ul>"), "{loose}");
assert_eq!(loose.matches("<ul>").count(), 2, "{loose}");
assert_eq!(h("- a\n\n문단"), "<ul><li>a</li></ul>\n\n<p>문단</p>");
}
#[test]
fn html_output_is_safe_for_inner_html() {
let h = |s| one(s, Channel::Html);
assert_eq!(h("1 < 2 & `a<b>`"), "<p>1 < 2 & <code>a<b></code></p>");
assert_eq!(h("[나쁨](javascript:alert) [좋음](https://a.com)"), "<p>나쁨 (javascript:alert) <a href=\"https://a.com\">좋음</a></p>");
assert_eq!(h("[메일](MAILTO:a@b.c)"), "<p><a href=\"MAILTO:a@b.c\">메일</a></p>");
assert_eq!(h("H<sub onclick=\"x()\">2</sub>O"), "<p>H<sub>2</sub>O</p>");
assert_eq!(h("<div>블록</div> <script>x</script>"), "<p>블록 <script>x</script></p>");
assert_eq!(h("**a<sub>b**c</sub>"), "<p><strong>a<sub>b</sub></strong>c</p>");
assert_eq!(h("<sub>안 닫힘 뒤 </b> 끝"), "<p><sub>안 닫힘 뒤 끝</sub></p>");
assert_eq!(h("앞 `a\nb 뒤"), "<p>앞 `a<br>\nb 뒤</p>");
assert_eq!(h("- 앞 `a\n b 뒤"), "<ul><li>앞 `a<br>\n b 뒤</li></ul>");
assert_eq!(
h("```x\"onmouseover=\"alert(1)\ncode\n```"),
"<pre><code class=\"language-x"onmouseover="alert(1)\">code</code></pre>"
);
}
#[test]
fn html_streaming_snapshot_is_always_balanced() {
let input = "## 제목\n\n문단 **굵게** 와 `코드`\n\n- 하나\n - 둘 <sub>x</sub>\n- 셋\n\n> 인용\n\n```\nfence\n```\n| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut s = mdwire::Streamer::new(Channel::Html);
let mut acc = String::new();
for ch in input.chars() {
s.push_into(ch.encode_utf8(&mut [0; 4]), &mut acc);
let mut snap = acc.clone();
s.close_open(&mut snap);
assert!(balanced_html(&snap), "균형이 깨진 스냅숏: {snap}");
}
s.finish_into(&mut acc);
assert_eq!(acc, render(input, Channel::Html).join(""));
assert!(balanced_html(&acc));
}
fn balanced_html(s: &str) -> bool {
let mut stack: Vec<String> = Vec::new();
let mut rest = s;
while let Some(at) = rest.find('<') {
let Some(end) = rest[at..].find('>') else { return false };
let tag = &rest[at + 1..at + end];
rest = &rest[at + end + 1..];
let name: String = tag.trim_start_matches('/').chars().take_while(|c| c.is_ascii_alphanumeric()).collect();
if matches!(name.as_str(), "br" | "hr") {
continue;
}
if tag.starts_with('/') {
if stack.pop().as_deref() != Some(name.as_str()) {
return false;
}
} else {
stack.push(name);
}
}
stack.is_empty()
}
#[test]
fn github_kept_tags_survive_splits_and_match_by_name() {
let input = format!("x <sub>{}</sub> 끝", "가나 ".repeat(30_000));
let parts = render(&input, Channel::GithubMarkdown);
assert!(parts.len() > 1);
for p in &parts {
assert_eq!(p.matches("<sub>").count(), p.matches("</sub>").count(), "조각 안에서 짝이 안 맞는다");
}
let g = |s| one(s, Channel::GithubMarkdown);
assert_eq!(g("<sub>foo <kbd>x</kbd></sub> 뒤"), "<sub>foo <kbd>x</kbd></sub> 뒤");
assert_eq!(g("<sub>\nfoo <kbd>x</kbd></sub> 뒤"), "foo <kbd>x</kbd> 뒤");
assert_eq!(g("| <sub>h</sub> | b |\n|---|---|\n| 1 | 2 |"), "| <sub>h</sub> | b |\n| --- | --- |\n| 1 | 2 |");
assert_eq!(g("앞 <SPAN style=\"x\">가</SPAN>"), "앞 <span>가</span>");
}
#[test]
fn caller_limit_overrides_the_channel_limit() {
let long = "가나다 ".repeat(3000);
assert_eq!(render(&long, Channel::Plain).len(), 1);
let opts = mdwire::Options { limit: Some(4096), ..Default::default() };
let parts = mdwire::render_with(&long, Channel::Plain, opts).parts;
assert!(parts.len() > 1 && parts.iter().all(|p| p.chars().count() <= 4096));
let url = format!("https://a.com/{}", "x".repeat(300));
let input = format!("[문서]({url})");
let opts = mdwire::Options { limit: Some(mdwire::MIN_LIMIT), ..Default::default() };
let out = mdwire::render_with(&input, Channel::TelegramHtml, opts).parts.join("");
assert!(!out.contains("<a href"), "한도 256 에 300자 주소 링크: {out}");
}
#[test]
fn tiny_limits_are_raised_and_html_never_splits() {
let tiny = mdwire::Options { limit: Some(1), ..Default::default() };
assert_eq!(mdwire::render_with("**x**", Channel::TelegramHtml, tiny.clone()).parts, vec!["<b>x</b>"]);
assert_eq!(mdwire::render_with("abcdefghijklmnop", Channel::Html, tiny).parts, vec!["<p>abcdefghijklmnop</p>"]);
let long = "가나다 ".repeat(300);
let parts = mdwire::render_with(&long, Channel::Plain, mdwire::Options { limit: Some(10), ..Default::default() }).parts;
assert!(parts.len() > 1 && parts.iter().all(|p| p.chars().count() <= mdwire::MIN_LIMIT));
}
#[test]
fn html_policy_options() {
use mdwire::{HtmlOptions, Images, LineBreaks, Options};
let input = "첫 줄\n둘째  [전화](tel:010) ";
let h = |html: HtmlOptions| {
mdwire::render_with(input, Channel::Html, Options { html, ..Default::default() }).parts.join("")
};
assert_eq!(
h(HtmlOptions::default()),
"<p>첫 줄<br>\n둘째 <a href=\"https://a.com/c.png\">고양이</a> 전화 (tel:010) 나쁨 (javascript:x)</p>"
);
assert!(h(HtmlOptions { line_breaks: LineBreaks::Space, ..Default::default() }).starts_with("<p>첫 줄\n둘째"));
let load = h(HtmlOptions { images: Images::Load, ..Default::default() });
assert!(load.contains("<img src=\"https://a.com/c.png\" alt=\"고양이\">") && load.contains("나쁨 (javascript:x)"));
let tel = h(HtmlOptions { schemes: Some(vec!["https".into(), "TEL".into()]), ..Default::default() });
assert!(tel.contains("<a href=\"tel:010\">전화</a>"));
let only_tel = h(HtmlOptions { schemes: Some(vec!["tel".into()]), ..Default::default() });
assert!(only_tel.contains("고양이 (https://a.com/c.png)"));
assert_eq!(one("", Channel::TelegramHtml), "<a href=\"https://a.com/c.png\">고양이</a>");
assert_eq!(one("", Channel::GithubMarkdown), "");
let mut s = mdwire::Streamer::new(Channel::Html);
let mut acc = String::new();
for piece in ["사진 !", "[고양이](https://a.com/c.png) 끝!"] {
s.push_into(piece, &mut acc);
}
s.finish_into(&mut acc);
assert_eq!(acc, render("사진  끝!", Channel::Html).join(""));
}
#[test]
fn preview_draws_what_push_holds() {
fn snap(channel: Channel, input: &str) -> (String, String) {
let mut s = Streamer::new(channel);
let mut acc = String::new();
s.push_into(input, &mut acc);
let tail = s.preview().to_string();
(acc, tail)
}
assert_eq!(snap(Channel::TelegramHtml, "앞말 **굵"), ("앞말 ".into(), "<b>굵</b>".into()));
assert_eq!(snap(Channel::TelegramHtml, "앞 `코드"), ("앞 ".into(), "<code>코드</code>".into()));
for (channel, input) in [
(Channel::TelegramHtml, "앞 `코드`"),
(Channel::TelegramHtml, "앞 `코드` "),
(Channel::Html, "> a ```b\n"),
(Channel::Html, "> a `b\n"),
(Channel::Html, "- a `b\n"),
] {
let (acc, tail) = snap(channel, input);
let got = acc + &tail;
assert!(!got.contains('`') && !got.contains("<pre>"), "{input:?} → {got:?}");
}
let (acc, tail) = snap(Channel::Html, "| a | b |\n|---|---|\n| 1 | 2 |\n| 3");
assert!(acc.is_empty() && tail.starts_with("<table>") && tail.contains("<td>1</td>"), "{tail}");
let mut s = Streamer::new(Channel::TelegramHtml);
let mut acc = String::new();
for c in ["앞 **굵", "게** `코", "드` 끝"] {
s.push_into(c, &mut acc);
let _ = s.preview();
}
s.finish_into(&mut acc);
assert_eq!(acc, render("앞 **굵게** `코드` 끝", Channel::TelegramHtml).concat());
}
#[test]
fn revised_tells_whether_the_last_preview_was_final() {
let run = |chunks: &[&str], preview_last: bool| {
let mut s = Streamer::new(Channel::TelegramHtml);
let mut acc = String::new();
for c in chunks {
s.push_into(c, &mut acc);
}
if preview_last {
let _ = s.preview();
}
s.finish_into(&mut acc);
s.revised()
};
assert!(!run(&["앞 **굵게** 끝"], true), "마지막 미리보기가 곧 완성본");
assert!(run(&["앞 `안 닫힌 코드"], true), "미리보기는 코드로, 완성본은 글자로");
assert!(run(&["앞 **굵게** 끝"], false), "미리보기를 안 했으면 다시 그린다");
let mut s = Streamer::new(Channel::TelegramHtml);
let mut acc = String::new();
s.push_into("앞", &mut acc);
let _ = s.preview();
s.push_into(" 뒤", &mut acc);
s.finish_into(&mut acc);
assert!(s.revised(), "미리보기 뒤에 조각이 더 왔다");
assert_eq!(s.preview(), "", "끝난 뒤에는 더 그릴 꼬리가 없다");
let mut s = Streamer::new(Channel::TelegramHtml);
s.push_into("앞 **굵", &mut acc);
s.finish_into(&mut acc);
assert_eq!(s.preview(), "", "미리보기 없이 끝났어도 마찬가지");
}