2be0e6586e
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>
240 lines
9.5 KiB
Rust
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]"));
|
|
}
|