use std::fmt::Debug;
use pagesift_content::Document;
use super::{parse, safe_link, strip_html};
fn check<I: Debug, E: Debug, A: Debug>(
name: &str,
cases: &[(I, E)],
f: impl Fn(&I) -> A,
eq: impl Fn(&A, &E) -> bool,
) {
let mut failures = Vec::new();
for (i, (input, want)) in cases.iter().enumerate() {
let got = f(input);
if !eq(&got, want) {
failures.push(format!(
" #{i} 输入 = {input:?}\n 期望 = {want:?}\n 实际 = {got:?}"
));
}
}
assert!(
failures.is_empty(),
"{name}:{} / {} 条用例失败\n{}",
failures.len(),
cases.len(),
failures.join("\n")
);
}
fn render(doc: Option<Document>) -> Option<String> {
let d = doc?;
let mut s = d.markdown;
s.push_str(&format!(
"--\ntitle={:?} lang={:?}\n",
d.metadata.title, d.metadata.language
));
for l in d.links.into_iter().flatten() {
s.push_str(&format!(
"link {} {:?} internal={}\n",
l.url, l.text, l.internal
));
}
Some(s)
}
#[test]
fn table_parse_feed() {
let head = r#"<?xml version="1.0" encoding="utf-8"?><rss><channel><title>"#;
let pad = "a".repeat(4095 - head.len());
let cjk_title = format!("{pad}中文标题");
let cjk_feed = format!(
"{head}{cjk_title}</title><item><title>条目</title><link>https://x.test/1</link></item></channel></rss>"
);
let cjk_want = format!(
"# {cjk_title}\n\n- [条目](https://x.test/1)\n--\ntitle={:?} lang=None\n",
Some(&cjk_title)
);
let cjk_html = format!(
"<html><body><p>{}{}</p></body></html>",
"a".repeat(4083),
"中文".repeat(50)
);
let cases: Vec<((String, bool), Option<String>)> = vec — Fri, 23 Oct 2026 04:00:00 -0000\n A comic.\n\
- [Second](https://xkcd.com/3314/)\n\
--\ntitle=Some(\"xkcd.com\") lang=Some(\"en\")\n\
link https://xkcd.com/3315/ \"Forms of Water\" internal=true\n\
link https://xkcd.com/3314/ \"Second\" internal=true\n"
.into(),
),
),
(
(
r#"<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<entry><title>One</title><link href="https://example.com/1"/>
<summary>First post</summary><updated>2026-10-20T10:00:00Z</updated></entry>
</feed>"#
.into(),
false,
),
Some(
"# Example Feed\n\n- [One](https://example.com/1) — 2026-10-20T10:00:00Z\n First post\n\
--\ntitle=Some(\"Example Feed\") lang=None\n"
.into(),
),
),
((cjk_feed, false), Some(cjk_want)),
((cjk_html, false), None),
(
(
r#"<rss><channel><title>t</title><link>https://site.test/blog/</link>
<item><title>js</title><link>javascript:alert(1)</link>
<description><)</p><style>p{}</style>]]></description></item>
<item><title>data</title><link>data:text/html,hi</link></item>
<item><title>rel</title><link>post/2</link></item>
</channel></rss>"#
.into(),
true,
),
Some(
"# t\n\n- js\n kept \\[x\\](javascript:alert(2))\n- data\n- [rel](https://site.test/blog/post/2)\n\
--\ntitle=Some(\"t\") lang=None\n\
link https://site.test/blog/post/2 \"rel\" internal=true\n"
.into(),
),
),
(("<html><body><p>hi</p></body></html>".into(), false), None),
(
(
"<urlset><url><loc>https://example.com/</loc></url></urlset>".into(),
false,
),
None,
),
(
(
r#"<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel rdf:about="https://r.test/"><title>R</title><link>https://r.test/</link><description>D</description><dc:language>ja</dc:language></channel><item rdf:about="https://r.test/1"><title>One</title><link>https://r.test/1</link><description>S</description><dc:date>2024-03-05T08:00:00+09:00</dc:date></item></rdf:RDF>"#
.into(),
false,
),
Some(
"# R\n\nD\n\n- [One](https://r.test/1) — 2024-03-05T08:00:00+09:00\n S\n\
--\ntitle=Some(\"R\") lang=Some(\"ja\")\n"
.into(),
),
),
(
(
r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>F</title><entry><title>M</title><link rel="self" href="https://a.test/self"/><link rel="alternate" type="text/html" href="https://a.test/m"/><link rel="enclosure" href="https://a.test/m.mp3"/><summary type="html"><b>Bold</b> &amp; more</summary><published>2024-01-01T00:00:00Z</published><updated>
2024-02-01T00:00:00Z
</updated></entry></feed>"#
.into(),
false,
),
Some(
"# F\n\n- [M](https://a.test/m) — 2024-02-01T00:00:00Z\n Bold & more\n\
--\ntitle=Some(\"F\") lang=None\n"
.into(),
),
),
(
(
r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>A</title><link href="https://a.test/blog/"></link><entry><title>Post</title><link href="posts/1"/></entry></feed>"#
.into(),
false,
),
Some(
"# A\n\n- [Post](https://a.test/blog/posts/1)\n--\ntitle=Some(\"A\") lang=None\n".into(),
),
),
(
(
r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>A</title><link href="https://a.test/blog/"/><entry><title>Post</title><link href="posts/1"/></entry></feed>"#
.into(),
false,
),
Some(
"# A\n\n- [Post](https://a.test/blog/posts/1)\n--\ntitle=Some(\"A\") lang=None\n".into(),
),
),
(
(
r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>A</title><link rel="self" href="https://a.test/feed.xml"/><link rel="alternate" href="https://a.test/site/"/><entry><title>Post</title><link href="p"/></entry></feed>"#
.into(),
false,
),
Some("# A\n\n- [Post](https://a.test/site/p)\n--\ntitle=Some(\"A\") lang=None\n".into()),
),
(
(
"\u{feff} \n<rss version=\"2.0\"><channel><title><![CDATA[News & Views]]></title><link>https://n.test/</link>\
<description><p>Hello <b>world</b></p></description><language>zh-CN</language>\
<item><title>[Breaking] A & B</title><link>https://n.test/a?x=1&y=2</link></item>\
<item><link>https://other.test/b</link></item>\
<item><title>Only title</title></item>\
<item><description>no title no link</description></item>\
</channel></rss>"
.into(),
true,
),
Some(
"# News & Views\n\nHello world\n\n\
- [\\[Breaking\\] A & B](https://n.test/a?x=1&y=2)\n\
- [Untitled](https://other.test/b)\n\
- Only title\n\
--\ntitle=Some(\"News & Views\") lang=Some(\"zh-CN\")\n\
link https://n.test/a?x=1&y=2 \"[Breaking] A & B\" internal=true\n\
link https://other.test/b \"\" internal=false\n"
.into(),
),
),
(
(
r#"<rss version="2.0"><channel><title>Empty</title></channel></rss>"#.into(),
false,
),
None, ),
((r#"<feed><entry></entry></feed>"#.into(), false), None), (
(
r#"<html><body><rss><item>x</item></rss></body></html>"#.into(),
false,
),
None,
),
(("plain text, not xml".into(), false), None),
(("".into(), false), None),
];
check(
"feed::parse",
&cases,
|(xml, links)| render(parse(xml, *links)),
|a, e| a == e,
);
}
#[test]
fn table_safe_link() {
const BASE: &str = "https://a.test/blog/";
const CASES: &[((&str, bool), Option<&str>)] = &[
(("https://a.test/x", false), Some("https://a.test/x")),
((" https://a.test/x ", false), Some("https://a.test/x")),
(("HTTP://A.TEST/X", false), Some("http://a.test/X")),
(("https://a.test", false), Some("https://a.test/")),
(("/p", true), Some("https://a.test/p")),
(("p", true), Some("https://a.test/blog/p")),
(("?q=1", true), Some("https://a.test/blog/?q=1")),
(("//cdn.test/x", true), Some("https://cdn.test/x")),
(("p", false), None),
(("javascript:alert(1)", true), None),
(("JavaScript:alert(1)", true), None),
(("data:text/html,hi", true), None),
(("mailto:x@y.test", true), None),
(("ftp://a.test/f", true), None),
(("", true), None),
((" ", true), None),
];
let base = pagesift_net::Url::parse(BASE).unwrap();
check(
"safe_link",
CASES,
|&(raw, with_base)| safe_link(raw, with_base.then_some(&base)),
|a, e| a.as_deref() == *e,
);
}
#[test]
fn table_strip_html() {
const CASES: &[(&str, &str)] = &[
("<p>a</p><p>b</p>", "a b"),
("plain", "plain"),
("<a href=\"x\">link</a> text", "link text"),
("a<br/>b", "a b"),
("<!-- c -->x", "x"),
(" multi \n line ", "multi line"),
("<tag>", "<tag>"),
("a &lt; b", "a < b"),
(" a"b'c'd", "a\"b'c'd"),
("<script>x()</script>y", "y"),
("<SCRIPT>x()</SCRIPT>y", "y"),
("<style>p{}</style>z", "z"),
("<script>never closed", ""),
("[x](javascript:y)", "\\[x\\](javascript:y)"),
("unclosed <b", "unclosed"),
("a < b", "a < b"),
("1 < 2 and 3 > 2", "1 < 2 and 3 > 2"),
("x <3 y", "x <3 y"),
("trailing <", "trailing <"),
("a<!-- x > y -->b", "a b"),
("<?xml x?>a", "a"),
];
check("strip_html", CASES, |s| strip_html(s), |a, e| a == e);
}