Skip to main content

ferogram_parsers/
lib.rs

1// Copyright (c) Ankit Chaubey <ankitchaubey.dev@gmail.com>
2//
3// ferogram: async Telegram MTProto client in Rust
4// https://github.com/ankit-chaubey/ferogram
5//
6// Licensed under either the MIT License or the Apache License 2.0.
7// See the LICENSE-MIT or LICENSE-APACHE file in this repository:
8// https://github.com/ankit-chaubey/ferogram
9//
10// Feel free to use, modify, and share this code.
11// Please keep this notice when redistributing.
12
13#![cfg_attr(docsrs, feature(doc_cfg))]
14#![doc(html_root_url = "https://docs.rs/ferogram-parsers/0.6.4")]
15//! Telegram HTML and Markdown entity parsers for ferogram.
16//!
17//! This crate is part of [ferogram](https://crates.io/crates/ferogram), an async Rust
18//! MTProto client built by [Ankit Chaubey](https://github.com/ankit-chaubey).
19//!
20//! - Channel: [t.me/Ferogram](https://t.me/Ferogram)
21//! - Chat: [t.me/FerogramChat](https://t.me/FerogramChat)
22//!
23//! Converts Telegram-flavoured Markdown and HTML into the `(plain_text,
24//! entities)` pair that the Telegram API expects, and generates those formats
25//! back from entities. Also provides rich-text parsers that produce
26//! `Vec<PageBlock>` for Telegraph-style structured content.
27//!
28//! Most users reach this through the `ferogram` crate's `InputMessage`
29//! builder (`.markdown()` / `.html()`). Use `ferogram-parsers` directly only
30//! when you need the parse/generate functions without the full client.
31//!
32//! # What's in here
33//!
34//! - **[`parse_markdown`]** / **[`generate_markdown`]**: Parse and generate
35//!   Telegram MarkdownV2. Supports bold, italic, underline, strikethrough,
36//!   spoiler, inline code, code blocks with language, blockquotes (regular
37//!   and expandable), text URLs, user mentions, and custom emoji. All offsets
38//!   are in UTF-16 code units as required by the Telegram API.
39//! - **[`parse_markdown_v1`]** (deprecated): Legacy MarkdownV1 retained for
40//!   backward compatibility. Prefer V2 for new code.
41//! - **[`parse_html`]** / **[`generate_html`]**: Parse and generate
42//!   Telegram Bot API HTML. Recognises `<b>`, `<i>`, `<u>`, `<s>`,
43//!   `<code>`, `<pre>`, `<a>`, `<blockquote>`, `<tg-spoiler>`,
44//!   `<tg-emoji>`, `<tg-time>`, and their aliases. The `html5ever` feature
45//!   switches to a spec-compliant parser for malformed input.
46//! - **[`parse_rich_markdown`]** / **[`parse_rich_inline_md`]**: Parse
47//!   extended Markdown into `Vec<PageBlock>` / `RichText` for Telegraph
48//!   articles. Supports headings (H1–H6), paragraphs, ordered and unordered
49//!   lists, task lists, tables, blockquotes, pull quotes, dividers, fenced
50//!   code blocks, and math blocks.
51//! - **[`parse_rich_html`]** / **[`parse_rich_html_inline`]**: Same as the
52//!   Markdown rich parsers but driven by HTML input (`<h1>`–`<h6>`, `<ul>`,
53//!   `<ol>`, `<table>`, `<details>`, `<tg-map>`, `<tg-math-block>`, etc.).
54//!
55//! # Example: Markdown round-trip
56//!
57//! ```rust
58//! use ferogram_parsers::{parse_markdown, generate_markdown};
59//!
60//! let (text, entities) = parse_markdown("Hello **world**!");
61//! assert_eq!(text, "Hello world!");
62//!
63//! let back = generate_markdown(&text, &entities);
64//! assert_eq!(back, "Hello *world*\\!");
65//! ```
66//!
67//! # Example: HTML to entities
68//!
69//! ```rust
70//! use ferogram_parsers::parse_html;
71//!
72//! let (text, entities) = parse_html("<b>bold</b> and <i>italic</i>");
73//! assert_eq!(text, "bold and italic");
74//! assert_eq!(entities.len(), 2);
75//! ```
76//!
77//! # Features
78//!
79//! | Feature | Default | Description |
80//! |---------|---------|-------------|
81//! | `html` | yes | Built-in hand-rolled HTML parser |
82//! | `html5ever` | no | Replaces the built-in parser with `html5ever` for spec-compliant handling of malformed HTML |
83
84#![deny(unsafe_code)]
85
86mod html;
87mod markdown;
88mod rich_common;
89mod rich_html;
90mod rich_markdown;
91
92#[allow(deprecated)]
93pub use markdown::{
94    generate_markdown, generate_markdown_v2, parse_markdown, parse_markdown_v1, parse_markdown_v2,
95};
96
97pub use html::{generate_html, parse_html};
98
99pub use rich_common::parse_rich_inline_md;
100pub use rich_markdown::parse_rich_markdown;
101
102pub use rich_html::{parse_rich_html, parse_rich_html_inline};
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use ferogram_tl_types as tl;
108
109    // Markdown V2 (default)
110
111    #[test]
112    fn markdown_bold() {
113        let (text, ents) = parse_markdown("Hello **world**!");
114        assert_eq!(text, "Hello world!");
115        if let tl::enums::MessageEntity::Bold(b) = &ents[0] {
116            assert_eq!(b.offset, 6);
117            assert_eq!(b.length, 5);
118        } else {
119            panic!("expected bold");
120        }
121    }
122
123    #[test]
124    fn markdown_bold_single_asterisk() {
125        let (text, ents) = parse_markdown("*bold*");
126        assert_eq!(text, "bold");
127        assert!(matches!(ents[0], tl::enums::MessageEntity::Bold(_)));
128    }
129
130    #[test]
131    fn markdown_italic_single_underscore() {
132        let (text, ents) = parse_markdown("_italic_");
133        assert_eq!(text, "italic");
134        assert!(matches!(ents[0], tl::enums::MessageEntity::Italic(_)));
135    }
136
137    /// V2: `__text__` = Underline (not Italic)
138    #[test]
139    fn markdown_v2_underline_double_underscore() {
140        let (text, ents) = parse_markdown("__underline__");
141        assert_eq!(text, "underline");
142        assert!(
143            matches!(ents[0], tl::enums::MessageEntity::Underline(_)),
144            "expected Underline, got {:?}",
145            ents[0]
146        );
147    }
148
149    /// V2: single `~text~` = Strikethrough
150    #[test]
151    fn markdown_v2_strike_single_tilde() {
152        let (text, ents) = parse_markdown("~strike~");
153        assert_eq!(text, "strike");
154        assert!(matches!(ents[0], tl::enums::MessageEntity::Strike(_)));
155    }
156
157    #[test]
158    fn markdown_spoiler() {
159        let (text, ents) = parse_markdown("||spoiler||");
160        assert_eq!(text, "spoiler");
161        assert!(matches!(ents[0], tl::enums::MessageEntity::Spoiler(_)));
162    }
163
164    #[test]
165    fn markdown_inline_code() {
166        let (text, ents) = parse_markdown("Use `foo()` to do it");
167        assert_eq!(text, "Use foo() to do it");
168        assert!(matches!(ents[0], tl::enums::MessageEntity::Code(_)));
169    }
170
171    #[test]
172    fn markdown_code_block_with_lang() {
173        let (text, ents) = parse_markdown("```rust\nfn main() {}\n```");
174        assert_eq!(text, "fn main() {}");
175        if let tl::enums::MessageEntity::Pre(p) = &ents[0] {
176            assert_eq!(p.language, "rust");
177            assert_eq!(p.offset, 0);
178        } else {
179            panic!("expected pre");
180        }
181    }
182
183    #[test]
184    fn markdown_code_block_no_lang() {
185        let (text, ents) = parse_markdown("```\nhello\n```");
186        assert_eq!(text, "hello");
187        if let tl::enums::MessageEntity::Pre(p) = &ents[0] {
188            assert_eq!(p.language, "");
189        } else {
190            panic!("expected pre");
191        }
192    }
193
194    #[test]
195    fn markdown_text_url() {
196        let (text, ents) = parse_markdown("[click](https://example.com)");
197        assert_eq!(text, "click");
198        if let tl::enums::MessageEntity::TextUrl(e) = &ents[0] {
199            assert_eq!(e.url, "https://example.com");
200        } else {
201            panic!("expected text url");
202        }
203    }
204
205    #[test]
206    fn markdown_mention() {
207        let (text, ents) = parse_markdown("[User](tg://user?id=42)");
208        assert_eq!(text, "User");
209        if let tl::enums::MessageEntity::MentionName(e) = &ents[0] {
210            assert_eq!(e.user_id, 42);
211        } else {
212            panic!("expected mention name");
213        }
214    }
215
216    #[test]
217    fn markdown_custom_emoji() {
218        let (text, ents) = parse_markdown("![👍](tg://emoji?id=5368324170671202286)");
219        assert_eq!(text, "👍");
220        if let tl::enums::MessageEntity::CustomEmoji(e) = &ents[0] {
221            assert_eq!(e.document_id, 5368324170671202286);
222        } else {
223            panic!("expected custom emoji");
224        }
225    }
226
227    /// V2: empty label in custom emoji is valid
228    #[test]
229    fn markdown_v2_custom_emoji_empty_label() {
230        let (text, ents) = parse_markdown("![](tg://emoji?id=12345)");
231        assert_eq!(text, "");
232        if let tl::enums::MessageEntity::CustomEmoji(e) = &ents[0] {
233            assert_eq!(e.document_id, 12345);
234        } else {
235            panic!("expected custom emoji");
236        }
237    }
238
239    #[test]
240    fn markdown_backslash_escape() {
241        let (text, ents) = parse_markdown(r"\*not bold\*");
242        assert_eq!(text, "*not bold*");
243        assert!(ents.is_empty());
244    }
245
246    #[test]
247    fn markdown_v2_backslash_escape_extended() {
248        let (text, ents) = parse_markdown(r"\>\=\.");
249        assert_eq!(text, ">=.");
250        assert!(ents.is_empty());
251    }
252
253    #[test]
254    fn markdown_v2_nested_bold_italic() {
255        let (text, ents) = parse_markdown("**bold _italic_ end**");
256        assert_eq!(text, "bold italic end");
257        assert_eq!(ents.len(), 2);
258        assert!(
259            ents.iter()
260                .any(|e| matches!(e, tl::enums::MessageEntity::Bold(_)))
261        );
262        assert!(
263            ents.iter()
264                .any(|e| matches!(e, tl::enums::MessageEntity::Italic(_)))
265        );
266    }
267
268    #[test]
269    fn markdown_v2_blockquote_single_line() {
270        let (text, ents) = parse_markdown("> hello");
271        assert_eq!(text, "hello");
272        if let tl::enums::MessageEntity::Blockquote(e) = &ents[0] {
273            assert!(!e.collapsed);
274        } else {
275            panic!("expected blockquote, got {:?}", ents[0]);
276        }
277    }
278
279    #[test]
280    fn markdown_v2_blockquote_multi_line() {
281        let (text, ents) = parse_markdown("> line1\n> line2");
282        assert_eq!(text, "line1\nline2");
283        assert_eq!(ents.len(), 1);
284        assert!(matches!(ents[0], tl::enums::MessageEntity::Blockquote(_)));
285    }
286
287    #[test]
288    fn markdown_v2_expandable_blockquote() {
289        let (text, ents) = parse_markdown("**> secret");
290        assert_eq!(text, "secret");
291        if let tl::enums::MessageEntity::Blockquote(e) = &ents[0] {
292            assert!(
293                e.collapsed,
294                "expandable blockquote should have collapsed=true"
295            );
296        } else {
297            panic!("expected blockquote");
298        }
299    }
300
301    // Markdown V1 (legacy)
302
303    /// V1: `__text__` = Italic (legacy behaviour)
304    #[test]
305    fn markdown_v1_italic_double_underscore() {
306        #[allow(deprecated)]
307        let (text, ents) = parse_markdown_v1("__italic__");
308        assert_eq!(text, "italic");
309        assert!(
310            matches!(ents[0], tl::enums::MessageEntity::Italic(_)),
311            "V1 __ should be Italic"
312        );
313    }
314
315    /// V1: `~~text~~` = Strike
316    #[test]
317    fn markdown_v1_strike_double_tilde() {
318        #[allow(deprecated)]
319        let (text, ents) = parse_markdown_v1("~~strike~~");
320        assert_eq!(text, "strike");
321        assert!(matches!(ents[0], tl::enums::MessageEntity::Strike(_)));
322    }
323
324    // Markdown V2 generator
325
326    #[test]
327    fn generate_markdown_pre() {
328        let entities = vec![tl::enums::MessageEntity::Pre(tl::types::MessageEntityPre {
329            offset: 0,
330            length: 12,
331            language: "rust".into(),
332        })];
333        let md = generate_markdown("fn main() {}", &entities);
334        assert_eq!(md, "```rust\nfn main() {}\n```");
335    }
336
337    #[test]
338    fn generate_markdown_text_url() {
339        let entities = vec![tl::enums::MessageEntity::TextUrl(
340            tl::types::MessageEntityTextUrl {
341                offset: 0,
342                length: 5,
343                url: "https://example.com".into(),
344            },
345        )];
346        let md = generate_markdown("click", &entities);
347        assert_eq!(md, "[click](https://example.com)");
348    }
349
350    #[test]
351    fn generate_markdown_mention() {
352        let entities = vec![tl::enums::MessageEntity::MentionName(
353            tl::types::MessageEntityMentionName {
354                offset: 0,
355                length: 4,
356                user_id: 99,
357            },
358        )];
359        let md = generate_markdown("User", &entities);
360        assert_eq!(md, "[User](tg://user?id=99)");
361    }
362
363    #[test]
364    fn generate_markdown_custom_emoji() {
365        let entities = vec![tl::enums::MessageEntity::CustomEmoji(
366            tl::types::MessageEntityCustomEmoji {
367                offset: 0,
368                length: 2,
369                document_id: 123456,
370            },
371        )];
372        let md = generate_markdown("👍", &entities);
373        assert_eq!(md, "![👍](tg://emoji?id=123456)");
374    }
375
376    #[test]
377    fn generate_markdown_v2_escapes_special_chars() {
378        let (_, empty): (_, Vec<_>) = (String::new(), vec![]);
379        let md = generate_markdown("1 * 2 = 2", &empty);
380        assert_eq!(md, r"1 \* 2 \= 2");
381    }
382
383    #[test]
384    fn generate_markdown_v2_italic_and_underline() {
385        let entities = vec![
386            tl::enums::MessageEntity::Italic(tl::types::MessageEntityItalic {
387                offset: 0,
388                length: 6,
389            }),
390            tl::enums::MessageEntity::Underline(tl::types::MessageEntityUnderline {
391                offset: 7,
392                length: 9,
393            }),
394        ];
395        let md = generate_markdown("italic underline", &entities);
396        assert_eq!(md, "_italic_ __underline__");
397    }
398
399    #[test]
400    fn generate_markdown_v2_strike() {
401        let entities = vec![tl::enums::MessageEntity::Strike(
402            tl::types::MessageEntityStrike {
403                offset: 0,
404                length: 6,
405            },
406        )];
407        let md = generate_markdown("struck", &entities);
408        assert_eq!(md, "~struck~");
409    }
410
411    #[test]
412    fn generate_markdown_v2_blockquote() {
413        let entities = vec![tl::enums::MessageEntity::Blockquote(
414            tl::types::MessageEntityBlockquote {
415                collapsed: false,
416                offset: 0,
417                length: 5,
418            },
419        )];
420        let md = generate_markdown("hello", &entities);
421        assert!(md.starts_with("> "), "expected '> ' prefix, got: {md:?}");
422        assert!(md.contains("hello"));
423    }
424
425    #[test]
426    fn generate_markdown_v2_expandable_blockquote() {
427        let entities = vec![tl::enums::MessageEntity::Blockquote(
428            tl::types::MessageEntityBlockquote {
429                collapsed: true,
430                offset: 0,
431                length: 6,
432            },
433        )];
434        let md = generate_markdown("secret", &entities);
435        assert!(
436            md.starts_with("**> "),
437            "expected '**> ' prefix, got: {md:?}"
438        );
439    }
440
441    #[test]
442    fn markdown_roundtrip_url() {
443        let original = "click";
444        let entities = vec![tl::enums::MessageEntity::TextUrl(
445            tl::types::MessageEntityTextUrl {
446                offset: 0,
447                length: 5,
448                url: "https://example.com".into(),
449            },
450        )];
451        let md = generate_markdown(original, &entities);
452        let (back, ents2) = parse_markdown(&md);
453        assert_eq!(back, original);
454        if let tl::enums::MessageEntity::TextUrl(e) = &ents2[0] {
455            assert_eq!(e.url, "https://example.com");
456        } else {
457            panic!("roundtrip url failed");
458        }
459    }
460
461    // HTML parser
462
463    #[test]
464    fn html_bold_italic() {
465        let (text, ents) = parse_html("<b>bold</b> and <i>italic</i>");
466        assert_eq!(text, "bold and italic");
467        assert_eq!(ents.len(), 2);
468    }
469
470    #[test]
471    fn html_strong_em_aliases() {
472        let (text, ents) = parse_html("<strong>bold</strong> <em>italic</em>");
473        assert_eq!(text, "bold italic");
474        assert!(
475            ents.iter()
476                .any(|e| matches!(e, tl::enums::MessageEntity::Bold(_)))
477        );
478        assert!(
479            ents.iter()
480                .any(|e| matches!(e, tl::enums::MessageEntity::Italic(_)))
481        );
482    }
483
484    #[test]
485    fn html_ins_alias_for_underline() {
486        let (text, ents) = parse_html("<ins>underline</ins>");
487        assert_eq!(text, "underline");
488        assert!(matches!(ents[0], tl::enums::MessageEntity::Underline(_)));
489    }
490
491    #[test]
492    fn html_span_tg_spoiler() {
493        let (text, ents) = parse_html("<span class=\"tg-spoiler\">hidden</span>");
494        assert_eq!(text, "hidden");
495        assert!(matches!(ents[0], tl::enums::MessageEntity::Spoiler(_)));
496    }
497
498    #[test]
499    fn html_blockquote() {
500        let (text, ents) = parse_html("<blockquote>quoted</blockquote>");
501        assert_eq!(text, "quoted");
502        if let tl::enums::MessageEntity::Blockquote(e) = &ents[0] {
503            assert!(!e.collapsed);
504        } else {
505            panic!("expected blockquote");
506        }
507    }
508
509    #[test]
510    fn html_blockquote_expandable() {
511        let (text, ents) = parse_html("<blockquote expandable>secret</blockquote>");
512        assert_eq!(text, "secret");
513        if let tl::enums::MessageEntity::Blockquote(e) = &ents[0] {
514            assert!(
515                e.collapsed,
516                "expandable blockquote should have collapsed=true"
517            );
518        } else {
519            panic!("expected blockquote");
520        }
521    }
522
523    #[test]
524    fn html_tg_time() {
525        let (text, ents) =
526            parse_html("<tg-time unix=\"1700000000\" format=\"Dt\">Nov 14</tg-time>");
527        assert_eq!(text, "Nov 14");
528        if let tl::enums::MessageEntity::FormattedDate(e) = &ents[0] {
529            assert_eq!(e.date, 1700000000);
530            assert!(e.long_date);
531            assert!(e.short_time);
532        } else {
533            panic!("expected FormattedDate, got {:?}", ents[0]);
534        }
535    }
536
537    #[test]
538    fn html_pre_with_language() {
539        let (text, ents) =
540            parse_html("<pre><code class=\"language-rust\">fn main() {}</code></pre>");
541        assert_eq!(text, "fn main() {}");
542        assert_eq!(ents.len(), 1, "should be exactly one Pre entity");
543        if let tl::enums::MessageEntity::Pre(p) = &ents[0] {
544            assert_eq!(p.language, "rust");
545        } else {
546            panic!("expected pre");
547        }
548    }
549
550    #[test]
551    fn html_link() {
552        let (text, ents) = parse_html("<a href=\"https://example.com\">click</a>");
553        assert_eq!(text, "click");
554        if let tl::enums::MessageEntity::TextUrl(e) = &ents[0] {
555            assert_eq!(e.url, "https://example.com");
556        } else {
557            panic!("expected text url");
558        }
559    }
560
561    #[cfg(not(feature = "html5ever"))]
562    #[test]
563    fn html_entities_decoded() {
564        let (text, _) = parse_html("A &amp; B &lt;3&gt;");
565        assert_eq!(text, "A & B <3>");
566    }
567
568    // HTML generator
569
570    #[test]
571    fn generate_html_roundtrip() {
572        let original = "Hello world";
573        let entities = vec![tl::enums::MessageEntity::Bold(
574            tl::types::MessageEntityBold {
575                offset: 0,
576                length: 5,
577            },
578        )];
579        let html = generate_html(original, &entities);
580        assert_eq!(html, "<b>Hello</b> world");
581        let (back, ents2) = parse_html(&html);
582        assert_eq!(back, original);
583        assert_eq!(ents2.len(), 1);
584    }
585
586    #[test]
587    fn generate_html_blockquote() {
588        let entities = vec![tl::enums::MessageEntity::Blockquote(
589            tl::types::MessageEntityBlockquote {
590                collapsed: false,
591                offset: 0,
592                length: 6,
593            },
594        )];
595        let html = generate_html("quoted", &entities);
596        assert_eq!(html, "<blockquote>quoted</blockquote>");
597    }
598
599    #[test]
600    fn generate_html_expandable_blockquote() {
601        let entities = vec![tl::enums::MessageEntity::Blockquote(
602            tl::types::MessageEntityBlockquote {
603                collapsed: true,
604                offset: 0,
605                length: 6,
606            },
607        )];
608        let html = generate_html("secret", &entities);
609        assert_eq!(html, "<blockquote expandable>secret</blockquote>");
610    }
611
612    #[test]
613    fn generate_html_formatted_date() {
614        let entities = vec![tl::enums::MessageEntity::FormattedDate(
615            tl::types::MessageEntityFormattedDate {
616                relative: false,
617                short_time: true,
618                long_time: false,
619                short_date: false,
620                long_date: true,
621                day_of_week: false,
622                offset: 0,
623                length: 6,
624                date: 1700000000,
625            },
626        )];
627        let html = generate_html("Nov 14", &entities);
628        assert!(html.contains("tg-time"), "expected tg-time in: {html}");
629        assert!(html.contains("1700000000"));
630    }
631
632    #[test]
633    fn html_pre_with_language_roundtrip() {
634        let original = "fn main() {}";
635        let entities = vec![tl::enums::MessageEntity::Pre(tl::types::MessageEntityPre {
636            offset: 0,
637            length: 12,
638            language: "rust".into(),
639        })];
640        let html = generate_html(original, &entities);
641        let (back, ents2) = parse_html(&html);
642        assert_eq!(back, original);
643        if let tl::enums::MessageEntity::Pre(p) = &ents2[0] {
644            assert_eq!(p.language, "rust");
645        } else {
646            panic!("roundtrip pre language failed");
647        }
648    }
649
650    // HTML parse: remaining tag aliases
651
652    #[test]
653    fn html_u_underline() {
654        let (text, ents) = parse_html("<u>under</u>");
655        assert_eq!(text, "under");
656        assert!(matches!(ents[0], tl::enums::MessageEntity::Underline(_)));
657    }
658
659    #[test]
660    fn html_s_strike() {
661        let (text, ents) = parse_html("<s>gone</s>");
662        assert_eq!(text, "gone");
663        assert!(matches!(ents[0], tl::enums::MessageEntity::Strike(_)));
664    }
665
666    #[test]
667    fn html_del_strike_alias() {
668        let (text, ents) = parse_html("<del>gone</del>");
669        assert_eq!(text, "gone");
670        assert!(matches!(ents[0], tl::enums::MessageEntity::Strike(_)));
671    }
672
673    #[test]
674    fn html_strike_tag_alias() {
675        let (text, ents) = parse_html("<strike>gone</strike>");
676        assert_eq!(text, "gone");
677        assert!(matches!(ents[0], tl::enums::MessageEntity::Strike(_)));
678    }
679
680    #[test]
681    fn html_tg_spoiler_tag() {
682        let (text, ents) = parse_html("<tg-spoiler>secret</tg-spoiler>");
683        assert_eq!(text, "secret");
684        assert!(matches!(ents[0], tl::enums::MessageEntity::Spoiler(_)));
685    }
686
687    #[test]
688    fn html_tg_emoji() {
689        let (text, ents) = parse_html("<tg-emoji emoji-id=\"9876\">X</tg-emoji>");
690        assert_eq!(text, "X");
691        if let tl::enums::MessageEntity::CustomEmoji(e) = &ents[0] {
692            assert_eq!(e.document_id, 9876);
693            assert_eq!(e.offset, 0);
694            assert_eq!(e.length, 1);
695        } else {
696            panic!("expected CustomEmoji, got {:?}", ents[0]);
697        }
698    }
699
700    #[test]
701    fn html_mention_name() {
702        let (text, ents) = parse_html("<a href=\"tg://user?id=777\">Alice</a>");
703        assert_eq!(text, "Alice");
704        if let tl::enums::MessageEntity::MentionName(e) = &ents[0] {
705            assert_eq!(e.user_id, 777);
706        } else {
707            panic!("expected MentionName, got {:?}", ents[0]);
708        }
709    }
710
711    #[test]
712    fn html_inline_code() {
713        let (text, ents) = parse_html("call <code>foo()</code> now");
714        assert_eq!(text, "call foo() now");
715        if let tl::enums::MessageEntity::Code(e) = &ents[0] {
716            assert_eq!(e.offset, 5);
717            assert_eq!(e.length, 5);
718        } else {
719            panic!("expected Code");
720        }
721    }
722
723    // HTML parse: offset correctness
724
725    #[test]
726    fn html_offset_mid_string() {
727        // "Hello bold end" -> bold at offset 6, length 4
728        let (text, ents) = parse_html("Hello <b>bold</b> end");
729        assert_eq!(text, "Hello bold end");
730        if let tl::enums::MessageEntity::Bold(e) = &ents[0] {
731            assert_eq!(e.offset, 6);
732            assert_eq!(e.length, 4);
733        } else {
734            panic!("expected Bold");
735        }
736    }
737
738    #[test]
739    fn html_nested_bold_italic() {
740        let (text, ents) = parse_html("<b>bold <i>both</i> bold</b>");
741        assert_eq!(text, "bold both bold");
742        assert_eq!(ents.len(), 2);
743        let bold = ents
744            .iter()
745            .find(|e| matches!(e, tl::enums::MessageEntity::Bold(_)))
746            .unwrap();
747        if let tl::enums::MessageEntity::Bold(e) = bold {
748            assert_eq!(e.offset, 0);
749            assert_eq!(e.length, 14); // whole string
750        }
751        let italic = ents
752            .iter()
753            .find(|e| matches!(e, tl::enums::MessageEntity::Italic(_)))
754            .unwrap();
755        if let tl::enums::MessageEntity::Italic(e) = italic {
756            assert_eq!(e.offset, 5);
757            assert_eq!(e.length, 4); // "both"
758        }
759    }
760
761    // HTML generate: all entity types
762
763    #[test]
764    fn generate_html_italic() {
765        let entities = vec![tl::enums::MessageEntity::Italic(
766            tl::types::MessageEntityItalic {
767                offset: 0,
768                length: 4,
769            },
770        )];
771        assert_eq!(generate_html("test", &entities), "<i>test</i>");
772    }
773
774    #[test]
775    fn generate_html_underline() {
776        let entities = vec![tl::enums::MessageEntity::Underline(
777            tl::types::MessageEntityUnderline {
778                offset: 0,
779                length: 4,
780            },
781        )];
782        assert_eq!(generate_html("test", &entities), "<u>test</u>");
783    }
784
785    #[test]
786    fn generate_html_strike() {
787        let entities = vec![tl::enums::MessageEntity::Strike(
788            tl::types::MessageEntityStrike {
789                offset: 0,
790                length: 4,
791            },
792        )];
793        assert_eq!(generate_html("test", &entities), "<s>test</s>");
794    }
795
796    #[test]
797    fn generate_html_spoiler() {
798        let entities = vec![tl::enums::MessageEntity::Spoiler(
799            tl::types::MessageEntitySpoiler {
800                offset: 0,
801                length: 6,
802            },
803        )];
804        assert_eq!(
805            generate_html("secret", &entities),
806            "<tg-spoiler>secret</tg-spoiler>"
807        );
808    }
809
810    #[test]
811    fn generate_html_inline_code() {
812        let entities = vec![tl::enums::MessageEntity::Code(
813            tl::types::MessageEntityCode {
814                offset: 0,
815                length: 3,
816            },
817        )];
818        assert_eq!(generate_html("foo", &entities), "<code>foo</code>");
819    }
820
821    #[test]
822    fn generate_html_pre_no_lang() {
823        let entities = vec![tl::enums::MessageEntity::Pre(tl::types::MessageEntityPre {
824            offset: 0,
825            length: 4,
826            language: String::new(),
827        })];
828        let html = generate_html("code", &entities);
829        assert_eq!(html, "<pre><code>code</code></pre>");
830    }
831
832    #[test]
833    fn generate_html_custom_emoji() {
834        let entities = vec![tl::enums::MessageEntity::CustomEmoji(
835            tl::types::MessageEntityCustomEmoji {
836                offset: 0,
837                length: 2,
838                document_id: 555,
839            },
840        )];
841        assert_eq!(
842            generate_html("ok", &entities),
843            "<tg-emoji emoji-id=\"555\">ok</tg-emoji>"
844        );
845    }
846
847    #[test]
848    fn generate_html_mention_name() {
849        let entities = vec![tl::enums::MessageEntity::MentionName(
850            tl::types::MessageEntityMentionName {
851                offset: 0,
852                length: 3,
853                user_id: 42,
854            },
855        )];
856        assert_eq!(
857            generate_html("Bob", &entities),
858            "<a href=\"tg://user?id=42\">Bob</a>"
859        );
860    }
861
862    // HTML generate: escaping in plain text
863
864    #[test]
865    fn generate_html_escapes_special_chars() {
866        let (_, empty): (_, Vec<_>) = (String::new(), vec![]);
867        let html = generate_html("a & b < c > d \"e\"", &empty);
868        assert_eq!(html, "a &amp; b &lt; c &gt; d &quot;e&quot;");
869    }
870
871    // UTF-16 offset correctness (multibyte chars)
872
873    #[test]
874    fn utf16_offset_emoji_before_entity() {
875        // "👍 bold": emoji is 2 UTF-16 code units, bold starts at offset 3 (2 + space)
876        let (text, ents) = parse_markdown("👍 **bold**");
877        assert_eq!(text, "👍 bold");
878        if let tl::enums::MessageEntity::Bold(e) = &ents[0] {
879            assert_eq!(e.offset, 3); // 2 (emoji) + 1 (space)
880            assert_eq!(e.length, 4);
881        } else {
882            panic!("expected Bold");
883        }
884    }
885
886    #[test]
887    fn utf16_offset_emoji_bold_in_html() {
888        let (text, ents) = parse_html("👍 <b>bold</b>");
889        assert_eq!(text, "👍 bold");
890        if let tl::enums::MessageEntity::Bold(e) = &ents[0] {
891            assert_eq!(e.offset, 3);
892            assert_eq!(e.length, 4);
893        } else {
894            panic!("expected Bold");
895        }
896    }
897
898    #[test]
899    fn utf16_offset_cjk_char() {
900        // CJK char is 1 UTF-16 unit (U+4E2D = 20013), bold starts at offset 1
901        let (text, ents) = parse_markdown("中**bold**");
902        assert_eq!(text, "中bold");
903        if let tl::enums::MessageEntity::Bold(e) = &ents[0] {
904            assert_eq!(e.offset, 1);
905            assert_eq!(e.length, 4);
906        } else {
907            panic!("expected Bold");
908        }
909    }
910
911    #[test]
912    fn utf16_offset_surrogate_pair_inside_entity() {
913        // Bold text that itself contains an emoji: "👍" is 2 UTF-16 units
914        let (text, ents) = parse_markdown("**👍**");
915        assert_eq!(text, "👍");
916        if let tl::enums::MessageEntity::Bold(e) = &ents[0] {
917            assert_eq!(e.offset, 0);
918            assert_eq!(e.length, 2); // emoji = 2 UTF-16 units
919        } else {
920            panic!("expected Bold");
921        }
922    }
923
924    // Markdown generate: missing types
925
926    #[test]
927    fn generate_markdown_v2_spoiler() {
928        let entities = vec![tl::enums::MessageEntity::Spoiler(
929            tl::types::MessageEntitySpoiler {
930                offset: 0,
931                length: 6,
932            },
933        )];
934        assert_eq!(generate_markdown("secret", &entities), "||secret||");
935    }
936
937    #[test]
938    fn generate_markdown_v2_bold() {
939        let entities = vec![tl::enums::MessageEntity::Bold(
940            tl::types::MessageEntityBold {
941                offset: 0,
942                length: 4,
943            },
944        )];
945        assert_eq!(generate_markdown("bold", &entities), "*bold*");
946    }
947
948    // Markdown roundtrip: all inline types
949
950    fn roundtrip_md(text: &str, ent: tl::enums::MessageEntity) {
951        let md = generate_markdown(text, &[ent]);
952        let (back, ents2) = parse_markdown(&md);
953        assert_eq!(back, text, "roundtrip text mismatch for: {md:?}");
954        assert_eq!(ents2.len(), 1, "roundtrip entity count wrong for: {md:?}");
955    }
956
957    #[test]
958    fn markdown_roundtrip_bold() {
959        roundtrip_md(
960            "x",
961            tl::enums::MessageEntity::Bold(tl::types::MessageEntityBold {
962                offset: 0,
963                length: 1,
964            }),
965        );
966    }
967    #[test]
968    fn markdown_roundtrip_italic() {
969        roundtrip_md(
970            "x",
971            tl::enums::MessageEntity::Italic(tl::types::MessageEntityItalic {
972                offset: 0,
973                length: 1,
974            }),
975        );
976    }
977    #[test]
978    fn markdown_roundtrip_underline() {
979        roundtrip_md(
980            "x",
981            tl::enums::MessageEntity::Underline(tl::types::MessageEntityUnderline {
982                offset: 0,
983                length: 1,
984            }),
985        );
986    }
987    #[test]
988    fn markdown_roundtrip_strike() {
989        roundtrip_md(
990            "x",
991            tl::enums::MessageEntity::Strike(tl::types::MessageEntityStrike {
992                offset: 0,
993                length: 1,
994            }),
995        );
996    }
997    #[test]
998    fn markdown_roundtrip_spoiler() {
999        roundtrip_md(
1000            "x",
1001            tl::enums::MessageEntity::Spoiler(tl::types::MessageEntitySpoiler {
1002                offset: 0,
1003                length: 1,
1004            }),
1005        );
1006    }
1007    #[test]
1008    fn markdown_roundtrip_code() {
1009        roundtrip_md(
1010            "x",
1011            tl::enums::MessageEntity::Code(tl::types::MessageEntityCode {
1012                offset: 0,
1013                length: 1,
1014            }),
1015        );
1016    }
1017
1018    // HTML roundtrip: all entity types
1019
1020    fn roundtrip_html(text: &str, ent: tl::enums::MessageEntity) {
1021        let html = generate_html(text, &[ent]);
1022        let (back, ents2) = parse_html(&html);
1023        assert_eq!(back, text, "roundtrip text mismatch for: {html:?}");
1024        assert_eq!(ents2.len(), 1, "roundtrip entity count wrong for: {html:?}");
1025    }
1026
1027    #[test]
1028    fn html_roundtrip_bold() {
1029        roundtrip_html(
1030            "x",
1031            tl::enums::MessageEntity::Bold(tl::types::MessageEntityBold {
1032                offset: 0,
1033                length: 1,
1034            }),
1035        );
1036    }
1037    #[test]
1038    fn html_roundtrip_italic() {
1039        roundtrip_html(
1040            "x",
1041            tl::enums::MessageEntity::Italic(tl::types::MessageEntityItalic {
1042                offset: 0,
1043                length: 1,
1044            }),
1045        );
1046    }
1047    #[test]
1048    fn html_roundtrip_underline() {
1049        roundtrip_html(
1050            "x",
1051            tl::enums::MessageEntity::Underline(tl::types::MessageEntityUnderline {
1052                offset: 0,
1053                length: 1,
1054            }),
1055        );
1056    }
1057    #[test]
1058    fn html_roundtrip_strike() {
1059        roundtrip_html(
1060            "x",
1061            tl::enums::MessageEntity::Strike(tl::types::MessageEntityStrike {
1062                offset: 0,
1063                length: 1,
1064            }),
1065        );
1066    }
1067    #[test]
1068    fn html_roundtrip_spoiler() {
1069        roundtrip_html(
1070            "x",
1071            tl::enums::MessageEntity::Spoiler(tl::types::MessageEntitySpoiler {
1072                offset: 0,
1073                length: 1,
1074            }),
1075        );
1076    }
1077    #[test]
1078    fn html_roundtrip_code() {
1079        roundtrip_html(
1080            "x",
1081            tl::enums::MessageEntity::Code(tl::types::MessageEntityCode {
1082                offset: 0,
1083                length: 1,
1084            }),
1085        );
1086    }
1087    #[test]
1088    fn html_roundtrip_blockquote() {
1089        roundtrip_html(
1090            "x",
1091            tl::enums::MessageEntity::Blockquote(tl::types::MessageEntityBlockquote {
1092                collapsed: false,
1093                offset: 0,
1094                length: 1,
1095            }),
1096        );
1097    }
1098    #[test]
1099    fn html_roundtrip_blockquote_expandable() {
1100        roundtrip_html(
1101            "x",
1102            tl::enums::MessageEntity::Blockquote(tl::types::MessageEntityBlockquote {
1103                collapsed: true,
1104                offset: 0,
1105                length: 1,
1106            }),
1107        );
1108    }
1109    #[test]
1110    fn html_roundtrip_custom_emoji() {
1111        let html = generate_html(
1112            "ok",
1113            &[tl::enums::MessageEntity::CustomEmoji(
1114                tl::types::MessageEntityCustomEmoji {
1115                    offset: 0,
1116                    length: 2,
1117                    document_id: 999,
1118                },
1119            )],
1120        );
1121        let (back, ents2) = parse_html(&html);
1122        assert_eq!(back, "ok");
1123        if let tl::enums::MessageEntity::CustomEmoji(e) = &ents2[0] {
1124            assert_eq!(e.document_id, 999);
1125        } else {
1126            panic!("expected CustomEmoji");
1127        }
1128    }
1129    #[test]
1130    fn html_roundtrip_mention_name() {
1131        let html = generate_html(
1132            "Bob",
1133            &[tl::enums::MessageEntity::MentionName(
1134                tl::types::MessageEntityMentionName {
1135                    offset: 0,
1136                    length: 3,
1137                    user_id: 42,
1138                },
1139            )],
1140        );
1141        let (back, ents2) = parse_html(&html);
1142        assert_eq!(back, "Bob");
1143        if let tl::enums::MessageEntity::MentionName(e) = &ents2[0] {
1144            assert_eq!(e.user_id, 42);
1145        } else {
1146            panic!("expected MentionName");
1147        }
1148    }
1149
1150    // Rich message parsers
1151
1152    #[test]
1153    fn rich_md_heading1() {
1154        let blocks = parse_rich_markdown("# Hello");
1155        assert!(matches!(blocks[0], tl::enums::PageBlock::Heading1(_)));
1156        if let tl::enums::PageBlock::Heading1(h) = &blocks[0] {
1157            assert!(matches!(h.text, tl::enums::RichText::TextPlain(_)));
1158        }
1159    }
1160
1161    #[test]
1162    fn rich_md_heading6() {
1163        let blocks = parse_rich_markdown("###### Deep");
1164        assert!(matches!(blocks[0], tl::enums::PageBlock::Heading6(_)));
1165    }
1166
1167    #[test]
1168    fn rich_md_paragraph() {
1169        let blocks = parse_rich_markdown("Hello world");
1170        assert!(matches!(blocks[0], tl::enums::PageBlock::Paragraph(_)));
1171    }
1172
1173    #[test]
1174    fn rich_md_code_block() {
1175        let blocks = parse_rich_markdown("```python\nprint('hi')\n```");
1176        if let tl::enums::PageBlock::Preformatted(p) = &blocks[0] {
1177            assert_eq!(p.language, "python");
1178            assert!(matches!(p.text, tl::enums::RichText::TextPlain(_)));
1179        } else {
1180            panic!("expected Preformatted");
1181        }
1182    }
1183
1184    #[test]
1185    fn rich_md_math_block_backtick() {
1186        let blocks = parse_rich_markdown("```math\nE = mc^2\n```");
1187        assert!(matches!(blocks[0], tl::enums::PageBlock::Math(_)));
1188        if let tl::enums::PageBlock::Math(m) = &blocks[0] {
1189            assert_eq!(m.source, "E = mc^2");
1190        }
1191    }
1192
1193    #[test]
1194    fn rich_md_divider() {
1195        let blocks = parse_rich_markdown("---");
1196        assert!(matches!(blocks[0], tl::enums::PageBlock::Divider));
1197    }
1198
1199    #[test]
1200    fn rich_md_unordered_list() {
1201        let blocks = parse_rich_markdown("- item 1\n- item 2");
1202        assert!(matches!(blocks[0], tl::enums::PageBlock::List(_)));
1203        if let tl::enums::PageBlock::List(l) = &blocks[0] {
1204            assert_eq!(l.items.len(), 2);
1205        }
1206    }
1207
1208    #[test]
1209    fn rich_md_task_list() {
1210        let blocks = parse_rich_markdown("- [ ] todo\n- [x] done");
1211        if let tl::enums::PageBlock::List(l) = &blocks[0] {
1212            assert!(
1213                matches!(l.items[0], tl::enums::PageListItem::Text(ref t) if t.checkbox && !t.checked)
1214            );
1215            assert!(
1216                matches!(l.items[1], tl::enums::PageListItem::Text(ref t) if t.checkbox && t.checked)
1217            );
1218        } else {
1219            panic!("expected List");
1220        }
1221    }
1222
1223    #[test]
1224    fn rich_md_ordered_list() {
1225        let blocks = parse_rich_markdown("1. first\n2. second");
1226        assert!(matches!(blocks[0], tl::enums::PageBlock::OrderedList(_)));
1227        if let tl::enums::PageBlock::OrderedList(l) = &blocks[0] {
1228            assert_eq!(l.items.len(), 2);
1229        }
1230    }
1231
1232    #[test]
1233    fn rich_md_blockquote() {
1234        let blocks = parse_rich_markdown(">Hello\n>World");
1235        assert!(matches!(blocks[0], tl::enums::PageBlock::Blockquote(_)));
1236    }
1237
1238    #[test]
1239    fn rich_md_table() {
1240        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
1241        let blocks = parse_rich_markdown(md);
1242        assert!(matches!(blocks[0], tl::enums::PageBlock::Table(_)));
1243        if let tl::enums::PageBlock::Table(t) = &blocks[0] {
1244            assert_eq!(t.rows.len(), 2); // header + data
1245        }
1246    }
1247
1248    #[test]
1249    fn rich_md_inline_bold() {
1250        let rt = parse_rich_inline_md("**bold**");
1251        assert!(matches!(rt, tl::enums::RichText::TextBold(_)));
1252    }
1253
1254    #[test]
1255    fn rich_md_inline_italic() {
1256        let rt = parse_rich_inline_md("*italic*");
1257        assert!(matches!(rt, tl::enums::RichText::TextItalic(_)));
1258    }
1259
1260    #[test]
1261    fn rich_md_inline_code() {
1262        let rt = parse_rich_inline_md("`code`");
1263        assert!(matches!(rt, tl::enums::RichText::TextFixed(_)));
1264    }
1265
1266    #[test]
1267    fn rich_md_inline_mark() {
1268        let rt = parse_rich_inline_md("==marked==");
1269        assert!(matches!(rt, tl::enums::RichText::TextMarked(_)));
1270    }
1271
1272    #[test]
1273    fn rich_md_inline_spoiler() {
1274        let rt = parse_rich_inline_md("||secret||");
1275        assert!(matches!(rt, tl::enums::RichText::TextSpoiler(_)));
1276    }
1277
1278    #[test]
1279    fn rich_md_inline_strike() {
1280        let rt = parse_rich_inline_md("~~strike~~");
1281        assert!(matches!(rt, tl::enums::RichText::TextStrike(_)));
1282    }
1283
1284    #[test]
1285    fn rich_md_inline_url() {
1286        let rt = parse_rich_inline_md("[click](https://t.me/)");
1287        assert!(matches!(rt, tl::enums::RichText::TextUrl(_)));
1288    }
1289
1290    #[test]
1291    fn rich_md_inline_mention() {
1292        let rt = parse_rich_inline_md("[User](tg://user?id=42)");
1293        assert!(matches!(rt, tl::enums::RichText::TextMentionName(_)));
1294        if let tl::enums::RichText::TextMentionName(m) = rt {
1295            assert_eq!(m.user_id, 42);
1296        }
1297    }
1298
1299    #[test]
1300    fn rich_md_inline_email_link() {
1301        let rt = parse_rich_inline_md("[mail](mailto:user@example.com)");
1302        assert!(matches!(rt, tl::enums::RichText::TextEmail(_)));
1303    }
1304
1305    #[test]
1306    fn rich_md_inline_phone_link() {
1307        let rt = parse_rich_inline_md("[call](tel:+123456789)");
1308        assert!(matches!(rt, tl::enums::RichText::TextPhone(_)));
1309    }
1310
1311    #[test]
1312    fn rich_md_inline_custom_emoji() {
1313        let rt = parse_rich_inline_md("![👍](tg://emoji?id=5368324170671202286)");
1314        assert!(matches!(rt, tl::enums::RichText::TextCustomEmoji(_)));
1315        if let tl::enums::RichText::TextCustomEmoji(e) = rt {
1316            assert_eq!(e.document_id, 5368324170671202286);
1317        }
1318    }
1319
1320    #[test]
1321    fn rich_md_inline_math() {
1322        let rt = parse_rich_inline_md("$x^2 + y^2$");
1323        assert!(matches!(rt, tl::enums::RichText::TextMath(_)));
1324        if let tl::enums::RichText::TextMath(m) = rt {
1325            assert_eq!(m.source, "x^2 + y^2");
1326        }
1327    }
1328
1329    #[test]
1330    fn rich_md_inline_html_underline() {
1331        let rt = parse_rich_inline_md("<u>underlined</u>");
1332        assert!(matches!(rt, tl::enums::RichText::TextUnderline(_)));
1333    }
1334
1335    #[test]
1336    fn rich_md_inline_html_sub() {
1337        let rt = parse_rich_inline_md("<sub>sub</sub>");
1338        assert!(matches!(rt, tl::enums::RichText::TextSubscript(_)));
1339    }
1340
1341    #[test]
1342    fn rich_md_inline_html_sup() {
1343        let rt = parse_rich_inline_md("<sup>sup</sup>");
1344        assert!(matches!(rt, tl::enums::RichText::TextSuperscript(_)));
1345    }
1346
1347    #[test]
1348    fn rich_md_inline_tg_spoiler_html() {
1349        let rt = parse_rich_inline_md("<tg-spoiler>hidden</tg-spoiler>");
1350        assert!(matches!(rt, tl::enums::RichText::TextSpoiler(_)));
1351    }
1352
1353    #[test]
1354    fn rich_html_heading() {
1355        let blocks = parse_rich_html("<h2>World</h2>");
1356        assert!(matches!(blocks[0], tl::enums::PageBlock::Heading2(_)));
1357    }
1358
1359    #[test]
1360    fn rich_html_paragraph() {
1361        let blocks = parse_rich_html("<p>Hello</p>");
1362        assert!(matches!(blocks[0], tl::enums::PageBlock::Paragraph(_)));
1363    }
1364
1365    #[test]
1366    fn rich_html_preformatted() {
1367        let blocks = parse_rich_html("<pre><code class=\"language-rust\">fn main(){}</code></pre>");
1368        if let tl::enums::PageBlock::Preformatted(p) = &blocks[0] {
1369            assert_eq!(p.language, "rust");
1370        } else {
1371            panic!(
1372                "expected Preformatted, got {:?}",
1373                blocks.get(0).map(|_| "block")
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn rich_html_blockquote() {
1380        let blocks = parse_rich_html("<blockquote>Quote<cite>Author</cite></blockquote>");
1381        assert!(matches!(blocks[0], tl::enums::PageBlock::Blockquote(_)));
1382        if let tl::enums::PageBlock::Blockquote(b) = &blocks[0] {
1383            assert!(!matches!(b.caption, tl::enums::RichText::TextEmpty));
1384        }
1385    }
1386
1387    #[test]
1388    fn rich_html_aside_pullquote() {
1389        let blocks = parse_rich_html("<aside>Pull quote<cite>The Author</cite></aside>");
1390        assert!(matches!(blocks[0], tl::enums::PageBlock::Pullquote(_)));
1391    }
1392
1393    #[test]
1394    fn rich_html_hr_divider() {
1395        let blocks = parse_rich_html("<hr/>");
1396        assert!(matches!(blocks[0], tl::enums::PageBlock::Divider));
1397    }
1398
1399    #[test]
1400    fn rich_html_unordered_list() {
1401        let blocks = parse_rich_html("<ul><li>a</li><li>b</li></ul>");
1402        assert!(matches!(blocks[0], tl::enums::PageBlock::List(_)));
1403        if let tl::enums::PageBlock::List(l) = &blocks[0] {
1404            assert_eq!(l.items.len(), 2);
1405        }
1406    }
1407
1408    #[test]
1409    fn rich_html_ordered_list() {
1410        let blocks = parse_rich_html("<ol><li>first</li><li>second</li></ol>");
1411        assert!(matches!(blocks[0], tl::enums::PageBlock::OrderedList(_)));
1412    }
1413
1414    #[test]
1415    fn rich_html_table() {
1416        let blocks = parse_rich_html(
1417            "<table><tr><th>H1</th><th>H2</th></tr><tr><td>v1</td><td>v2</td></tr></table>",
1418        );
1419        assert!(matches!(blocks[0], tl::enums::PageBlock::Table(_)));
1420        if let tl::enums::PageBlock::Table(t) = &blocks[0] {
1421            assert_eq!(t.rows.len(), 2);
1422        }
1423    }
1424
1425    #[test]
1426    fn rich_html_details() {
1427        let blocks = parse_rich_html("<details open><summary>Title</summary>Content</details>");
1428        assert!(matches!(blocks[0], tl::enums::PageBlock::Details(_)));
1429        if let tl::enums::PageBlock::Details(d) = &blocks[0] {
1430            assert!(d.open);
1431        }
1432    }
1433
1434    #[test]
1435    fn rich_html_map() {
1436        let blocks = parse_rich_html("<tg-map lat=\"41.9\" long=\"12.5\" zoom=\"14\"/>");
1437        assert!(matches!(blocks[0], tl::enums::PageBlock::Map(_)));
1438        if let tl::enums::PageBlock::Map(m) = &blocks[0] {
1439            assert_eq!(m.zoom, 14);
1440        }
1441    }
1442
1443    #[test]
1444    fn rich_html_math_block() {
1445        let blocks = parse_rich_html("<tg-math-block>E = mc^2</tg-math-block>");
1446        assert!(matches!(blocks[0], tl::enums::PageBlock::Math(_)));
1447        if let tl::enums::PageBlock::Math(m) = &blocks[0] {
1448            assert_eq!(m.source, "E = mc^2");
1449        }
1450    }
1451
1452    #[test]
1453    fn rich_html_inline_bold() {
1454        let rt = parse_rich_html_inline("<b>bold</b>");
1455        assert!(matches!(rt, tl::enums::RichText::TextBold(_)));
1456    }
1457
1458    #[test]
1459    fn rich_html_inline_spoiler() {
1460        let rt = parse_rich_html_inline("<tg-spoiler>secret</tg-spoiler>");
1461        assert!(matches!(rt, tl::enums::RichText::TextSpoiler(_)));
1462    }
1463
1464    #[test]
1465    fn rich_html_inline_custom_emoji() {
1466        let rt = parse_rich_html_inline("<tg-emoji emoji-id=\"999\">👍</tg-emoji>");
1467        assert!(matches!(rt, tl::enums::RichText::TextCustomEmoji(_)));
1468        if let tl::enums::RichText::TextCustomEmoji(e) = rt {
1469            assert_eq!(e.document_id, 999);
1470        }
1471    }
1472
1473    #[test]
1474    fn rich_html_inline_tg_time() {
1475        let rt = parse_rich_html_inline(
1476            "<tg-time unix=\"1647531900\" format=\"wDT\">22:45 tomorrow</tg-time>",
1477        );
1478        assert!(matches!(rt, tl::enums::RichText::TextDate(_)));
1479        if let tl::enums::RichText::TextDate(d) = rt {
1480            assert_eq!(d.date, 1647531900);
1481            assert!(d.day_of_week);
1482        }
1483    }
1484
1485    #[test]
1486    fn rich_html_photo_block() {
1487        let blocks = parse_rich_html("<img src=\"https://telegram.org/example/photo.jpg\"/>");
1488        assert!(matches!(blocks[0], tl::enums::PageBlock::Photo(_)));
1489    }
1490
1491    #[test]
1492    fn rich_html_video_block() {
1493        let blocks =
1494            parse_rich_html("<video src=\"https://telegram.org/example/video.mp4\"></video>");
1495        assert!(matches!(blocks[0], tl::enums::PageBlock::Video(_)));
1496    }
1497
1498    #[test]
1499    fn rich_html_audio_block() {
1500        let blocks =
1501            parse_rich_html("<audio src=\"https://telegram.org/example/audio.mp3\"></audio>");
1502        assert!(matches!(blocks[0], tl::enums::PageBlock::Audio(_)));
1503    }
1504
1505    #[test]
1506    fn rich_html_collage() {
1507        let blocks = parse_rich_html(
1508            "<tg-collage><img src=\"https://telegram.org/example/photo.jpg\"/><video src=\"https://telegram.org/example/video.mp4\"/></tg-collage>",
1509        );
1510        assert!(matches!(blocks[0], tl::enums::PageBlock::Collage(_)));
1511        if let tl::enums::PageBlock::Collage(c) = &blocks[0] {
1512            assert_eq!(c.items.len(), 2);
1513        }
1514    }
1515
1516    #[test]
1517    fn rich_html_slideshow() {
1518        let blocks = parse_rich_html(
1519            "<tg-slideshow><img src=\"https://telegram.org/example/photo.jpg\"/><video src=\"https://telegram.org/example/video.mp4\"/></tg-slideshow>",
1520        );
1521        assert!(matches!(blocks[0], tl::enums::PageBlock::Slideshow(_)));
1522    }
1523
1524    #[test]
1525    fn rich_html_footer() {
1526        let blocks = parse_rich_html("<footer>Footer text</footer>");
1527        assert!(matches!(blocks[0], tl::enums::PageBlock::Footer(_)));
1528    }
1529
1530    #[test]
1531    fn rich_html_anchor_block() {
1532        let blocks = parse_rich_html("<a name=\"chapter-1\"></a>");
1533        assert!(matches!(blocks[0], tl::enums::PageBlock::Anchor(_)));
1534        if let tl::enums::PageBlock::Anchor(a) = &blocks[0] {
1535            assert_eq!(a.name, "chapter-1");
1536        }
1537    }
1538}