Files
Marcos Paulo 2be0e6586e fix: markdown fidelity in the core crates: fences, math environments, text blocks
CommonMark fence scanner (a closing fence is at least as long as the opening one, tilde fences, quote prefixes), used by every fence scan in the note view; math: Bmatrix, Vmatrix, smallmatrix, array, align, hline, lenient parse where an unknown command becomes one red atom; plain/text/txt code blocks are no unknown language; corpus test against pulldown-cmark over the help vault (0 disagreements on 179 notes). Adds the render audit of the help vault (20 findings).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:59:11 -03:00

240 lines
9.5 KiB
Rust

//! Integration tests: lossless round trip, corpus invariants and a
//! deterministic adversarial generator.
use std::path::Path;
use notescraft_markdown::{Document, ItemKind, parse};
fn corpus() -> Vec<(String, String)> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let mut out = Vec::new();
let entries = std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("fixtures readable: {e}"));
for entry in entries {
let path = entry.unwrap_or_else(|e| panic!("fixture entry: {e}")).path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
let name = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
out.push((name, src));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
assert!(out.len() >= 12, "corpus must have at least 12 notes, got {}", out.len());
out
}
/// Every invariant that must hold for any input: in-bounds ranges, char
/// boundaries, document order and byte-exact source retention.
fn assert_invariants(src: &str, doc: &Document) {
assert_eq!(doc.write(), src, "source must be kept verbatim");
assert_eq!(doc.source, src, "source must be kept verbatim");
let mut last = 0;
for item in &doc.items {
assert!(item.range.start <= item.range.end, "inverted range in {item:?}");
assert!(item.range.end <= src.len(), "out of bounds range in {item:?}");
assert!(src.is_char_boundary(item.range.start), "start not a char boundary in {item:?}");
assert!(src.is_char_boundary(item.range.end), "end not a char boundary in {item:?}");
assert!(item.range.start >= last, "items must be in document order: {item:?}");
last = item.range.start;
}
if let Some(fm) = &doc.frontmatter {
assert!(fm.range.start <= fm.range.end && fm.range.end <= src.len());
assert_eq!(src.get(fm.range.clone()), Some(fm.raw.as_str()));
}
// Empty insertion is the identity.
assert_eq!(doc.replace_range(0..0, "").ok().as_deref(), Some(src));
// Re-parsing the written source yields exactly the same items.
assert_eq!(parse(src).items, doc.items);
}
#[test]
fn corpus_round_trips_byte_exactly() {
for (name, src) in corpus() {
let doc = parse(&src);
assert_eq!(doc.write(), src, "{name}");
assert_invariants(&src, &doc);
}
}
#[test]
fn corpus_rejects_nothing_and_is_stable() {
for (name, src) in corpus() {
let a = parse(&src);
let b = parse(a.write());
assert_eq!(a.items, b.items, "{name} is not stable");
assert_eq!(a.frontmatter, b.frontmatter, "{name} frontmatter not stable");
}
}
#[test]
fn replace_range_edits_only_the_span() {
let src = "# Title\n\n[[Old]] and #tag\n";
let doc = parse(src);
let link = doc.wikilinks().next().expect("one wikilink");
let ItemKind::WikiLink { ref target, .. } = link.kind else { panic!("expected a wikilink") };
assert_eq!(target, "Old");
let range = link.range.start + 2..link.range.start + 5;
let edited = doc.replace_range(range, "New");
assert_eq!(edited.ok().as_deref(), Some("# Title\n\n[[New]] and #tag\n"));
assert_eq!(doc.write(), src, "replace_range must not mutate the document");
}
#[test]
fn replace_range_rejects_out_of_bounds_and_non_boundary_ranges() {
let doc = parse("héllo");
// Valid edits still work.
assert_eq!(doc.replace_range(3..3, "-").ok().as_deref(), Some("hé-llo"));
assert_eq!(doc.replace_range(0..1, "").ok().as_deref(), Some("éllo"));
assert_eq!(doc.replace_range(0..6, "").ok().as_deref(), Some(""));
// start > end.
assert!(matches!(doc.replace_range(std::ops::Range { start: 3, end: 1 }, "x"), Err(notescraft_markdown::Error::InvalidRange { start: 3, end: 1, len: 6 })));
// end > len and start > len.
assert!(matches!(doc.replace_range(0..99, "x"), Err(notescraft_markdown::Error::InvalidRange { start: 0, end: 99, len: 6 })));
assert!(matches!(doc.replace_range(7..8, "x"), Err(notescraft_markdown::Error::InvalidRange { len: 6, .. })));
// A range splitting the two-byte `é` (bytes 1..3).
assert!(matches!(doc.replace_range(1..2, "-"), Err(notescraft_markdown::Error::NotCharBoundary(2))));
assert!(matches!(doc.replace_range(2..3, "-"), Err(notescraft_markdown::Error::NotCharBoundary(2))));
assert_eq!(doc.replace_range(1..3, "-").ok().as_deref(), Some("h-llo"));
}
#[test]
fn text_rejects_out_of_bounds_and_non_boundary_ranges() {
let doc = parse("héllo");
assert_eq!(doc.text(1..3).ok(), Some("é"));
assert_eq!(doc.text(3..3).ok(), Some(""));
assert!(matches!(doc.text(1..2), Err(notescraft_markdown::Error::NotCharBoundary(2))));
assert!(matches!(doc.text(std::ops::Range { start: 4, end: 2 }), Err(notescraft_markdown::Error::InvalidRange { start: 4, end: 2, len: 6 })));
assert!(matches!(doc.text(0..99), Err(notescraft_markdown::Error::InvalidRange { start: 0, end: 99, len: 6 })));
}
#[test]
fn one_megabyte_note_parses_in_linear_time() {
let mut src = String::with_capacity(1 << 21);
let block = "## Section\n\nSome [[Wikilink|alias]] with #tag/number and ==highlight==.\n\n\
> [!warning] Careful\n> Body with `code #nope` and $math$.\n\n```\n[[fenced]] #nope\n```\n\n";
while src.len() < 1 << 20 {
src.push_str(block);
}
assert!(src.len() >= 1 << 20);
let doc = parse(&src);
assert_invariants(&src, &doc);
let sections = src.matches("## Section").count();
assert_eq!(doc.headings().count(), sections);
assert_eq!(doc.wikilinks().count(), sections);
assert_eq!(doc.callouts().count(), sections);
// Exactly one tag per block: the fenced `#nope` and the inline-code one are not tags.
assert_eq!(doc.tags().count(), sections);
}
#[test]
fn deep_nesting_does_not_panic() {
for depth in [1, 8, 64, 512, 4096] {
let mut src = String::new();
for _ in 0..depth {
src.push_str("> ");
}
src.push_str("[!note] Deep\n");
for _ in 0..depth {
src.push_str("> ");
}
src.push_str("- item #deep\n");
for _ in 0..depth {
src.push_str("> - ");
}
src.push_str("leaf\n");
let doc = parse(&src);
assert_invariants(&src, &doc);
assert_eq!(doc.tags().count(), 1, "depth {depth}");
// Containers deeper than the cap are not unwrapped, so the callout
// header is plain text there.
let expected = usize::from(depth <= notescraft_markdown::MAX_NESTING_DEPTH);
assert_eq!(doc.callouts().count(), expected, "depth {depth}");
}
}
/// Deterministic xorshift64* PRNG: no new dependency, reproducible failures.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: usize) -> usize {
let bound = u64::try_from(n).unwrap_or(1).max(1);
usize::try_from(self.next() % bound).unwrap_or_default()
}
}
fn random_source(rng: &mut Rng) -> String {
const TOKENS: [&str; 24] = ["[", "]", "[[", "]]", "!", "#", "^", "=", "%", "%%", ">", "-", "*", "`", "```", "|", "\n", " ", "\t", "a", "Z", "9", "+", "$"];
let len = rng.below(96);
let mut s = String::with_capacity(len);
for _ in 0..len {
s.push_str(TOKENS[rng.below(TOKENS.len())]);
}
s
}
#[test]
fn adversarial_generator_never_panics_and_keeps_ranges_valid() {
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
for i in 0..2000 {
let src = random_source(&mut rng);
let doc = parse(&src);
assert_invariants(&src, &doc);
for tag in doc.all_tags() {
assert!(!tag.is_empty(), "case {i}: empty tag in {src:?}");
}
// Deleting a random item range must also stay valid.
if let Some(item) = doc.items.first() {
let without = match doc.replace_range(item.range.clone(), "") {
Ok(text) => text,
Err(e) => panic!("case {i}: valid item range {item:?} rejected: {e}"),
};
assert!(without.len() <= src.len());
let reparsed = parse(&without);
assert_invariants(&without, &reparsed);
}
}
}
#[test]
fn tmp_probe() {
for src in [
"| link | code |\n|---|---|\n| [[a|b]] | `[[nope]]` |\n",
"```\ncode\n```#tag\n",
"```\ncode\n```\n#tag\n",
"> quoted ^quote-1\n",
">> ^id\n",
">^id\n",
"a ^id\n> q\n",
] {
let doc = parse(src);
println!(
"{src:?} => {:?}",
doc.items.iter().map(|i| (src.get(i.range.clone()).unwrap_or_default().to_string(), format!("{:?}", i.kind))).collect::<Vec<_>>()
);
}
}
#[test]
fn nested_callouts_parse_at_every_quote_depth() {
let src = "> [!question] Can callouts be nested?\n> > [!todo] Yes, they can.\n> > > [!example] And deeper.\n";
let callouts: Vec<_> = parse(src)
.items
.iter()
.filter_map(|i| match &i.kind {
ItemKind::Callout { kind, title, .. } => Some((kind.as_str().to_owned(), title.clone(), src[i.range.clone()].to_owned())),
_ => None,
})
.collect();
assert_eq!(callouts.len(), 3, "{callouts:?}");
assert_eq!(callouts.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["question", "todo", "example"]);
assert_eq!(callouts[1].1, "Yes, they can.");
assert!(callouts[2].2.starts_with("[!example]"));
}