use crate::channels::telegram::rich::api::build_body_markdown_media;
use crate::channels::telegram::rich::ast::{Block, Inline, MermaidResult};
use crate::channels::telegram::rich::markdown_to_html_mermaid;
use crate::channels::telegram::rich::mermaid::{
MediaEntry, base64url, error_note, failure_html, find_mermaid_fences, has_mermaid_fence,
image_html, is_image_response, markdown_failure_block, replacement_for, resolve_blocks,
resolve_markdown_media,
};
#[test]
fn base64url_matches_rfc4648_url_safe_no_pad() {
assert_eq!(base64url("hello world"), "aGVsbG8gd29ybGQ");
assert_eq!(base64url("~~~"), "fn5-");
assert_eq!(base64url("????"), "Pz8_Pw");
}
#[test]
fn base64url_output_never_contains_forbidden_chars() {
for input in [
"a",
"ab",
"abc",
"mermaid graph TD; A-->B",
"héllo wörld",
"????~~~~",
] {
let out = base64url(input);
assert!(!out.contains('+'), "unexpected '+' in {out}");
assert!(!out.contains('/'), "unexpected '/' in {out}");
assert!(!out.contains('='), "unexpected '=' padding in {out}");
}
}
#[test]
fn base64url_round_trips() {
use base64::Engine as _;
for input in ["graph TD; A-->B;", "flowchart LR\n X --> Y", "ünïcode ✓"] {
let encoded = base64url(input);
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded.as_bytes())
.expect("base64url must decode");
assert_eq!(String::from_utf8(decoded).unwrap(), input);
}
}
#[test]
fn has_mermaid_fence_detects_tagged_fence() {
assert!(has_mermaid_fence("```mermaid\ngraph TD;\n```"));
assert!(has_mermaid_fence("before\n```mermaid\nA-->B\n```\nafter"));
}
#[test]
fn has_mermaid_fence_is_case_insensitive_and_tolerates_space() {
assert!(has_mermaid_fence("```Mermaid\ngraph TD;\n```"));
assert!(has_mermaid_fence("``` mermaid\ngraph TD;\n```"));
}
#[test]
fn has_mermaid_fence_rejects_other_or_missing_fences() {
assert!(!has_mermaid_fence("```rust\nfn main() {}\n```"));
assert!(!has_mermaid_fence("plain prose, no fences"));
assert!(!has_mermaid_fence("I like mermaid diagrams"));
assert!(!has_mermaid_fence("```\nuntagged fence\n```"));
}
#[test]
fn is_image_response_accepts_2xx_image() {
assert!(is_image_response(200, "image/jpeg"));
assert!(is_image_response(200, "image/png"));
assert!(is_image_response(200, "image/svg+xml"));
assert!(is_image_response(200, "image/png; charset=binary"));
assert!(is_image_response(204, "image/webp"));
}
#[test]
fn is_image_response_is_case_insensitive_on_content_type() {
assert!(is_image_response(200, "IMAGE/PNG"));
assert!(is_image_response(200, "Image/Jpeg"));
}
#[test]
fn is_image_response_rejects_non_2xx_or_non_image() {
assert!(!is_image_response(400, "image/jpeg"));
assert!(!is_image_response(500, "image/png"));
assert!(!is_image_response(200, "text/plain"));
assert!(!is_image_response(200, "text/html"));
assert!(!is_image_response(200, ""));
assert!(!is_image_response(300, "image/png"));
}
#[test]
fn error_note_returns_body_when_present() {
assert_eq!(
error_note(400, "Parse error on line 2: got 'LINK'"),
"Parse error on line 2: got 'LINK'"
);
}
#[test]
fn error_note_trims_whitespace() {
assert_eq!(error_note(400, " some error "), "some error");
}
#[test]
fn error_note_falls_back_to_status_on_empty_body() {
assert_eq!(error_note(500, ""), "diagram renderer returned HTTP 500");
assert_eq!(error_note(400, " "), "diagram renderer returned HTTP 400");
}
#[test]
fn error_note_caps_length() {
let long = "x".repeat(1000);
let note = error_note(400, &long);
assert_eq!(note.chars().count(), 400);
}
#[test]
fn image_html_wraps_url_in_figure() {
assert_eq!(
image_html("https://mermaid.ink/img/abc123"),
"<figure><img src=\"https://mermaid.ink/img/abc123\"/></figure>"
);
}
#[test]
fn image_html_escapes_url_entities() {
assert_eq!(
image_html("a&b<c>"),
"<figure><img src=\"a&b<c>\"/></figure>"
);
}
#[test]
fn failure_html_contains_warning_error_and_source() {
let html = failure_html("Parse error on line 2", "graph TD; A-->B");
assert!(html.contains("<b>⚠️ Mermaid diagram could not be rendered</b>"));
assert!(html.contains("<blockquote>Parse error on line 2</blockquote>"));
assert!(html.contains("<pre><code>graph TD; A-->B</code></pre>"));
}
#[test]
fn failure_html_escapes_error_and_source() {
let html = failure_html("<script>alert(1)</script>", "a < b & c");
assert!(html.contains("<script>alert(1)</script>"));
assert!(html.contains("a < b & c"));
assert!(!html.contains("<script>"));
}
#[tokio::test]
async fn resolve_blocks_passes_through_non_mermaid() {
let blocks = vec![
Block::Paragraph(vec![Inline::Text("hello".into())]),
Block::Code {
lang: Some("rust".into()),
text: "fn main() {}".into(),
},
];
let resolved = resolve_blocks(blocks.clone()).await;
assert_eq!(resolved, blocks, "non-mermaid blocks must be untouched");
}
#[tokio::test]
async fn resolve_blocks_empty_input() {
let resolved = resolve_blocks(Vec::new()).await;
assert!(resolved.is_empty());
}
#[tokio::test]
async fn markdown_to_html_mermaid_renders_plain_markdown() {
let html = markdown_to_html_mermaid("# Hi\n\nSome **bold** text.").await;
assert_eq!(html, "<b>Hi</b>\n\nSome <b>bold</b> text.");
}
#[test]
fn find_mermaid_fences_locates_single_fence_with_range_and_source() {
let text = "before\n```mermaid\ngraph TD;\nA-->B\n```\nafter";
let fences = find_mermaid_fences(text);
assert_eq!(fences.len(), 1);
let f = &fences[0];
assert_eq!(&text[f.start..f.end], "```mermaid\ngraph TD;\nA-->B\n```\n");
assert_eq!(f.source, "graph TD;\nA-->B\n");
}
#[test]
fn find_mermaid_fences_locates_multiple_and_orders_them() {
let text = "```mermaid\nA\n```\nmid\n```mermaid\nB\n```";
let fences = find_mermaid_fences(text);
assert_eq!(fences.len(), 2);
assert_eq!(fences[0].source, "A\n");
assert_eq!(fences[1].source, "B\n");
assert!(fences[0].start < fences[1].start);
}
#[test]
fn find_mermaid_fences_ignores_non_mermaid_and_unclosed() {
assert!(find_mermaid_fences("```rust\nfn main() {}\n```").is_empty());
assert!(find_mermaid_fences("no fences").is_empty());
assert!(find_mermaid_fences("```mermaid\ngraph TD;\n").is_empty());
}
#[test]
fn replacement_for_image_emits_media_reference_and_entry() {
let outcome = MermaidResult::Image("https://mermaid.ink/img/xyz".into());
let (md, entry) = replacement_for(&outcome, 0, "graph TD;");
assert_eq!(md, "");
let e = entry.expect("image outcome must carry a media entry");
assert_eq!(e.id, "diag0");
assert_eq!(e.url, "https://mermaid.ink/img/xyz");
}
#[test]
fn replacement_for_image_uses_fence_index_in_id() {
let outcome = MermaidResult::Image("u".into());
let (md, entry) = replacement_for(&outcome, 3, "src");
assert_eq!(md, "");
assert_eq!(entry.unwrap().id, "diag3");
}
#[test]
fn replacement_for_failed_emits_failure_block_and_no_entry() {
let outcome = MermaidResult::Failed("Parse error".into());
let (md, entry) = replacement_for(&outcome, 0, "graph TD;");
assert!(
entry.is_none(),
"failed outcome must not carry a media entry"
);
assert!(md.contains("Mermaid diagram could not be rendered"));
assert!(md.contains("Parse error"));
assert!(md.contains("graph TD;"));
}
#[test]
fn markdown_failure_block_contains_warning_error_and_source() {
let md = markdown_failure_block("Parse error on line 2", "graph TD; A-->B");
assert!(md.contains("> ⚠️ **Mermaid diagram could not be rendered**"));
assert!(md.contains("Parse error on line 2"));
assert!(md.contains("graph TD; A-->B"));
assert!(md.contains("Source:"));
}
#[test]
fn build_body_markdown_media_matches_prototype_shape() {
let media = vec![MediaEntry {
id: "diag0".into(),
url: "https://mermaid.ink/img/abc".into(),
}];
let body = build_body_markdown_media(-100, None, "text", &media);
assert_eq!(body["chat_id"], -100);
assert_eq!(body["rich_message"]["markdown"], "text");
let arr = body["rich_message"]["media"]
.as_array()
.expect("media array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "diag0");
assert_eq!(arr[0]["media"]["type"], "photo");
assert_eq!(arr[0]["media"]["media"], "https://mermaid.ink/img/abc");
assert!(body.get("message_thread_id").is_none());
}
#[test]
fn build_body_markdown_media_includes_thread_id_when_present() {
use teloxide::types::{MessageId, ThreadId};
let body = build_body_markdown_media(-100, Some(ThreadId(MessageId(249))), "m", &[]);
assert_eq!(body["message_thread_id"], 249);
}
#[tokio::test]
async fn resolve_markdown_media_passes_through_without_fences() {
let text = "# Title\n\nSome **bold** text and a table:\n\n| a | b |\n|---|---|\n| 1 | 2 |";
let (resolved, media) = resolve_markdown_media(text).await;
assert_eq!(resolved, text, "no-fence text must be byte-identical");
assert!(media.is_empty());
}