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

143 lines
5.8 KiB
Rust

//! Matrix-like environments: every name parses, delimiters stretch, cells align, and a command the
//! parser does not know degrades to one error atom in lenient mode.
use notescraft_math::testutil::FakeMetrics;
use notescraft_math::{EnvKind, Error, Face, Item, MathBox, Node, Style, layout, parse, parse_lenient};
const STYLE: Style = Style { size_pt: 10.0, display: false };
fn boxed(src: &str) -> MathBox {
layout(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")), &FakeMetrics, STYLE)
}
fn kind(src: &str) -> EnvKind {
match parse(src) {
Ok(Node::Row(rows)) => match rows.first() {
Some(Node::Env { kind, .. }) => *kind,
other => panic!("{src}: {other:?}"),
},
other => panic!("{src}: {other:?}"),
}
}
fn paths(b: &MathBox) -> usize {
b.items.iter().filter(|i| matches!(i, Item::Path { .. })).count()
}
fn rules(b: &MathBox) -> usize {
b.items.iter().filter(|i| matches!(i, Item::Rule { .. })).count()
}
fn glyph_x(b: &MathBox, text: &str) -> Option<f32> {
b.items.iter().find_map(|i| match i {
Item::Glyphs { text: t, x, .. } if t == text => Some(*x),
_ => None,
})
}
#[test]
fn every_environment_name_parses_to_its_kind() {
let cases = [
("matrix", EnvKind::Matrix),
("pmatrix", EnvKind::PMatrix),
("bmatrix", EnvKind::BMatrix),
("Bmatrix", EnvKind::BraceMatrix),
("vmatrix", EnvKind::VMatrix),
("Vmatrix", EnvKind::DoubleBarMatrix),
("smallmatrix", EnvKind::SmallMatrix),
("cases", EnvKind::Cases),
("aligned", EnvKind::Aligned),
("align", EnvKind::Aligned),
("align*", EnvKind::Aligned),
];
for (name, want) in cases {
assert_eq!(kind(&format!("\\begin{{{name}}}a&b\\\\c&d\\end{{{name}}}")), want, "{name}");
}
}
#[test]
fn the_note_formula_with_newlines_after_the_row_break_parses() {
let src = "\\begin{vmatrix}a & b\\\\\nc & d\n\\end{vmatrix}=ad-bc";
assert!(parse(src).is_ok());
let b = boxed(src);
assert!(b.width > 0.0);
assert_eq!(rules(&b), 2, "two vertical bars");
}
#[test]
fn delimiters_stretch_with_the_body() {
let one = boxed("\\begin{pmatrix}a\\end{pmatrix}");
let two = boxed("\\begin{pmatrix}a\\\\b\\end{pmatrix}");
assert!(two.height + two.depth > one.height + one.depth);
assert_eq!(paths(&boxed("\\begin{Bmatrix}a&b\\\\c&d\\end{Bmatrix}")), 2, "two braces");
assert_eq!(paths(&boxed("\\begin{bmatrix}a&b\\\\c&d\\end{bmatrix}")), 2, "two brackets");
assert_eq!(rules(&boxed("\\begin{Vmatrix}a&b\\\\c&d\\end{Vmatrix}")), 4, "two double bars are four rules");
assert_eq!(paths(&boxed("\\begin{matrix}a&b\\end{matrix}")) + rules(&boxed("\\begin{matrix}a&b\\end{matrix}")), 0);
}
#[test]
fn matrix_cells_are_centred_in_their_column() {
let b = boxed("\\begin{matrix}abc&d\\\\e&f\\end{matrix}");
let narrow = glyph_x(&b, "e").unwrap_or(f32::NAN);
assert!((narrow - 5.0).abs() < 0.01, "e is centred in a 15 wide column, got {narrow}");
}
#[test]
fn smallmatrix_is_smaller_than_matrix() {
let big = boxed("\\begin{matrix}a&b\\\\c&d\\end{matrix}");
let small = boxed("\\begin{smallmatrix}a&b\\\\c&d\\end{smallmatrix}");
assert!(small.width < big.width && small.height + small.depth < big.height + big.depth);
}
#[test]
fn array_spec_aligns_columns_and_draws_rules() {
let src = "\\begin{array}{l|r}abc&d\\\\e&fgh\\end{array}";
let EnvKind::Array(spec) = kind(src) else { panic!("array expected") };
assert_eq!((spec.cols, spec.align(0), spec.align(1), spec.bars), (2, 1, 2, 0b010));
let b = boxed(src);
assert_eq!(rules(&b), 1);
assert_eq!(glyph_x(&b, "e"), Some(0.0), "left column starts at the edge");
let abc_end = 15.0;
let right = glyph_x(&b, "d").unwrap_or(f32::NAN);
assert!(right > abc_end, "right column starts after the left one, got {right}");
assert_eq!(kind("\\begin{array}{ccc}a&b&c\\end{array}"), EnvKind::Array(notescraft_math::ArraySpec { aligns: 0, cols: 3, bars: 0 }));
assert!(matches!(parse("\\begin{array}a\\end{array}"), Err(Error::Unexpected { .. })), "the column spec is required");
}
#[test]
fn aligned_and_cases_keep_their_alignment() {
let b = boxed("\\begin{align*}ab&=c\\\\abc&=d\\end{align*}");
assert!(glyph_x(&b, "ab").is_some_and(|x| x > 0.0), "first column is right aligned");
assert_eq!(paths(&boxed("\\begin{cases}a\\\\b\\end{cases}")), 1);
}
#[test]
fn spacing_commands_inside_cells_are_not_row_breaks() {
assert!(parse("\\begin{matrix}a\\,b&c\\end{matrix}").is_ok());
match parse("\\begin{matrix}a\\,b&c\\end{matrix}") {
Ok(Node::Row(items)) => assert!(matches!(items.first(), Some(Node::Env { rows, .. }) if rows.len() == 1)),
other => panic!("{other:?}"),
}
}
#[test]
fn an_unknown_command_is_one_error_atom_in_lenient_mode() {
assert!(matches!(parse("x+\\foo y"), Err(Error::Unknown { .. })), "strict mode still fails");
let node = parse_lenient("x+\\foo y").unwrap_or_else(|e| panic!("{e}"));
let b = layout(&node, &FakeMetrics, STYLE);
let bad: Vec<&str> =
b.items.iter().filter_map(|i| if let Item::Glyphs { text, face: Face::Invalid, .. } = i { Some(text.as_str()) } else { None }).collect();
assert_eq!(bad, vec!["\\foo"]);
assert!(b.items.iter().any(|i| matches!(i, Item::Glyphs { face, .. } if *face != Face::Invalid)), "the rest still lays out");
assert!(parse_lenient("é").is_ok(), "a stray character becomes a plain glyph");
assert!(matches!(parse_lenient("\\frac{a"), Err(Error::Unbalanced { .. })), "structural errors stay errors");
}
#[test]
fn multi_line_display_blocks_and_common_commands_parse() {
for src in ["\nx^2 + y^2 = z^2\n", "\n\\int_0^1 x\\,dx\n", "\\frac12", "\r\n a \r\n + b\r\n"] {
let node = parse(src).unwrap_or_else(|e| panic!("{src:?}: {e}"));
assert!(layout(&node, &FakeMetrics, STYLE).width > 0.0, "{src:?}");
}
}