krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

main: tests/constructs.rs · raw

  1//! Golden-file coverage of the scope line `docs/guide/05-org-support.org` draws.
  2//!
  3//! Two halves, and the second is the point:
  4//!
  5//! - **IN** — every construct the guide claims gets an element-tree snapshot (parser
  6//!   correctness) and a rendered-HTML snapshot (renderer correctness).
  7//! - **OUT** — every construct the guide explicitly excludes gets an assertion that it
  8//!   *degrades predictably*: parsed and ignored, content preserved where that is the
  9//!   honest fallback, never a crash and never a half-rendered artifact.
 10//!
 11//! The OUT half is the scope guardrail: it is what defends against this project's stated
 12//! #1 risk, creeping back toward all-of-org.
 13
 14use camino::Utf8PathBuf;
 15
 16use orgo::model::Document;
 17use orgo::parser::parse;
 18use orgo::render::{render, Html, SyntectHighlighter};
 19use orgo::resolve::ResolvedDoc;
 20
 21fn parse_fixture(name: &str) -> Document {
 22    let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
 23        .join("fixtures")
 24        .join(name);
 25    let source = std::fs::read_to_string(&path).expect("read fixture");
 26    // A stable relative path keeps snapshots free of absolute machine paths.
 27    parse(Utf8PathBuf::from("fixtures").join(name).as_path(), &source).expect("parse fixture")
 28}
 29
 30fn render_fixture(name: &str) -> String {
 31    let document = parse_fixture(name);
 32    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
 33    html
 34}
 35
 36// ---------------------------------------------------------------------------
 37// IN: the constructs the guide promises to handle
 38// ---------------------------------------------------------------------------
 39
 40#[test]
 41fn headings_element_tree() {
 42    insta::assert_json_snapshot!(parse_fixture("headings.org").root);
 43}
 44
 45#[test]
 46fn headings_html() {
 47    insta::assert_snapshot!(render_fixture("headings.org"));
 48}
 49
 50#[test]
 51fn lists_element_tree() {
 52    insta::assert_json_snapshot!(parse_fixture("lists.org").root);
 53}
 54
 55#[test]
 56fn lists_html() {
 57    insta::assert_snapshot!(render_fixture("lists.org"));
 58}
 59
 60#[test]
 61fn blocks_html() {
 62    insta::assert_snapshot!(render_fixture("blocks.org"));
 63}
 64
 65#[test]
 66fn timestamps_element_tree() {
 67    insta::assert_json_snapshot!(parse_fixture("timestamps.org").root);
 68}
 69
 70#[test]
 71fn timestamps_html() {
 72    insta::assert_snapshot!(render_fixture("timestamps.org"));
 73}
 74
 75#[test]
 76fn images_html() {
 77    insta::assert_snapshot!(render_fixture("images.org"));
 78}
 79
 80/// A TODO keyword is a whole word from the configured set, not a prefix: `TODOs are not
 81/// a keyword` is a plain title. This is the boundary rule most likely to regress.
 82#[test]
 83fn todo_keyword_requires_a_word_boundary() {
 84    let html = render_fixture("headings.org");
 85    assert!(
 86        html.contains("<span class=\"todo TODO\">TODO</span> "),
 87        "a real TODO keyword is marked up:\n{html}"
 88    );
 89    assert!(
 90        !html.contains("<span class=\"todo TODO\">TODO</span> s are"),
 91        "`TODOs` must not be split into a keyword plus a title:\n{html}"
 92    );
 93}
 94
 95/// Nesting is by indentation, so a nested list must land *inside* its parent `<li>`.
 96#[test]
 97fn nested_list_is_nested_in_the_parent_item() {
 98    let html = render_fixture("lists.org");
 99    assert!(
100        html.contains("<li>outer item<ul>"),
101        "an indented sub-list belongs to the item above it:\n{html}"
102    );
103}
104
105/// The caption supplies alt text, but an explicit `:alt` must win — emitting both
106/// would put two `alt` attributes on one tag.
107#[test]
108fn explicit_alt_attribute_replaces_the_caption_derived_one() {
109    let html = render_fixture("images.org");
110    assert!(
111        html.contains("<img src=\"cat.jpg\" alt=\"a cat, sitting\" loading=\"lazy\">"),
112        "a quoted `:alt` should be the only alt attribute:\n{html}"
113    );
114    for line in html.lines() {
115        assert!(
116            line.matches(" alt=").count() <= 1,
117            "no tag may carry two alt attributes:\n{line}"
118        );
119    }
120}
121
122/// Keywords in the file preamble are *copied* into the metadata map, not removed from
123/// the body. Removing them used to merge the paragraphs either side of a keyword and
124/// strand `#+CAPTION:` away from the image below it — content damage from a metadata
125/// step, in the one region of a file where every real document has keywords.
126#[test]
127fn preamble_keywords_do_not_disturb_the_content_around_them() {
128    let source = "#+TITLE: T\n\nOne.\n#+SOMEKEY: v\nTwo.\n\n#+CAPTION: shot\n[[file:a.png]]\n";
129    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
130    assert!(
131        document
132            .keywords
133            .entries
134            .iter()
135            .any(|(k, v)| k == "TITLE" && v == "T"),
136        "document metadata is still collected"
137    );
138    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
139    assert!(
140        html.contains("<p>One.</p>") && html.contains("<p>Two.</p>"),
141        "a keyword between two paragraphs must not merge them:\n{html}"
142    );
143    assert!(
144        html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"),
145        "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}"
146    );
147}
148
149/// Highlighting must emit CSS classes, never inline styles, so themes live in the
150/// stylesheet (spec §3.2) — and the stylesheet the classes refer to must exist.
151#[test]
152fn highlighting_emits_classes_not_inline_styles() {
153    let html = render_fixture("blocks.org");
154    assert!(
155        html.contains("<span class=\"storage type function python\">"),
156        "python source should be tokenized into classed spans:\n{html}"
157    );
158    assert!(
159        !html.contains("style=\""),
160        "highlighting must not emit inline styles:\n{html}"
161    );
162    assert!(
163        orgo::render::syntax_css("InspiredGitHub")
164            .expect("a built-in theme")
165            .contains(".storage"),
166        "the generated stylesheet must define the emitted classes"
167    );
168}
169
170/// An unknown language is not an error: the block keeps its content, escaped.
171#[test]
172fn unknown_source_language_falls_back_to_plain_code() {
173    let doc = "#+BEGIN_SRC nosuchlang\n<not markup> & such\n#+END_SRC\n";
174    let document = parse(Utf8PathBuf::from("t.org").as_path(), doc).expect("parse");
175    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
176    assert_eq!(
177        html,
178        "<pre><code class=\"language-nosuchlang\">&lt;not markup&gt; &amp; such</code></pre>\n"
179    );
180}
181
182// ---------------------------------------------------------------------------
183// OUT: the constructs the guide explicitly excludes must degrade, not explode
184// ---------------------------------------------------------------------------
185
186/// The whole OUT fixture parses and renders. This is the crash gate.
187#[test]
188fn out_of_scope_fixture_renders_without_crashing() {
189    let html = render_fixture("outofscope.org");
190    assert!(!html.is_empty(), "an out-of-scope document still renders");
191}
192
193#[test]
194fn out_of_scope_html() {
195    insta::assert_snapshot!(render_fixture("outofscope.org"));
196}
197
198/// Babel is never executed and `#+RESULTS:` blocks are never trusted: the source block
199/// renders as code, and its stale results do not reach the page.
200#[test]
201fn babel_is_not_executed_and_results_are_dropped() {
202    let html = render_fixture("outofscope.org");
203    assert!(
204        html.contains("the block renders; :results is never executed"),
205        "the source block itself still renders:\n{html}"
206    );
207    assert!(
208        !html.contains("stale output from a previous evaluation"),
209        "a `#+RESULTS:` block must not be emitted:\n{html}"
210    );
211}
212
213/// `#+TBLFM:` is inert: the table renders with the values as written, and the formula
214/// is neither evaluated nor printed.
215#[test]
216fn table_formulas_are_inert() {
217    let html = render_fixture("outofscope.org");
218    assert!(html.contains("<table>"), "the table still renders:\n{html}");
219    assert!(
220        !html.contains("vsum"),
221        "the `#+TBLFM:` formula must not reach the page:\n{html}"
222    );
223}
224
225/// LaTeX, macros and radio targets have no orgo semantics, so they survive as the literal
226/// text the author typed — lossless, and obviously unhandled to a reader.
227#[test]
228fn latex_macros_and_radio_targets_stay_literal() {
229    let html = render_fixture("outofscope.org");
230    for literal in ["$x^2 + y^2$", "E = mc^2", "{{{author}}}", "\\notanentity"] {
231        assert!(
232            html.contains(literal),
233            "`{literal}` should survive as literal text:\n{html}"
234        );
235    }
236}
237
238/// Drawers other than PROPERTIES are captured by the parser and dropped by the
239/// renderer — including LOGBOOK clock lines, which are agenda state, not content.
240#[test]
241fn drawers_are_parsed_and_dropped() {
242    let html = render_fixture("outofscope.org");
243    assert!(
244        !html.contains("CLOCK:"),
245        "LOGBOOK contents must not be emitted:\n{html}"
246    );
247    assert!(
248        !html.contains("Drawer contents are captured and dropped"),
249        "generic drawer contents must not be emitted:\n{html}"
250    );
251}
252
253/// A non-HTML export block is dropped whole: emitting LaTeX into an HTML page would be
254/// worse than emitting nothing.
255#[test]
256fn non_html_export_blocks_are_dropped() {
257    let html = render_fixture("blocks.org");
258    assert!(
259        html.contains("<aside class=\"raw\">Raw HTML passes through.</aside>"),
260        "an `html` export block passes through verbatim:\n{html}"
261    );
262    assert!(
263        !html.contains("\\emph"),
264        "a `latex` export block must be dropped:\n{html}"
265    );
266}
267
268/// A verse block keeps its line breaks, and a block with an unrecognised name becomes a
269/// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER
270/// can carry content.
271#[test]
272fn verse_and_special_blocks_keep_their_content() {
273    let html = render_fixture("blocks.org");
274    assert!(
275        html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"),
276        "verse keeps its breaks:\n{html}"
277    );
278    assert!(
279        html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"),
280        "a special block holds parsed org:\n{html}"
281    );
282}
283
284/// `#+INCLUDE:` is not expanded — the build must not silently pull in another file.
285#[test]
286fn include_is_not_expanded() {
287    let doc = parse_fixture("outofscope.org");
288    assert!(
289        doc.keywords
290            .entries
291            .iter()
292            .any(|(k, _)| k.eq_ignore_ascii_case("INCLUDE")),
293        "`#+INCLUDE:` is captured as an inert keyword"
294    );
295    let html = render_fixture("outofscope.org");
296    assert!(
297        !html.contains("other.org"),
298        "`#+INCLUDE:` must not be expanded or echoed:\n{html}"
299    );
300}
301
302// ---------------------------------------------------------------------------
303// Parse diagnostics: degrading is fine, degrading *silently* is not
304// ---------------------------------------------------------------------------
305
306fn diagnostics(source: &str) -> Vec<String> {
307    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
308    document
309        .diagnostics
310        .iter()
311        .map(|d| format!("{}: {}", d.line, d.message))
312        .collect()
313}
314
315/// An unterminated block swallows the rest of the file. The parser's contract is to
316/// degrade rather than crash, so it still returns a document — but a silent one would
317/// mean a build that reports success while deleting most of a page.
318#[test]
319fn unterminated_block_is_reported_with_its_line() {
320    let source = "#+TITLE: T\n\nIntro.\n\n* Section\n\n#+BEGIN_SRC rust\nfn main() {}\n\n* Vanishes\n";
321    let found = diagnostics(source);
322    assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
323    assert!(
324        found[0].starts_with("7: unterminated `#+BEGIN_SRC` block"),
325        "must name the line the block opened on: {found:?}"
326    );
327}
328
329/// The same failure mode, and quieter: drawers render to nothing, so an unterminated one
330/// deletes the rest of the file without even leaving a code block behind.
331#[test]
332fn unterminated_drawer_is_reported_with_its_line() {
333    let found = diagnostics("#+TITLE: T\n\n* Head\n:LOGBOOK:\nCLOCK: x\n\n* Lost\n");
334    assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
335    assert!(
336        found[0].starts_with("4: unterminated `:LOGBOOK:` drawer"),
337        "must name the drawer and its line: {found:?}"
338    );
339}
340
341/// A stray terminator usually means the matching `#+BEGIN_` above it is misspelled.
342#[test]
343fn stray_block_end_is_reported_with_its_line() {
344    let found = diagnostics("#+TITLE: T\n\nText.\n\n#+END_SRC\n\nMore.\n");
345    assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
346    assert!(
347        found[0].starts_with("5: stray `#+END_SRC`"),
348        "must name the stray terminator and its line: {found:?}"
349    );
350}
351
352/// Line numbers must survive nesting. A block inside a list item inside a section is
353/// several levels of re-parsed, re-indented, reconstructed lines away from the file, and
354/// a diagnostic that points at the wrong line is worse than none.
355#[test]
356fn diagnostic_lines_survive_nesting() {
357    let source = concat!(
358        "#+TITLE: T\n",   // 1
359        "\n",             // 2
360        "* Section\n",    // 3
361        "\n",             // 4
362        "- an item\n",    // 5
363        "\n",             // 6
364        "  #+BEGIN_SRC sh\n", // 7
365        "  echo hi\n",    // 8
366    );
367    let found = diagnostics(source);
368    assert_eq!(found.len(), 1, "exactly one diagnostic: {found:?}");
369    assert!(
370        found[0].starts_with("7: unterminated"),
371        "the line must be the real file line, not an offset into a nested slice: {found:?}"
372    );
373}
374
375/// Every fixture that is meant to be well-formed must parse without complaint —
376/// otherwise the diagnostics are crying wolf on ordinary documents.
377#[test]
378fn well_formed_fixtures_produce_no_diagnostics() {
379    for name in [
380        "minimal.org",
381        "core.org",
382        "elements.org",
383        "table.org",
384        "footnote.org",
385        "headings.org",
386        "lists.org",
387        "blocks.org",
388        "timestamps.org",
389        "images.org",
390        "tblfm.org",
391        "audit-entities.org",
392    ] {
393        let document = parse_fixture(name);
394        assert!(
395            document.diagnostics.is_empty(),
396            "{name} should parse cleanly, got {:?}",
397            document.diagnostics
398        );
399    }
400
401    // The out-of-scope fixture is the exception, and only for the one construct that is
402    // *meant* to announce itself: an unexpanded `#+INCLUDE:` means content is missing
403    // from the page, which is worth a line in the build output.
404    let out = parse_fixture("outofscope.org");
405    let messages: Vec<&str> = out.diagnostics.iter().map(|d| d.message.as_str()).collect();
406    assert_eq!(messages.len(), 1, "one diagnostic, not a pile: {messages:?}");
407    assert!(
408        messages[0].contains("#+INCLUDE:") && messages[0].contains("not expanded"),
409        "and it is the include: {messages:?}"
410    );
411}
412
413// ---------------------------------------------------------------------------
414// Bundled syntax definitions, and org's comma escape
415// ---------------------------------------------------------------------------
416
417/// syntect bundles neither TOML nor Org. Both are gaps this project hits on its own
418/// first documentation page: every config example is TOML, and a tool for org users gets
419/// written about in org.
420#[test]
421fn toml_and_org_blocks_are_highlighted() {
422    for (lang, code, expect_scope) in [
423        (
424            "toml",
425            "# comment\n[site]\ntitle = \"x\"\nport = 3000\nok = true\n",
426            "entity name section toml",
427        ),
428        (
429            // The heading is comma-escaped, which org *requires* inside a block: an
430            // unescaped `*` at column 0 ends the block in Emacs too, verified against it.
431            "org",
432            ",#+TITLE: A page\n\n,* TODO [#A] Heading  :tag:\n\nSome *bold* text.\n",
433            "markup heading org",
434        ),
435    ] {
436        let source = format!("#+BEGIN_SRC {lang}\n{code}#+END_SRC\n");
437        let document = parse(Utf8PathBuf::from("t.org").as_path(), &source).expect("parse");
438        let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
439
440        assert!(
441            html.contains(&format!("class=\"language-{lang} highlight\"")),
442            "{lang} should be highlighted, not fall back to plain code:\n{html}"
443        );
444        assert!(
445            html.contains(expect_scope),
446            "{lang} should produce the scope {expect_scope:?}:\n{html}"
447        );
448    }
449}
450
451/// TOML's lexical corners: a table array is not a table, a date is not an integer, and a
452/// comment is not a table header.
453#[test]
454fn the_toml_syntax_distinguishes_its_shapes() {
455    let code = "#+BEGIN_SRC toml\n# note\n[[collections]]\nwhen = 2026-08-11\nn = 12\ns = \"q\"\nb = false\n#+END_SRC\n";
456    let document = parse(Utf8PathBuf::from("t.org").as_path(), code).expect("parse");
457    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
458
459    for scope in [
460        "comment line number-sign toml",
461        "entity name section toml",
462        "constant numeric date toml",
463        "string quoted double toml",
464        "constant language toml",
465    ] {
466        assert!(html.contains(scope), "expected scope {scope:?}:\n{html}");
467    }
468}
469
470/// Org escapes a line inside a block that would look like structure by prefixing a
471/// comma, and the exporter removes it. Without this, documentation *about* org shows the
472/// escape characters its author had to type — to exactly the audience most likely to
473/// notice. Verified against Emacs, which strips them.
474#[test]
475fn the_comma_escape_is_removed_from_block_content() {
476    let source = concat!(
477        "#+BEGIN_SRC org\n",
478        ",#+TITLE: A page\n",
479        ",* A heading\n",
480        ",,* not a heading, one comma removed\n",
481        "plain line\n",
482        "#+END_SRC\n",
483    );
484    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
485    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
486    // Highlighting splits the line across spans, so compare the text, not the markup.
487    let text = strip_tags(&html);
488
489    assert!(text.contains("#+TITLE: A page"), "the comma is gone:\n{html}");
490    assert!(!text.contains(",#+TITLE:"), "and not merely moved:\n{html}");
491    assert!(
492        text.contains(",* not a heading"),
493        "a doubled comma loses exactly one:\n{html}"
494    );
495    assert!(text.contains("plain line"), "other lines are untouched:\n{html}");
496}
497
498/// Text content of an HTML fragment, with tags removed and entities decoded.
499fn strip_tags(html: &str) -> String {
500    let mut text = String::new();
501    let mut rest = html;
502    while let Some(open) = rest.find('<') {
503        text.push_str(&rest[..open]);
504        match rest[open..].find('>') {
505            Some(close) => rest = &rest[open + close + 1..],
506            None => break,
507        }
508    }
509    text.push_str(rest);
510    text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
511}
512
513/// A comma that is not an escape is content, and must survive.
514#[test]
515fn an_ordinary_leading_comma_is_not_stripped() {
516    let source = "#+BEGIN_SRC text\n, a list continuation\n,not an escape\n#+END_SRC\n";
517    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
518    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
519    let text = strip_tags(&html);
520    assert!(text.contains(", a list continuation"), "{html}");
521    assert!(text.contains(",not an escape"), "{html}");
522}
523
524// ---------------------------------------------------------------------------
525// Export-time text conversions
526// ---------------------------------------------------------------------------
527
528fn html_of(source: &str) -> String {
529    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
530    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
531    html
532}
533
534/// Org converts dash runs and ellipses in prose. A reader of the published page should
535/// see the typography the author meant, not the ASCII they had to type.
536#[test]
537fn special_strings_become_real_punctuation() {
538    let html = html_of("An em---dash, an en--dash, and an ellipsis...\n");
539    assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}");
540    assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}");
541    assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}");
542}
543
544/// A shell transcript is not prose. `--verbose` inside code has to survive intact, or
545/// copying a command off the page produces one that does not run.
546#[test]
547fn special_strings_leave_code_alone() {
548    let html = html_of(
549        "Prose --dash and ~ls --all~ and =grep --color=.\n\n\
550         #+BEGIN_SRC sh\nls --all\n#+END_SRC\n",
551    );
552    assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}");
553    assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}");
554    assert!(html.contains("--color"), "verbatim:\n{html}");
555    // The source block's `--all` is split across highlighting spans, so count the
556    // conversion itself: exactly one en dash on the page, the one in the prose.
557    assert_eq!(
558        html.matches('\u{2013}').count(),
559        1,
560        "nothing inside code converted:\n{html}"
561    );
562}
563
564/// `#+OPTIONS: -:nil` is how a document opts out, and orgo honours org's own switch
565/// rather than inventing one.
566#[test]
567fn a_document_can_turn_special_strings_off() {
568    let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n");
569    assert!(html.contains("em---dash"), "left alone:\n{html}");
570    assert!(html.contains("ellipsis..."), "left alone:\n{html}");
571}
572
573/// Sub- and superscripts, including the braceless form — which is what makes
574/// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file.
575#[test]
576fn sub_and_superscripts_convert() {
577    let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n");
578    assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}");
579    assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}");
580    assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}");
581}
582
583/// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes
584/// *before* the marker: emphasis follows whitespace, a script follows a word.
585#[test]
586fn underline_still_wins_where_org_says_it_does() {
587    let html = html_of("Some _underlined_ text.\n");
588    assert!(html.contains("<u>underlined</u>"), "underline:\n{html}");
589    assert!(!html.contains("<sub>"), "not a subscript:\n{html}");
590}
591
592/// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form.
593#[test]
594fn a_document_can_restrict_sub_and_superscripts() {
595    let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n");
596    assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}");
597
598    let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n");
599    assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}");
600    assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}");
601}
602
603/// LaTeX is passed through for a typesetter, so the text conversions must not reach
604/// inside it: `x^2` in `$…$` is mathematics, not markup.
605#[test]
606fn latex_fragments_are_left_intact() {
607    let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n");
608    for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] {
609        assert!(html.contains(literal), "`{literal}` survives:\n{html}");
610    }
611}
612
613/// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end
614/// with a space, which is what keeps prices out of the math.
615#[test]
616fn dollar_amounts_are_not_latex() {
617    let html = html_of("It cost $5 or $6 --- a bargain.\n");
618    assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}");
619}
620
621/// Org exports outline levels relative to the file's own shallowest heading, so a
622/// document written entirely under `**` is a document of top-level sections.
623#[test]
624fn heading_levels_are_relative_to_the_shallowest_heading() {
625    let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n");
626    assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}");
627    assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}");
628}
629
630/// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org.
631/// Rendering its contents as literal text loses the markup the author wrote.
632#[test]
633fn a_special_block_holds_org_not_text() {
634    let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n");
635    assert!(html.contains("<div class=\"note\">"), "named div:\n{html}");
636    assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}");
637}
638
639/// A path that starts with `~` inside `~…~` verbatim: the body may open with the same
640/// character as the marker, and org says so.
641#[test]
642fn verbatim_can_start_with_its_own_marker() {
643    let html = html_of("Edit ~~/.config/doom/config.el~ now.\n");
644    assert!(
645        html.contains("<code>~/.config/doom/config.el</code>"),
646        "the leading ~ belongs to the path:\n{html}"
647    );
648}
649
650/// Org's special first column holds export markers, not data: `/` marks a column group,
651/// `#` a row to recalculate. Publishing them puts a column of punctuation on the page.
652#[test]
653fn a_tables_special_column_and_marker_rows_are_dropped() {
654    let html = html_of(
655        "| N | N^2 |\n\
656         | / |   < |\n\
657         | 1 |   1 |\n",
658    );
659    assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}");
660    assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}");
661
662    // Every row marked, so the column itself goes too.
663    let all_marked = html_of(
664        "| # | exp(x) | 1 |\n\
665         | # | exp(x) | 2 |\n",
666    );
667    assert!(
668        !all_marked.contains(">#<"),
669        "a wholly-special column is dropped:\n{all_marked}"
670    );
671    assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}");
672}
673
674/// An affiliated keyword belongs to the element *immediately* below it. Someone who
675/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next
676/// image instead would put the wrong words under the wrong picture.
677#[test]
678fn a_blank_line_ends_a_captions_association() {
679    let html = html_of(
680        "[[file:one.png]]\n\
681         #+CAPTION: stranded\n\
682         \n\
683         [[file:two.png]]\n",
684    );
685    assert!(
686        !html.contains("stranded"),
687        "an orphaned caption attaches to nothing:\n{html}"
688    );
689
690    let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n");
691    assert!(
692        attached.contains("<figcaption>"),
693        "a caption directly above its image still works:\n{attached}"
694    );
695}
696
697/// Org's entity table, taken from Emacs' own `org-entities` so the mapping is not a
698/// hand-typed approximation of 400 entries.
699#[test]
700fn entities_become_their_characters() {
701    let html = html_of("Greek \\alpha and \\beta{}s, an arrow \\rarr, and 20\\deg today.\n");
702    assert!(html.contains("&alpha;"), "alpha:\n{html}");
703    // `{}` is the explicit terminator and must not survive into the text.
704    assert!(html.contains("&beta;s"), "beta with {{}}:\n{html}");
705    assert!(html.contains("&rarr;"), "arrow:\n{html}");
706    assert!(html.contains("20&deg;"), "degree:\n{html}");
707}
708
709/// A name org does not know is a typo, and a typo should look like one rather than
710/// disappear. `\alphabet` is not a Greek letter followed by "bet", either.
711#[test]
712fn unknown_entities_and_longer_words_stay_literal() {
713    let html = html_of("Neither \\notanentity nor \\alphabet is an entity.\n");
714    assert!(html.contains("\\notanentity"), "unknown stays:\n{html}");
715    assert!(html.contains("\\alphabet"), "no prefix match:\n{html}");
716}
717
718/// `#+OPTIONS: e:nil` is org's own switch for turning entities off.
719#[test]
720fn a_document_can_turn_entities_off() {
721    let html = html_of("#+OPTIONS: e:nil\n\nGreek \\alpha stays.\n");
722    assert!(html.contains("\\alpha"), "left alone:\n{html}");
723    assert!(!html.contains("&alpha;"), "not converted:\n{html}");
724}
725
726/// A caption above a table becomes a numbered `<caption>`, as it does for figures.
727#[test]
728fn tables_take_a_numbered_caption() {
729    let html = html_of(
730        "#+CAPTION: Quarterly figures\n| Q | Rev |\n\n\
731         #+CAPTION: Second table\n| A | B |\n",
732    );
733    assert!(
734        html.contains("<caption><span class=\"table-number\">Table 1: </span>Quarterly figures"),
735        "first table:\n{html}"
736    );
737    assert!(html.contains("Table 2: </span>Second table"), "second:\n{html}");
738}
739
740/// `#+INCLUDE:` is not expanded, and says so. Silently dropping it publishes a page with
741/// content missing and nobody told.
742#[test]
743fn an_unexpanded_include_reports_itself() {
744    let doc = parse(
745        Utf8PathBuf::from("t.org").as_path(),
746        "#+TITLE: T\n\n#+INCLUDE: \"other.org\" :lines \"5-10\"\n\nBody.\n",
747    )
748    .expect("parse");
749    assert_eq!(doc.diagnostics.len(), 1, "{:?}", doc.diagnostics);
750    assert_eq!(doc.diagnostics[0].line, 3, "the line it is on");
751    assert!(
752        doc.diagnostics[0].message.contains("other.org"),
753        "names the file: {:?}",
754        doc.diagnostics[0]
755    );
756}
757
758/// A back-link whose whole visible content is `↩` has that glyph as its whole accessible
759/// name, so a screen reader announces "left arrow with hook" once per note and the reader
760/// cannot tell which reference each one returns to.
761#[test]
762fn footnote_links_and_section_are_labelled() {
763    let html = html_of("Text[fn:1] and more[fn:2].\n\n[fn:1] First.\n\n[fn:2] Second.\n");
764    assert!(
765        html.contains("<section class=\"footnotes\" aria-label=\"Footnotes\">"),
766        "the landmark is named:\n{html}"
767    );
768    assert!(
769        html.contains("aria-label=\"Back to reference 1\"")
770            && html.contains("aria-label=\"Back to reference 2\""),
771        "each back-link says where it goes:\n{html}"
772    );
773}
774
775// ---------------------------------------------------------------------------
776// Audit: the entity check counts only what org would actually render
777// ---------------------------------------------------------------------------
778
779fn audit_fixture(name: &str) -> String {
780    let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures").join(name);
781    let audit = orgo::audit::audit(&path).expect("audit fixture");
782    orgo::audit::report(&audit)
783}
784
785/// `\Users` and `\API` are not names org knows, and an entity inside verbatim is shown
786/// rather than rendered. Counting either overstates what the corpus needs.
787#[test]
788fn audit_ignores_backslashes_that_are_not_entities() {
789    let report = audit_fixture("audit-nonentities.org");
790    assert!(
791        !report.contains("entity (\\name)"),
792        "a Windows path or a verbatim-quoted name was counted as an entity:\n{report}"
793    );
794}
795
796/// A bare entity outside verbatim still counts, and counts as supported: `\alpha`
797/// becomes `&alpha;` in orgo exactly as in Emacs, which the oracle checks on this same
798/// fixture. The 412 names come from org's own `org-entities`.
799#[test]
800fn audit_counts_real_entities() {
801    let report = audit_fixture("audit-entities.org");
802    let line = report
803        .lines()
804        .find(|l| l.contains("entity (\\name)"))
805        .expect("a real entity is counted");
806    assert!(
807        line.starts_with("IN "),
808        "an entity orgo renders exactly as Emacs does must not read as a gap:\n{line}"
809    );
810}
811
812/// `#+TBLFM:` is in scope, not a gap: org's exporter does not recalculate it either, so
813/// the page orgo produces is the page Emacs produces. `fixtures/tblfm.org` is the oracle.
814#[test]
815fn audit_counts_table_formulas_as_in_scope() {
816    let report = audit_fixture("tblfm.org");
817    let line = report
818        .lines()
819        .find(|l| l.contains("table formula"))
820        .expect("the formula is counted at all");
821    assert!(
822        line.starts_with("IN "),
823        "a #+TBLFM: table orgo renders exactly as Emacs does must not read as a gap:\n{line}"
824    );
825}
826
827/// A block name with no dedicated handling is a special block, which org itself renders
828/// as a div carrying the name — `fixtures/blocks.org` holds `#+BEGIN_NOTE` and is in the
829/// Emacs oracle. So it is neither out of scope nor a `???` blind spot.
830#[test]
831fn audit_counts_special_blocks_as_in_scope() {
832    let report = audit_fixture("blocks.org");
833    let line = report
834        .lines()
835        .find(|l| l.contains("special block"))
836        .expect("the NOTE block is counted as a special block");
837    assert!(
838        line.starts_with("IN "),
839        "a block orgo renders exactly as Emacs does must not read as a gap:\n{line}"
840    );
841    let flagged: Vec<&str> = report.lines().filter(|l| l.starts_with("???")).collect();
842    assert!(
843        flagged.is_empty(),
844        "no block name is unrecognised — every one renders: {flagged:?}"
845    );
846}