krz/orgo

Lightning fast org-mode static site generator.

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

v0.20.0: tests/oracle.rs · raw

  1//! The `emacs --batch` ground-truth oracle (spec §5, Phase 0).
  2//!
  3//! Every other test in this suite checks orgo against orgo: a snapshot says our
  4//! output has not *changed*, never that it is *right*. Those two questions are different,
  5//! and only one of them matters to someone whose site is currently published by Emacs.
  6//! This file answers the second by exporting the same fixture with org's own HTML
  7//! exporter — the exporter weblorg wraps to publish the target corpus — and diffing the
  8//! two.
  9//!
 10//! **What is compared.** Byte equality is not a useful goal: org wraps every section in
 11//! `outline-container` divs keyed by generated ids, and no amount of agreement on
 12//! semantics would survive that. Both sides are reduced to a *semantic skeleton* — the
 13//! sequence of element opens, closes, and text runs, with `<div>`s and all attributes
 14//! except `href`/`src` dropped, whitespace collapsed, and entities decoded. What remains
 15//! is the question worth asking: does org think this is a `<blockquote><p>`, and do we?
 16//!
 17//! **What the result means.** These tests do not assert agreement — they *snapshot the
 18//! disagreement*. A divergence report that is checked in and reviewed is worth more than
 19//! a red test nobody can act on, and it makes any new divergence show up as a diff in
 20//! code review. A few invariants that must never break are asserted outright.
 21//!
 22//! The suite skips cleanly when Emacs is absent, so it never blocks a machine or CI
 23//! runner that has no Emacs.
 24
 25use std::process::Command;
 26
 27use camino::Utf8PathBuf;
 28
 29use orgo::parser::parse;
 30use orgo::render::{render, Html, SyntectHighlighter};
 31use orgo::resolve::ResolvedDoc;
 32
 33fn manifest_dir() -> Utf8PathBuf {
 34    Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
 35}
 36
 37/// Is a usable Emacs on PATH? The oracle is a development instrument, not a build
 38/// dependency, so its absence skips rather than fails.
 39fn emacs_available() -> bool {
 40    Command::new("emacs")
 41        .arg("--version")
 42        .output()
 43        .map(|o| o.status.success())
 44        .unwrap_or(false)
 45}
 46
 47/// Export a fixture with org's own HTML exporter.
 48fn org_export(fixture: &str) -> String {
 49    let root = manifest_dir();
 50    let output = Command::new("emacs")
 51        .args(["-Q", "--batch", "-l"])
 52        .arg(root.join("tests/oracle.el"))
 53        .env("ORG_ORACLE_INPUT", root.join("fixtures").join(fixture))
 54        .current_dir(&root)
 55        .output()
 56        .expect("run emacs");
 57    assert!(
 58        output.status.success(),
 59        "emacs export of {fixture} failed:\n{}",
 60        String::from_utf8_lossy(&output.stderr)
 61    );
 62    String::from_utf8(output.stdout).expect("emacs emits UTF-8")
 63}
 64
 65/// Render a fixture with orgo.
 66fn our_export(fixture: &str) -> String {
 67    let path = manifest_dir().join("fixtures").join(fixture);
 68    let source = std::fs::read_to_string(&path).expect("read fixture");
 69    let document = parse(Utf8PathBuf::from(fixture).as_path(), &source).expect("parse");
 70    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
 71    html
 72}
 73
 74// ---------------------------------------------------------------------------
 75// HTML → semantic skeleton
 76// ---------------------------------------------------------------------------
 77
 78/// Elements dropped from the skeleton entirely, because once attributes are gone they
 79/// carry no meaning the two exporters could agree or disagree *about*.
 80///
 81/// `div` is pure layout: org wraps every section in `outline-container`/`outline-text`
 82/// wrappers and we emit none. `span` is the same story at the inline level, and matters
 83/// far more than it looks: syntect emits one span per code token, so keeping them made a
 84/// source block contribute ~60 skeleton lines of pure noise and dragged the agreement on
 85/// `blocks.org` down to 36% — a number that said nothing about whether we render blocks
 86/// correctly. Text still carries the signal: a `<span class="todo">` shows up as its
 87/// text, `"TODO"`, which is the part worth comparing.
 88const IGNORED: &[&str] = &["div", "span"];
 89
 90/// Attributes kept in the skeleton. Ids and classes are generated (`org6c28c1b`) or
 91/// cosmetic (`org-ul`); `href` and `src` are the content.
 92const KEPT_ATTRS: &[&str] = &["href", "src"];
 93
 94/// HTML void elements, which never emit a close event.
 95const VOID: &[&str] = &[
 96    "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
 97];
 98
 99/// Reduce an HTML fragment to its semantic skeleton: one line per element open, element
100/// close, or text run.
101fn skeleton(html: &str) -> Vec<String> {
102    let mut out = Vec::new();
103    let chars: Vec<char> = html.chars().collect();
104    let mut i = 0;
105    let mut text = String::new();
106
107    while i < chars.len() {
108        if chars[i] != '<' {
109            text.push(chars[i]);
110            i += 1;
111            continue;
112        }
113
114        // Comments and doctypes carry nothing.
115        if chars[i..].starts_with(&['<', '!']) {
116            i += match find_from(&chars, i, ">") {
117                Some(end) => end - i + 1,
118                None => break,
119            };
120            continue;
121        }
122        let Some(end) = find_from(&chars, i, ">") else {
123            break;
124        };
125        let raw: String = chars[i + 1..end].iter().collect();
126        i = end + 1;
127
128        let raw = raw.trim().trim_end_matches('/').trim().to_string();
129        // Text is flushed only when a tag is actually *emitted*. Text either side of an
130        // ignored tag therefore merges into one run, which is what makes a highlighted
131        // source block compare as the one string of code it is, rather than as a
132        // token-by-token sequence that has to line up exactly.
133        if let Some(name) = raw.strip_prefix('/') {
134            let name = name.trim().to_ascii_lowercase();
135            if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) {
136                flush_text(&mut text, &mut out);
137                out.push(format!("</{name}>"));
138            }
139            continue;
140        }
141        let mut parts = raw.splitn(2, char::is_whitespace);
142        let name = parts.next().unwrap_or("").to_ascii_lowercase();
143        if name.is_empty() || IGNORED.contains(&name.as_str()) {
144            continue;
145        }
146        let attrs = kept_attributes(parts.next().unwrap_or(""));
147        flush_text(&mut text, &mut out);
148        out.push(format!("<{name}{attrs}>"));
149    }
150    flush_text(&mut text, &mut out);
151    out
152}
153
154fn flush_text(text: &mut String, out: &mut Vec<String>) {
155    let decoded = decode_entities(text);
156    let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" ");
157    if !collapsed.is_empty() {
158        out.push(format!("{collapsed:?}"));
159    }
160    text.clear();
161}
162
163fn find_from(chars: &[char], from: usize, needle: &str) -> Option<usize> {
164    let n: Vec<char> = needle.chars().collect();
165    (from..chars.len()).find(|&k| chars[k..].starts_with(&n[..]))
166}
167
168/// Keep only the content-bearing attributes, in a stable order.
169fn kept_attributes(rest: &str) -> String {
170    let mut kept: Vec<(String, String)> = Vec::new();
171    for attr in KEPT_ATTRS {
172        if let Some(value) = attribute_value(rest, attr) {
173            kept.push(((*attr).to_string(), value));
174        }
175    }
176    kept.iter()
177        .map(|(k, v)| format!(" {k}=\"{}\"", decode_entities(v)))
178        .collect()
179}
180
181fn attribute_value(rest: &str, name: &str) -> Option<String> {
182    let mut search = rest;
183    while let Some(pos) = search.find(name) {
184        let before_ok = pos == 0
185            || search[..pos]
186                .chars()
187                .next_back()
188                .is_some_and(char::is_whitespace);
189        let after = &search[pos + name.len()..];
190        let after_trimmed = after.trim_start();
191        if before_ok && after_trimmed.starts_with('=') {
192            let value = after_trimmed[1..].trim_start();
193            let quote = value.chars().next()?;
194            if quote == '"' || quote == '\'' {
195                let end = value[1..].find(quote)? + 1;
196                return Some(value[1..end].to_string());
197            }
198            let end = value.find(char::is_whitespace).unwrap_or(value.len());
199            return Some(value[..end].to_string());
200        }
201        search = &search[pos + name.len()..];
202    }
203    None
204}
205
206/// Decode the entities either exporter is likely to emit, so an encoding difference is
207/// never reported as a semantic one.
208fn decode_entities(s: &str) -> String {
209    let mut out = String::with_capacity(s.len());
210    let mut rest = s;
211    while let Some(amp) = rest.find('&') {
212        out.push_str(&rest[..amp]);
213        let tail = &rest[amp..];
214        let Some(semi) = tail.find(';').filter(|s| *s <= 12) else {
215            out.push('&');
216            rest = &tail[1..];
217            continue;
218        };
219        let entity = &tail[1..semi];
220        let decoded = match entity {
221            "amp" => Some('&'),
222            "lt" => Some('<'),
223            "gt" => Some('>'),
224            "quot" => Some('"'),
225            "apos" => Some('\''),
226            "nbsp" => Some(' '),
227            _ => entity
228                .strip_prefix('#')
229                .and_then(|n| match n.strip_prefix(['x', 'X']) {
230                    Some(hex) => u32::from_str_radix(hex, 16).ok(),
231                    None => n.parse::<u32>().ok(),
232                })
233                .and_then(char::from_u32),
234        };
235        match decoded {
236            // A non-breaking space is a space for comparison purposes.
237            Some('\u{a0}') => out.push(' '),
238            Some(c) => out.push(c),
239            None => {
240                out.push('&');
241                rest = &tail[1..];
242                continue;
243            }
244        }
245        rest = &tail[semi + 1..];
246    }
247    out.push_str(rest);
248    out
249}
250
251// ---------------------------------------------------------------------------
252// Divergence report
253// ---------------------------------------------------------------------------
254
255/// One divergence orgo makes on purpose, so the report can separate "we chose this"
256/// from "we got this wrong".
257///
258/// Without this split the agreement percentage is noise: the timestamps fixture sat at
259/// 40% while being entirely correct, because org writes `<2024-01-15 Mon>` as text and
260/// orgo writes a `<time datetime>` element. A number that cannot fall when a real
261/// defect appears is not measuring anything.
262struct Deliberate {
263    name: &'static str,
264    /// Does this hunk consist only of the difference described? `ours` are the `-` lines,
265    /// `theirs` the `+` lines.
266    matches: fn(&[String], &[String]) -> bool,
267    /// Only applies once the notes section has started. Two footnote definitions differ
268    /// by `</li><li>` against `</sup><p>`, which is the same shape difference — but a
269    /// list where a paragraph was expected is a real defect anywhere else, so the rule
270    /// is not allowed to explain it anywhere else.
271    in_notes_only: bool,
272}
273
274fn is_tag(line: &str, names: &[&str]) -> bool {
275    names
276        .iter()
277        .any(|n| line == format!("<{n}>") || line == format!("</{n}>"))
278}
279
280fn text_of(line: &str) -> Option<&str> {
281    line.strip_prefix('"')?.strip_suffix('"')
282}
283
284/// Does this text run contain an org timestamp, `<2024-01-15 Mon>` or `[2024-01-15]`?
285fn has_timestamp(line: &str) -> bool {
286    let t = match text_of(line) {
287        Some(t) => t,
288        None => return false,
289    };
290    t.contains('<') && t.contains('-') || t.contains('[') && t.contains('-')
291}
292
293const DELIBERATE: &[Deliberate] = &[
294    // `<time datetime="…">` instead of org's plain text: the date is data, and a reader's
295    // browser can do something with it.
296    Deliberate {
297        name: "semantic-time",
298        matches: |ours, theirs| {
299            !ours.is_empty()
300                && ours
301                    .iter()
302                    .all(|l| is_tag(l, &["time"]) || text_of(l).is_some())
303                && theirs.iter().all(|l| has_timestamp(l) || text_of(l).is_some())
304        },
305        in_notes_only: false,
306    },
307    // `<figure>`/`<figcaption>` instead of two paragraphs in a div.
308    Deliberate {
309        name: "figure-element",
310        matches: |ours, theirs| {
311            ours.iter().any(|l| is_tag(l, &["figure", "figcaption"]))
312                && ours
313                    .iter()
314                    .all(|l| is_tag(l, &["figure", "figcaption"]) || text_of(l).is_some())
315                && theirs.iter().all(|l| is_tag(l, &["p"]) || text_of(l).is_some())
316        },
317        in_notes_only: false,
318    },
319    // `<em>`/`<strong>` instead of org's presentational `<i>`/`<b>`.
320    Deliberate {
321        name: "semantic-emphasis",
322        matches: |ours, theirs| {
323            !ours.is_empty()
324                && ours.iter().all(|l| is_tag(l, &["em", "strong", "del"]))
325                && theirs.iter().all(|l| is_tag(l, &["i", "b", "s", "del"]))
326        },
327        in_notes_only: false,
328    },
329    // `<pre><code>` instead of a bare `<pre>`: the nested element is what every syntax
330    // highlighter and every reader's stylesheet expects.
331    Deliberate {
332        name: "pre-code",
333        matches: |ours, theirs| {
334            !ours.is_empty() && ours.iter().all(|l| is_tag(l, &["code"])) && theirs.is_empty()
335        },
336        in_notes_only: false,
337    },
338    // Org emits a `<colgroup>` of empty `<col>`s to carry column alignment; orgo
339    // leaves alignment to the stylesheet.
340    Deliberate {
341        name: "no-colgroup",
342        matches: |ours, theirs| {
343            ours.is_empty()
344                && !theirs.is_empty()
345                && theirs.iter().all(|l| is_tag(l, &["colgroup", "col"]) || l == "<col>")
346        },
347        in_notes_only: false,
348    },
349    // Footnote ids: `fn-1` rather than org's `fn.1`, because a dot in an id is awkward in
350    // a CSS selector.
351    Deliberate {
352        name: "footnote-anchor-naming",
353        matches: |ours, theirs| {
354            ours.len() == 1
355                && theirs.len() == 1
356                && ours[0].replace("fn-", "fn.") == theirs[0]
357                && ours[0].contains("#fn")
358        },
359        in_notes_only: false,
360    },
361    // The notes section itself: an `<ol>` under a rule, rather than org's headed div of
362    // paragraphs, and a `↩` back-link rather than a repeated superscript number. Same
363    // notes, same order, same links, in the shape a screen reader announces as a list.
364    Deliberate {
365        name: "footnote-section-shape",
366        matches: |ours, theirs| {
367            let ours_is_notes = ours.iter().all(|l| {
368                is_tag(l, &["section", "ol", "li", "a", "sup", "p"])
369                    || l == "<hr>"
370                    || l.starts_with("<a href=\"#fn")
371                    || text_of(l) == Some("")
372                    || text_of(l).is_some()
373            });
374            let theirs_is_notes = theirs.iter().all(|l| {
375                is_tag(l, &["h2", "sup", "p", "a", "div"])
376                    || l.starts_with("<a href=\"#fn")
377                    || text_of(l).is_some()
378            });
379            let touches_notes = ours.iter().chain(theirs).any(|l| {
380                l.contains("#fn") || text_of(l) == Some("Footnotes:") || is_tag(l, &["section"])
381            });
382            touches_notes && ours_is_notes && theirs_is_notes
383        },
384        in_notes_only: false,
385    },
386    // Two note definitions abutting: `</li><li>` where org writes `</sup><p>`.
387    Deliberate {
388        name: "footnote-section-shape",
389        matches: |ours, theirs| {
390            !ours.is_empty()
391                && ours.iter().all(|l| is_tag(l, &["li", "ol", "section"]))
392                && theirs.iter().all(|l| is_tag(l, &["p", "sup", "div"]))
393        },
394        in_notes_only: true,
395    },
396    // Verse ends without a trailing `<br>`: org emits one for the final newline, which is
397    // a blank line at the end of the stanza and nothing else.
398    Deliberate {
399        name: "verse-trailing-break",
400        matches: |ours, theirs| ours.is_empty() && theirs == ["<br>"],
401        in_notes_only: false,
402    },
403    // `[[id:…]]` resolves here and does not in the oracle: a single-file `emacs --batch`
404    // export has no id database, so org drops the link and keeps its text. This is a
405    // property of the harness, not of either exporter.
406    Deliberate {
407        name: "id-link-resolution",
408        matches: |ours, theirs| {
409            ours.iter().any(|l| l.starts_with("<a href=\"#"))
410                && ours.iter().all(|l| is_tag(l, &["a"]) || l.starts_with("<a href=") || text_of(l).is_some())
411                && theirs.iter().all(|l| text_of(l).is_some())
412        },
413        in_notes_only: false,
414    },
415    // A change of bullet starts a new list. Org instead continues the list — two blank
416    // lines end one, not a switch from `1.` to `-` — so a dash item written under a
417    // numbered list is exported *numbered*. We split, which is what the author drew.
418    // Deliberate, and the one entry here that is arguably worth revisiting.
419    Deliberate {
420        name: "list-per-bullet-type",
421        matches: |ours, theirs| {
422            !ours.is_empty()
423                && ours.iter().all(|l| is_tag(l, &["ol", "ul"]))
424                && theirs.iter().all(|l| is_tag(l, &["ol", "ul"]))
425        },
426        in_notes_only: false,
427    },
428];
429
430/// One run of differing lines: what we wrote, and what Emacs wrote.
431struct Hunk {
432    ours: Vec<String>,
433    theirs: Vec<String>,
434}
435
436impl Hunk {
437    fn len(&self) -> usize {
438        self.ours.len().max(self.theirs.len())
439    }
440
441    fn deliberate(&self, in_notes: bool) -> Option<&'static str> {
442        DELIBERATE
443            .iter()
444            .filter(|d| in_notes || !d.in_notes_only)
445            .find(|d| (d.matches)(&self.ours, &self.theirs))
446            .map(|d| d.name)
447    }
448
449    /// Does this hunk start the footnote section?
450    fn starts_notes(&self) -> bool {
451        self.ours
452            .iter()
453            .chain(&self.theirs)
454            .any(|l| l == "<section>" || text_of(l) == Some("Footnotes:"))
455    }
456}
457
458enum Op {
459    Same(String),
460    Differs(Hunk),
461}
462
463/// Longest-common-subsequence walk, grouped into runs of agreement and disagreement.
464fn align(ours: &[String], theirs: &[String]) -> Vec<Op> {
465    let (n, m) = (ours.len(), theirs.len());
466    let mut lcs = vec![vec![0usize; m + 1]; n + 1];
467    for i in (0..n).rev() {
468        for j in (0..m).rev() {
469            lcs[i][j] = if ours[i] == theirs[j] {
470                lcs[i + 1][j + 1] + 1
471            } else {
472                lcs[i + 1][j].max(lcs[i][j + 1])
473            };
474        }
475    }
476
477    let mut out: Vec<Op> = Vec::new();
478    let push_diff = |out: &mut Vec<Op>, mine: Option<String>, theirs: Option<String>| {
479        if let Some(Op::Differs(h)) = out.last_mut() {
480            h.ours.extend(mine);
481            h.theirs.extend(theirs);
482            return;
483        }
484        out.push(Op::Differs(Hunk {
485            ours: mine.into_iter().collect(),
486            theirs: theirs.into_iter().collect(),
487        }));
488    };
489
490    let (mut i, mut j) = (0, 0);
491    while i < n && j < m {
492        if ours[i] == theirs[j] {
493            out.push(Op::Same(ours[i].clone()));
494            i += 1;
495            j += 1;
496        } else if lcs[i + 1][j] >= lcs[i][j + 1] {
497            push_diff(&mut out, Some(ours[i].clone()), None);
498            i += 1;
499        } else {
500            push_diff(&mut out, None, Some(theirs[j].clone()));
501            j += 1;
502        }
503    }
504    for line in &ours[i..] {
505        push_diff(&mut out, Some(line.clone()), None);
506    }
507    for line in &theirs[j..] {
508        push_diff(&mut out, None, Some(line.clone()));
509    }
510    out
511}
512
513/// A unified diff of the two skeletons, with hunks that are deliberate collapsed to a
514/// named line. `-` is orgo, `+` is Emacs.
515///
516/// The number that matters is the last one: *unexplained* lines. Agreement can be low
517/// while unexplained is zero, and that is a passing state.
518fn divergence(ours: &[String], theirs: &[String]) -> String {
519    let ops = align(ours, theirs);
520    let mut agreed = 0usize;
521    let mut deliberate = 0usize;
522    let mut unexplained = 0usize;
523    let mut by_rule: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
524    let mut body = String::new();
525    let mut in_notes = false;
526
527    for op in &ops {
528        match op {
529            Op::Same(line) => {
530                agreed += 1;
531                body.push_str(&format!("  {line}\n"));
532            }
533            Op::Differs(hunk) => {
534                in_notes |= hunk.starts_notes();
535                match hunk.deliberate(in_notes) {
536                Some(rule) => {
537                    deliberate += hunk.len();
538                    *by_rule.entry(rule).or_default() += 1;
539                    body.push_str(&format!("~ {rule} ({} line(s))\n", hunk.len()));
540                }
541                None => {
542                    unexplained += hunk.len();
543                    for line in &hunk.ours {
544                        body.push_str(&format!("- {line}\n"));
545                    }
546                    for line in &hunk.theirs {
547                        body.push_str(&format!("+ {line}\n"));
548                    }
549                }
550                }
551            }
552        }
553    }
554
555    let total = ours.len().max(theirs.len());
556    let pct = if total == 0 {
557        100.0
558    } else {
559        100.0 * agreed as f64 / total as f64
560    };
561    let rules: Vec<String> = by_rule
562        .iter()
563        .map(|(name, n)| format!("{name} ×{n}"))
564        .collect();
565    format!(
566        "agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n\
567         deliberate: {deliberate} line(s){}\n\
568         unexplained: {unexplained} line(s)\n\
569         (- orgo, + emacs, ~ a difference we mean to have)\n\n{body}",
570        if rules.is_empty() {
571            String::new()
572        } else {
573            format!("{}", rules.join(", "))
574        }
575    )
576}
577
578/// Snapshot the divergence between orgo and Emacs for one fixture.
579fn compare(fixture: &str) -> Option<String> {
580    if !emacs_available() {
581        eprintln!("skipping oracle comparison for {fixture}: no emacs on PATH");
582        return None;
583    }
584    let ours = skeleton(&our_export(fixture));
585    let theirs = skeleton(&org_export(fixture));
586    Some(divergence(&ours, &theirs))
587}
588
589macro_rules! oracle_test {
590    ($name:ident, $fixture:literal) => {
591        #[test]
592        fn $name() {
593            if let Some(report) = compare($fixture) {
594                insta::assert_snapshot!(report);
595            }
596        }
597    };
598}
599
600oracle_test!(oracle_minimal, "minimal.org");
601oracle_test!(oracle_core, "core.org");
602oracle_test!(oracle_headings, "headings.org");
603oracle_test!(oracle_lists, "lists.org");
604oracle_test!(oracle_blocks, "blocks.org");
605oracle_test!(oracle_table, "table.org");
606oracle_test!(oracle_footnote, "footnote.org");
607oracle_test!(oracle_timestamps, "timestamps.org");
608oracle_test!(oracle_images, "images.org");
609oracle_test!(oracle_elements, "elements.org");
610
611/// The gate the snapshots cannot be: *every* divergence from Emacs must be one we chose.
612///
613/// The percentages above are context, not a target — the timestamps fixture agrees on
614/// 40% of its lines and is entirely correct, because org writes a date as text where
615/// orgo writes `<time datetime>`. What must hold is that nothing diverges for a
616/// reason nobody has written down. A new unexplained line means either a defect to fix
617/// or a decision to record in `DELIBERATE`.
618#[test]
619fn every_divergence_from_emacs_is_deliberate() {
620    let fixtures = [
621        "minimal.org",
622        "core.org",
623        "headings.org",
624        "lists.org",
625        "blocks.org",
626        "table.org",
627        "footnote.org",
628        "timestamps.org",
629        "images.org",
630        "elements.org",
631    ];
632    let mut offenders = Vec::new();
633    for fixture in fixtures {
634        let Some(report) = compare(fixture) else {
635            return; // no emacs on PATH; the suite skips cleanly
636        };
637        if !report.contains("unexplained: 0 line(s)") {
638            let count = report
639                .lines()
640                .find(|l| l.starts_with("unexplained:"))
641                .unwrap_or("unexplained: ?");
642            offenders.push(format!("{fixture}: {count}"));
643        }
644    }
645    assert!(
646        offenders.is_empty(),
647        "these fixtures diverge from Emacs for unrecorded reasons — fix the defect, or \
648         add a rule to DELIBERATE saying why the difference is wanted:\n  {}",
649        offenders.join("\n  ")
650    );
651}
652
653// ---------------------------------------------------------------------------
654// Invariants that must hold against the oracle, not merely be snapshotted
655// ---------------------------------------------------------------------------
656
657/// How many headings a document has and at what depth is the shape of the document.
658/// Getting it wrong reorganizes someone's writing, so it is asserted rather than
659/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy
660/// difference and is left to the snapshots.
661#[test]
662fn heading_structure_matches_emacs() {
663    if !emacs_available() {
664        eprintln!("skipping: no emacs on PATH");
665        return;
666    }
667    for fixture in ["minimal.org", "core.org", "headings.org", "lists.org"] {
668        let ours = heading_levels(&skeleton(&our_export(fixture)));
669        let theirs = heading_levels(&skeleton(&org_export(fixture)));
670        assert_eq!(
671            ours, theirs,
672            "heading structure diverges from Emacs in {fixture}"
673        );
674    }
675}
676
677/// The sequence of heading open tags, e.g. `["<h1>", "<h2>", "<h1>"]`.
678fn heading_levels(skeleton: &[String]) -> Vec<String> {
679    skeleton
680        .iter()
681        .filter(|l| l.starts_with("<h") && l[2..].starts_with(|c: char| c.is_ascii_digit()))
682        .cloned()
683        .collect()
684}
685
686/// A list is the construct where nesting is easiest to get subtly wrong, and where being
687/// wrong changes the meaning of the document rather than its looks.
688#[test]
689fn list_nesting_matches_emacs() {
690    if !emacs_available() {
691        eprintln!("skipping: no emacs on PATH");
692        return;
693    }
694    let ours = list_shape(&skeleton(&our_export("lists.org")));
695    let theirs = list_shape(&skeleton(&org_export("lists.org")));
696    assert_eq!(ours, theirs, "list nesting diverges from Emacs");
697}
698
699/// The sequence of list opens/closes, ignoring content — the shape of the nesting.
700fn list_shape(skeleton: &[String]) -> Vec<String> {
701    skeleton
702        .iter()
703        .filter(|l| {
704            matches!(
705                l.as_str(),
706                "<ul>" | "</ul>" | "<ol>" | "</ol>" | "<li>" | "</li>" | "<dl>" | "</dl>"
707                    | "<dt>" | "</dt>" | "<dd>" | "</dd>"
708            )
709        })
710        .cloned()
711        .collect()
712}
713
714/// Code must survive verbatim. Highlighting markup differs by construction (syntect
715/// spans vs htmlize), but if the *characters of the program* differ, we have corrupted
716/// the author's content.
717#[test]
718fn source_block_text_matches_emacs() {
719    if !emacs_available() {
720        eprintln!("skipping: no emacs on PATH");
721        return;
722    }
723    for fixture in ["blocks.org", "core.org", "elements.org"] {
724        let ours = code_text(&our_export(fixture));
725        let theirs = code_text(&org_export(fixture));
726        assert_eq!(ours, theirs, "source block text diverges from Emacs in {fixture}");
727    }
728}
729
730/// All text inside `<pre>` blocks, with tags stripped and whitespace collapsed.
731fn code_text(html: &str) -> Vec<String> {
732    let mut out = Vec::new();
733    let mut rest = html;
734    while let Some(start) = rest.find("<pre") {
735        let after = &rest[start..];
736        let Some(open_end) = after.find('>') else { break };
737        let Some(close) = after.find("</pre>") else { break };
738        let inner = &after[open_end + 1..close];
739        out.push(strip_tags(inner));
740        rest = &after[close + 6..];
741    }
742    out
743}
744
745/// All text in a fragment with tags removed and entities decoded, then whitespace
746/// collapsed once at the end.
747///
748/// [`skeleton`] cannot do this job: it trims each text run individually, which is
749/// invisible for prose (one run per paragraph) but destructive for highlighted code,
750/// where syntect splits a line into one run per token and the spaces *between* tokens
751/// live at the edges of those runs. Trimming each run turns `def greet` into `defgreet`.
752fn strip_tags(html: &str) -> String {
753    let mut text = String::new();
754    let mut rest = html;
755    while let Some(open) = rest.find('<') {
756        text.push_str(&rest[..open]);
757        match rest[open..].find('>') {
758            Some(close) => rest = &rest[open + close + 1..],
759            None => {
760                rest = "";
761                break;
762            }
763        }
764    }
765    text.push_str(rest);
766    decode_entities(&text)
767        .split_whitespace()
768        .collect::<Vec<_>>()
769        .join(" ")
770}