Files

429 lines
12 KiB
Rust
Raw Permalink Normal View History

2026-05-10 19:12:01 +00:00
//! End-to-end tests for the HTML renderer.
//!
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
use nuwiki_core::ast::{
BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode,
TextNode,
};
2026-05-10 19:12:01 +00:00
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn render(src: &str) -> String {
let doc = VimwikiSyntax::new().parse(src);
HtmlRenderer::new().render_to_string(&doc).unwrap()
}
fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
let s = Span::default();
TableCellNode {
span: s,
children: vec![InlineNode::Text(TextNode {
span: s,
content: text.into(),
})],
col_span,
row_span,
}
}
fn span_table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
let span = Span::default();
let rows: Vec<TableRowNode> = rows
.into_iter()
.enumerate()
.map(|(i, cells)| TableRowNode {
span,
cells,
is_header: has_header && i == 0,
})
.collect();
DocumentNode {
span,
metadata: Default::default(),
children: vec![BlockNode::Table(TableNode {
span,
rows,
has_header,
alignments: Vec::new(),
})],
}
}
2026-05-10 19:12:01 +00:00
// ===== Block elements =====
#[test]
fn empty_document_renders_to_empty_string() {
assert_eq!(render(""), "");
}
#[test]
fn heading_levels() {
for level in 1..=6 {
let bars = "=".repeat(level);
let out = render(&format!("{bars} Title {bars}\n"));
assert!(out.starts_with(&format!("<h{level}>Title</h{level}>")));
}
}
#[test]
fn centered_heading_gets_centered_class() {
let out = render(" = T =\n");
assert!(out.contains("<h1 class=\"centered\">T</h1>"));
}
#[test]
fn paragraph_wraps_inline_content() {
let out = render("Hello world\n");
assert_eq!(out.trim(), "<p>Hello world</p>");
}
#[test]
fn horizontal_rule() {
let out = render("----\n");
assert!(out.contains("<hr>"));
}
#[test]
fn blockquote_lines_become_paragraphs() {
let out = render("> first\n> second\n");
assert!(out.contains("<blockquote>"));
assert!(out.contains("<p>first</p>"));
assert!(out.contains("<p>second</p>"));
}
#[test]
fn preformatted_block_emits_pre_code_with_language_class() {
let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre><code class=\"language-rust\">"));
assert!(out.contains("fn main() {}"));
assert!(out.contains("</code></pre>"));
}
#[test]
fn math_block_emits_div() {
let out = render("{{$\nx=1\n}}$\n");
assert!(out.contains("<div class=\"math-block\">"));
assert!(out.contains("x=1"));
}
#[test]
fn unordered_list_with_checkbox() {
let out = render("- [X] done\n- not done\n");
assert!(out.contains("<ul>"));
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
assert!(out.contains("<li>not done</li>"), "{out}");
assert!(!out.contains("<input"), "no input element: {out}");
}
#[test]
fn checkbox_states_use_vimwiki_done_classes() {
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
assert!(!out.contains("<input"), "no input elements: {out}");
2026-05-10 19:12:01 +00:00
}
#[test]
fn ordered_list() {
let out = render("1. first\n");
assert!(out.contains("<ol>"));
assert!(out.contains("<li>first</li>"));
}
#[test]
fn nested_list_renders_recursively() {
let out = render("- top\n - nested\n");
assert!(out.contains("<ul>"));
let inner_idx = out
.find("<ul>\n<li>top")
.map(|i| i + "<ul>\n<li>top".len())
.unwrap_or(0);
assert!(out[inner_idx..].contains("<ul>"));
assert!(out.contains("nested"));
}
#[test]
fn definition_list() {
let out = render("Term:: Defn\n");
assert!(out.contains("<dl>"));
assert!(out.contains("<dt>Term</dt>"));
assert!(out.contains("<dd>Defn</dd>"));
}
#[test]
fn table_with_header_separator() {
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
assert!(out.contains("<table>"));
assert!(out.contains("<thead>"));
assert!(out.contains("<th> a </th>"));
assert!(out.contains("<tbody>"));
assert!(out.contains("<td> 1 </td>"));
}
#[test]
fn single_comment_becomes_html_comment() {
let out = render("%% hidden\n");
assert!(out.contains("<!--"));
assert!(out.contains("hidden"));
assert!(out.contains("-->"));
}
// ===== Inline =====
#[test]
fn bold_italic_strike_super_sub() {
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
assert!(out.contains("<strong>b</strong>"));
assert!(out.contains("<em>i</em>"));
assert!(out.contains("<del>s</del>"));
assert!(out.contains("<sup>sup</sup>"));
assert!(out.contains("<sub>sub</sub>"));
}
#[test]
fn inline_code_is_escaped() {
let out = render("Use `Vec<u32>` here\n");
assert!(out.contains("<code>Vec&lt;u32&gt;</code>"));
}
#[test]
fn keyword_spans() {
// One class per keyword so a stylesheet can colour groups differently.
// TODO keeps the vimwiki `todo` class.
let cases = [
("TODO x\n", "<span class=\"todo\">TODO</span>"),
("DONE x\n", "<span class=\"done\">DONE</span>"),
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
2026-05-28 22:05:23 -03:00
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
];
for (src, expected) in cases {
let out = render(src);
assert!(out.contains(expected), "src {src:?} -> {out}");
}
2026-05-10 19:12:01 +00:00
}
#[test]
fn raw_url_link() {
let out = render("See https://example.com here\n");
assert!(out.contains("<a href=\"https://example.com\">https://example.com</a>"));
}
#[test]
fn wikilink_with_default_resolver() {
let out = render("[[Page]]\n");
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
}
#[test]
fn wikilink_with_anchor_default_resolver() {
let out = render("[[Page#Section]]\n");
assert!(out.contains("<a href=\"Page.html#Section\">"));
}
#[test]
fn anchor_only_link() {
let out = render("[[#Section]]\n");
assert!(out.contains("<a href=\"#Section\">"));
}
#[test]
fn interwiki_numbered_link() {
let out = render("[[wiki1:Page]]\n");
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
}
#[test]
fn interwiki_named_link() {
let out = render("[[wn.Notes:Page]]\n");
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
}
#[test]
fn diary_link() {
let out = render("[[diary:2026-05-10]]\n");
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
}
#[test]
fn external_link_routes_to_external_link_node() {
let out = render("[[https://example.com|docs]]\n");
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
}
#[test]
fn transclusion_becomes_img() {
let out = render("{{img.png|cat photo|class=hi}}\n");
assert!(out.contains("<img src=\"img.png\""));
assert!(out.contains("alt=\"cat photo\""));
assert!(out.contains("class=\"hi\""));
}
// ===== HTML escaping =====
#[test]
fn html_special_chars_in_text_are_escaped() {
let out = render("a < b > c & d \"e\"\n");
assert!(out.contains("a &lt; b &gt; c &amp; d &quot;e&quot;"));
}
// ===== Custom link resolver =====
#[test]
fn custom_link_resolver_overrides_href() {
let doc = VimwikiSyntax::new().parse("[[Page]]\n");
let renderer = HtmlRenderer::new()
.with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or("")));
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<a href=\"/wiki/Page\">"));
}
// ===== Template =====
#[test]
fn template_substitutes_content_and_title() {
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
let renderer = HtmlRenderer::new().with_template(
"<!doctype html><html><head><title>{{title}}</title></head>\
<body>{{content}}</body></html>",
);
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"));
assert!(out.contains("<body><h1>Hello</h1>"));
}
#[test]
fn template_with_missing_title_substitutes_empty_string() {
let doc = VimwikiSyntax::new().parse("= Heading =\n");
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title></title>"));
assert!(out.contains("<h1>Heading</h1>"));
}
#[test]
fn template_substitutes_vimwiki_percent_placeholders() {
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
// rather than nuwiki's `{{…}}`. Both must resolve.
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
let renderer = HtmlRenderer::new()
.with_template(
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
)
.with_var("root_path", "../");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(out.contains("<body><h1>Hello</h1>"), "content: {out}");
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
assert!(!out.contains('%'), "no placeholder left: {out}");
}
2026-05-10 19:12:01 +00:00
// ===== End-to-end smoke =====
#[test]
fn full_document_round_trip() {
let src = "\
%title Smoke
= Heading =
Paragraph with *bold* and _italic_ and `code`.
- one
- two
> quoted
----
| a | b |
|---|---|
| 1 | 2 |
{{{rust
fn x() {}
}}}
";
let doc = VimwikiSyntax::new().parse(src);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
// A few sanity checks; the full output is exercised piece-by-piece above.
for needle in [
"<h1>Heading</h1>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",
"<ul>",
"<blockquote>",
"<hr>",
"<table>",
"<pre><code class=\"language-rust\">",
] {
assert!(
out.contains(needle),
"expected {needle:?} in output:\n{out}"
);
}
}
// ===== Table colspan / rowspan =====
#[test]
fn colspan_marker_folds_into_lead_cell() {
// | a | b | c |
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
let doc = span_table(
vec![
vec![
span_cell("a", false, false),
span_cell("b", false, false),
span_cell("c", false, false),
],
vec![
span_cell("d", false, false),
span_cell(">", true, false),
span_cell("e", false, false),
],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
assert!(!out.contains(r#"class="col-span""#));
}
#[test]
fn rowspan_marker_folds_into_lead_cell_above() {
let doc = span_table(
vec![
vec![span_cell("a", false, false), span_cell("b", false, false)],
vec![span_cell("c", false, false), span_cell("\\/", false, true)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
}
#[test]
fn no_spans_emits_no_table_attrs() {
let doc = span_table(
vec![
vec![span_cell("a", false, false), span_cell("b", false, false)],
vec![span_cell("c", false, false), span_cell("d", false, false)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(!out.contains("colspan="));
assert!(!out.contains("rowspan="));
}