mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
refactor(engine): move the tests module of math.rs to math/tests.rs
This commit is contained in:
+1
-481
@@ -945,484 +945,4 @@ pub fn to_svg(latex: &str, size: f64) -> (Vec<u8>, (f64, f64)) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input.
|
||||
/// Frozen recursive layout oracle for shallow test fixtures only, never deep input.
|
||||
fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox {
|
||||
match n {
|
||||
Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident),
|
||||
Node::Text(s) => text_box(f, s, size, false),
|
||||
Node::Space(em) => MBox { w: em * size, ..Default::default() },
|
||||
Node::Group(list) => recursive_group_reference(f, list, size),
|
||||
Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size),
|
||||
Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size),
|
||||
Node::Big(op) => recursive_big_reference(f, op, size),
|
||||
Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size),
|
||||
Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle for operator spacing, leading-sign treatment and punctuation gaps.
|
||||
fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox {
|
||||
let mut parts = Vec::new();
|
||||
let mut prev: Option<Class> = None;
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
let cl = match item {
|
||||
Node::Sym(_, c) => Some(*c),
|
||||
Node::Scripts { base, .. } => match &**base {
|
||||
Node::Sym(_, c) => Some(*c),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// A leading + or − (or after another operator) is a sign, not a binary operator.
|
||||
let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) {
|
||||
Some(Class::Plain)
|
||||
} else {
|
||||
cl
|
||||
};
|
||||
let gap = |c: Option<Class>| match c {
|
||||
Some(Class::Bin) => size * 4.0 / 18.0,
|
||||
Some(Class::Rel) => size * 5.0 / 18.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let mut before = gap(cl).max(gap(prev));
|
||||
if k == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
if prev == Some(Class::Punct) {
|
||||
before = before.max(size * 3.0 / 18.0);
|
||||
}
|
||||
parts.push((recursive_layout_reference(f, item, size), before));
|
||||
prev = cl;
|
||||
}
|
||||
hbox(parts)
|
||||
}
|
||||
|
||||
/// Oracle for fraction rule, child scale, math axis, gaps and bounds.
|
||||
fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox {
|
||||
let axis = size * 0.25;
|
||||
let s2 = size * 0.85;
|
||||
let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2));
|
||||
let w = na.w.max(nb.w) + size * 0.2;
|
||||
let t = (size * 0.06).max(0.4);
|
||||
let gap = size * 0.15;
|
||||
let num_base = -(axis + t / 2.0 + gap + na.desc);
|
||||
let den_base = -axis + t / 2.0 + gap + nb.asc;
|
||||
let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() };
|
||||
out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t });
|
||||
let (wa, wb) = (na.w, nb.w);
|
||||
out.els.extend(na.shifted((w - wa) / 2.0, num_base));
|
||||
out.els.extend(nb.shifted((w - wb) / 2.0, den_base));
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for radical geometry, body offset and optional root index.
|
||||
fn recursive_sqrt_reference(f: &Fonts, idx: &Option<Box<Node>>, a: &Node, size: f64) -> MBox {
|
||||
let axis = size * 0.25;
|
||||
let inner = recursive_layout_reference(f, a, size);
|
||||
let t = (size * 0.05).max(0.4);
|
||||
let gap = size * 0.12;
|
||||
let top = inner.asc + gap + t;
|
||||
let bottom = inner.desc;
|
||||
let hook = size * 0.55;
|
||||
let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() };
|
||||
// The radical: a short rise, the long stroke down, then up to the bar.
|
||||
out.els.push(Elem::Line {
|
||||
pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)],
|
||||
w: t,
|
||||
});
|
||||
let iw = inner.w;
|
||||
let _ = iw;
|
||||
out.els.extend(inner.shifted(hook, 0.0));
|
||||
if let Some(i) = idx {
|
||||
let ib = recursive_layout_reference(f, i, size * 0.55);
|
||||
let (ia, iw2) = (ib.asc, ib.w);
|
||||
out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1));
|
||||
out.asc = out.asc.max(axis + size * 0.1 + ia);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for enlarged, math-axis-centred big operators.
|
||||
fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox {
|
||||
let mut b = text_box(f, op, size * 1.5, false);
|
||||
b.asc = size * 1.05;
|
||||
b.desc = size * 0.35;
|
||||
for e in &mut b.els {
|
||||
if let Elem::Text { y, .. } = e {
|
||||
// Centre the glyph on the math axis.
|
||||
*y = size * 0.32;
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Oracle for ordinary sup/sub placement and big-operator limits above and below.
|
||||
fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option<Box<Node>>, sub: &Option<Box<Node>>, size: f64) -> MBox {
|
||||
let big = matches!(base, Node::Big(_));
|
||||
let b = recursive_layout_reference(f, base, size);
|
||||
let s2 = size * 0.7;
|
||||
let (bw, basc, bdesc) = (b.w, b.asc, b.desc);
|
||||
let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els };
|
||||
if big {
|
||||
// Limits above and below.
|
||||
let mut w = bw;
|
||||
if let Some(sp) = sup {
|
||||
let sb = recursive_layout_reference(f, sp, s2);
|
||||
let y = -basc - size * 0.1 - sb.desc;
|
||||
w = w.max(sb.w);
|
||||
let (sw, sa) = (sb.w, sb.asc);
|
||||
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
|
||||
out.asc = out.asc.max(-y + sa);
|
||||
}
|
||||
if let Some(sbn) = sub {
|
||||
let sb = recursive_layout_reference(f, sbn, s2);
|
||||
let y = bdesc + size * 0.1 + sb.asc;
|
||||
w = w.max(sb.w);
|
||||
let (sw, sd) = (sb.w, sb.desc);
|
||||
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
|
||||
out.desc = out.desc.max(y + sd);
|
||||
}
|
||||
// Centre the operator over the widest limit.
|
||||
let dx = (w - bw) / 2.0;
|
||||
let els = std::mem::take(&mut out.els);
|
||||
return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) };
|
||||
}
|
||||
let mut w = bw;
|
||||
if let Some(sp) = sup {
|
||||
let sb = recursive_layout_reference(f, sp, s2);
|
||||
let y = -(size * 0.42).max(basc - sb.asc * 0.6);
|
||||
w = w.max(bw + sb.w + size * 0.05);
|
||||
let sa = sb.asc;
|
||||
out.els.extend(sb.shifted(bw + size * 0.05, y));
|
||||
out.asc = out.asc.max(-y + sa);
|
||||
}
|
||||
if let Some(sbn) = sub {
|
||||
let sb = recursive_layout_reference(f, sbn, s2);
|
||||
let y = (size * 0.2).max(bdesc * 0.8);
|
||||
w = w.max(bw + sb.w + size * 0.05);
|
||||
let sd = sb.desc;
|
||||
out.els.extend(sb.shifted(bw + size * 0.05, y));
|
||||
out.desc = out.desc.max(y + sd);
|
||||
}
|
||||
out.w = w;
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for content-scaled, centred delimiters.
|
||||
fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox {
|
||||
let inner = recursive_layout_reference(f, body, size);
|
||||
let h = (inner.asc + inner.desc).max(size);
|
||||
let k = h / size;
|
||||
let ds = size * k.max(1.0);
|
||||
let mk = |s: &str| -> MBox {
|
||||
if s.is_empty() {
|
||||
return MBox::default();
|
||||
}
|
||||
let mut b = text_box(f, s, ds, false);
|
||||
// Scaled delimiters centred on the content.
|
||||
let mid = (inner.desc - inner.asc) / 2.0;
|
||||
for e in &mut b.els {
|
||||
if let Elem::Text { y, .. } = e {
|
||||
*y = mid + ds * 0.25;
|
||||
}
|
||||
}
|
||||
b.asc = inner.asc.max(size * 0.72);
|
||||
b.desc = inner.desc.max(size * 0.22);
|
||||
b
|
||||
};
|
||||
let (l, r) = (mk(open), mk(close));
|
||||
hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)])
|
||||
}
|
||||
|
||||
/// Asserts fraction/nested-root, big scripts, relation, symbol, fence and raw `\text` parse shapes.
|
||||
#[test]
|
||||
fn parses_the_common_constructs() {
|
||||
let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Frac(_, _)));
|
||||
let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_))));
|
||||
assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel)));
|
||||
assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident)));
|
||||
let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")"));
|
||||
assert!(v.contains(&Node::Text("if ".into())));
|
||||
}
|
||||
|
||||
/// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence.
|
||||
#[test]
|
||||
fn right_prefixed_commands_do_not_close_a_fence() {
|
||||
let n = parse(r"\left( x \rightarrow y \right)");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert_eq!(v.len(), 1, "{v:?}");
|
||||
let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") };
|
||||
assert!(o == "(" && c == ")", "{v:?}");
|
||||
let Node::Group(b) = &**body else { panic!("{v:?}") };
|
||||
assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]);
|
||||
// An unknown `\right…` command stays inside the fence too.
|
||||
let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() };
|
||||
assert_eq!(v.len(), 1, "{v:?}");
|
||||
assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}");
|
||||
// Real closers still close it.
|
||||
for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] {
|
||||
let Node::Group(v) = &parse(src) else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_sizes_and_svg() {
|
||||
let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0);
|
||||
let s = String::from_utf8(svg).unwrap();
|
||||
assert!(s.starts_with("<svg") && s.contains("font-style=\"italic\">E<") && s.contains(">2<"));
|
||||
assert!(w > 30.0 && h > 10.0, "{w} {h}");
|
||||
// A fraction is taller than its numerator alone; a big operator with limits taller still.
|
||||
let (_, (_, h1)) = to_svg("x", 12.0);
|
||||
let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0);
|
||||
let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0);
|
||||
assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}");
|
||||
// The SVG parses and rasterises.
|
||||
let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0);
|
||||
assert!(designcraft_images::svg_tree(&svg).is_some());
|
||||
}
|
||||
|
||||
/// Hostile input must not overflow the stack: nesting past the cap stops descending instead
|
||||
/// of recursing once per group (100k levels used to abort the process).
|
||||
#[test]
|
||||
fn an_over_deep_expression_is_bounded() {
|
||||
for latex in [
|
||||
format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)),
|
||||
format!("{}x", r"\frac".repeat(100_000)),
|
||||
format!("{}x", r"\left(".repeat(100_000)),
|
||||
format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)),
|
||||
format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)),
|
||||
] {
|
||||
let (svg, (w, h)) = to_svg(&latex, 12.0);
|
||||
assert!(!svg.is_empty());
|
||||
assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}");
|
||||
}
|
||||
}
|
||||
/// Count actual tree shape without recursively comparing or formatting a deep AST.
|
||||
fn shape(root: &Node) -> ([usize; 6], usize) {
|
||||
let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z
|
||||
let mut deepest = 0;
|
||||
let mut work = vec![(root, 1)];
|
||||
while let Some((node, depth)) = work.pop() {
|
||||
deepest = deepest.max(depth);
|
||||
match node {
|
||||
Node::Group(nodes) => {
|
||||
counts[0] += 1;
|
||||
work.extend(nodes.iter().map(|node| (node, depth + 1)));
|
||||
}
|
||||
Node::Frac(a, b) => {
|
||||
counts[1] += 1;
|
||||
work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]);
|
||||
}
|
||||
Node::Sqrt(index, body) => {
|
||||
counts[2] += 1;
|
||||
work.push((body, depth + 1));
|
||||
if let Some(index) = index {
|
||||
work.push((index, depth + 1));
|
||||
}
|
||||
}
|
||||
Node::Fenced(_, body, _) => {
|
||||
counts[3] += 1;
|
||||
work.push((body, depth + 1));
|
||||
}
|
||||
Node::Scripts { base, sup, sub } => {
|
||||
counts[4] += 1;
|
||||
work.push((base, depth + 1));
|
||||
if let Some(sup) = sup {
|
||||
work.push((sup, depth + 1));
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
work.push((sub, depth + 1));
|
||||
}
|
||||
}
|
||||
Node::Sym(s, _) if s == "z" => counts[5] += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(counts, deepest)
|
||||
}
|
||||
|
||||
/// With `MAX_NESTING == 256`, all supported 255-level constructs stay nested and still render their sentinel.
|
||||
#[test]
|
||||
fn the_full_256_budget_keeps_deep_valid_formulas_intact() {
|
||||
assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability");
|
||||
let groups = MAX_NESTING - 1;
|
||||
let arguments = (MAX_NESTING - 1) / 2;
|
||||
let index_groups = MAX_NESTING - 3;
|
||||
let cases = [
|
||||
(format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2),
|
||||
(format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2),
|
||||
(format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2),
|
||||
(format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4),
|
||||
];
|
||||
for (latex, counts, depth) in cases {
|
||||
assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings");
|
||||
let (svg, (w, h)) = to_svg(&latex, 12.0);
|
||||
assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}");
|
||||
assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render");
|
||||
}
|
||||
}
|
||||
|
||||
/// A capped list consumes nothing; capped root-index/body cursor behavior and the permissive first-`]` grammar remain unchanged.
|
||||
#[test]
|
||||
fn cap_and_index_returns_preserve_their_exact_cursor_semantics() {
|
||||
let chars: Vec<_> = "x".chars().collect();
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)]));
|
||||
assert_eq!(p.i, 1);
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![]));
|
||||
assert_eq!(p.i, 0, "a capped List does not consume its input");
|
||||
// Both the index subparse and radicand still have their entire old depth allowance.
|
||||
let source = r"\sqrt[x]{y}z";
|
||||
assert_eq!(
|
||||
parse_at(source, MAX_NESTING - 4),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(
|
||||
Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))),
|
||||
Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)]))
|
||||
),
|
||||
Node::Sym("z".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
// At this starting depth the index and braced body are capped. The outer cursor is
|
||||
// restored after ']', then the old non-consuming body cap leaves y/}/z as siblings.
|
||||
let chars: Vec<_> = source.chars().collect();
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(
|
||||
p.parse(MAX_NESTING - 2),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))),
|
||||
Node::Sym("y".into(), Class::Ident),
|
||||
Node::Sym("}".into(), Class::Plain),
|
||||
Node::Sym("z".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
assert_eq!(p.i, chars.len());
|
||||
// Do not change the permissive first-']' index grammar into balanced bracket parsing.
|
||||
assert_eq!(
|
||||
parse(r"\sqrt[x[y]z]q"),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(
|
||||
Some(Box::new(Node::Group(vec![
|
||||
Node::Sym("x".into(), Class::Ident),
|
||||
Node::Sym("[".into(), Class::Plain),
|
||||
Node::Sym("y".into(), Class::Ident)
|
||||
]))),
|
||||
Box::new(Node::Sym("z".into(), Class::Ident))
|
||||
),
|
||||
Node::Sym("]".into(), Class::Plain),
|
||||
Node::Sym("q".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated scripts replace slots; a fence `^` stays literal; incomplete fractions, escaped braces and unknown commands retain the prior grammar.
|
||||
#[test]
|
||||
fn heap_continuations_preserve_small_grammar_and_script_precedence() {
|
||||
let sym = |s: &str| Node::Sym(s.into(), Class::Ident);
|
||||
assert_eq!(
|
||||
parse("x^{a}_{b}^{c}_{d}"),
|
||||
Node::Group(vec![Node::Scripts {
|
||||
base: Box::new(sym("x")),
|
||||
sup: Some(Box::new(Node::Group(vec![sym("c")]))),
|
||||
sub: Some(Box::new(Node::Group(vec![sym("d")]))),
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parse(r"\left( ^x \right)"),
|
||||
Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())])
|
||||
);
|
||||
assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))]));
|
||||
assert_eq!(
|
||||
parse(r"\text{a{b}}\{\}\_\unknown"),
|
||||
Node::Group(vec![
|
||||
Node::Text("a{b}".into()),
|
||||
Node::Sym("{".into(), Class::Punct),
|
||||
Node::Sym("}".into(), Class::Punct),
|
||||
Node::Sym("_".into(), Class::Plain),
|
||||
Node::Text(r"\unknown".into())
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// Iterative layout equals the frozen recursive oracle for representative formulas, including malformed inputs.
|
||||
#[test]
|
||||
fn heap_layout_preserves_the_existing_element_order_and_geometry() {
|
||||
let db = FontDb::global();
|
||||
let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") };
|
||||
for latex in [
|
||||
"E = mc^2",
|
||||
"x^{a}_{b}^{c}_{d}",
|
||||
r"\frac{a^2+b}{\sqrt[3]{x}}",
|
||||
r"\sum_{i=0}^{n} i^2",
|
||||
r"\int_a^b x",
|
||||
r"\left( x \rightarrow y \right) + \text{if } x",
|
||||
r"\left. x \right]",
|
||||
r"-x,+y;\quad z",
|
||||
r"\sqrt[x[y]z]q",
|
||||
r"\frac{x",
|
||||
r"\left( ^x \right)",
|
||||
] {
|
||||
let tree = parse(latex);
|
||||
let actual = layout(&f, &tree, 12.0);
|
||||
let expected = recursive_layout_reference(&f, &tree, 12.0);
|
||||
assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a deep five-way alternating AST for destructor stress tests.
|
||||
fn mixed_chain(depth: usize) -> Node {
|
||||
let mut node = Node::Sym("z".into(), Class::Ident);
|
||||
for i in 0..depth {
|
||||
node = match i % 5 {
|
||||
0 => Node::Group(vec![node]),
|
||||
1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))),
|
||||
2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)),
|
||||
3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) },
|
||||
_ => Node::Fenced("(".into(), Box::new(node), ")".into()),
|
||||
};
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Drops 50k-node chains, replaces one script slot, and unwinds parser continuations while preserving the retained slot.
|
||||
#[test]
|
||||
fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() {
|
||||
// Far beyond the parser budget: this directly proves the private destructor's traversal.
|
||||
drop(mixed_chain(50_000));
|
||||
for up in [false, true] {
|
||||
let old = Box::new(mixed_chain(50_000));
|
||||
let other = Box::new(Node::Text("other".into()));
|
||||
let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) };
|
||||
let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up);
|
||||
let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") };
|
||||
assert!(matches!(&**base, Node::Big(s) if s == "∑"));
|
||||
let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) };
|
||||
assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new"));
|
||||
assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other"));
|
||||
}
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
// Actual continuation variants own completed children while awaiting later input.
|
||||
let _partial = [
|
||||
ParseWork::FractionDenominator { numerator: mixed_chain(25_000) },
|
||||
ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) },
|
||||
];
|
||||
panic!("exercise partial-parser-state unwinding");
|
||||
});
|
||||
assert!(result.is_err(), "the intentional panic must actually unwind the owned frames");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
use super::*;
|
||||
|
||||
// Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input.
|
||||
/// Frozen recursive layout oracle for shallow test fixtures only, never deep input.
|
||||
fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox {
|
||||
match n {
|
||||
Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident),
|
||||
Node::Text(s) => text_box(f, s, size, false),
|
||||
Node::Space(em) => MBox { w: em * size, ..Default::default() },
|
||||
Node::Group(list) => recursive_group_reference(f, list, size),
|
||||
Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size),
|
||||
Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size),
|
||||
Node::Big(op) => recursive_big_reference(f, op, size),
|
||||
Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size),
|
||||
Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle for operator spacing, leading-sign treatment and punctuation gaps.
|
||||
fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox {
|
||||
let mut parts = Vec::new();
|
||||
let mut prev: Option<Class> = None;
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
let cl = match item {
|
||||
Node::Sym(_, c) => Some(*c),
|
||||
Node::Scripts { base, .. } => match &**base {
|
||||
Node::Sym(_, c) => Some(*c),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// A leading + or − (or after another operator) is a sign, not a binary operator.
|
||||
let cl =
|
||||
if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { Some(Class::Plain) } else { cl };
|
||||
let gap = |c: Option<Class>| match c {
|
||||
Some(Class::Bin) => size * 4.0 / 18.0,
|
||||
Some(Class::Rel) => size * 5.0 / 18.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let mut before = gap(cl).max(gap(prev));
|
||||
if k == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
if prev == Some(Class::Punct) {
|
||||
before = before.max(size * 3.0 / 18.0);
|
||||
}
|
||||
parts.push((recursive_layout_reference(f, item, size), before));
|
||||
prev = cl;
|
||||
}
|
||||
hbox(parts)
|
||||
}
|
||||
|
||||
/// Oracle for fraction rule, child scale, math axis, gaps and bounds.
|
||||
fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox {
|
||||
let axis = size * 0.25;
|
||||
let s2 = size * 0.85;
|
||||
let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2));
|
||||
let w = na.w.max(nb.w) + size * 0.2;
|
||||
let t = (size * 0.06).max(0.4);
|
||||
let gap = size * 0.15;
|
||||
let num_base = -(axis + t / 2.0 + gap + na.desc);
|
||||
let den_base = -axis + t / 2.0 + gap + nb.asc;
|
||||
let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() };
|
||||
out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t });
|
||||
let (wa, wb) = (na.w, nb.w);
|
||||
out.els.extend(na.shifted((w - wa) / 2.0, num_base));
|
||||
out.els.extend(nb.shifted((w - wb) / 2.0, den_base));
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for radical geometry, body offset and optional root index.
|
||||
fn recursive_sqrt_reference(f: &Fonts, idx: &Option<Box<Node>>, a: &Node, size: f64) -> MBox {
|
||||
let axis = size * 0.25;
|
||||
let inner = recursive_layout_reference(f, a, size);
|
||||
let t = (size * 0.05).max(0.4);
|
||||
let gap = size * 0.12;
|
||||
let top = inner.asc + gap + t;
|
||||
let bottom = inner.desc;
|
||||
let hook = size * 0.55;
|
||||
let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() };
|
||||
// The radical: a short rise, the long stroke down, then up to the bar.
|
||||
out.els.push(Elem::Line {
|
||||
pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)],
|
||||
w: t,
|
||||
});
|
||||
let iw = inner.w;
|
||||
let _ = iw;
|
||||
out.els.extend(inner.shifted(hook, 0.0));
|
||||
if let Some(i) = idx {
|
||||
let ib = recursive_layout_reference(f, i, size * 0.55);
|
||||
let (ia, iw2) = (ib.asc, ib.w);
|
||||
out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1));
|
||||
out.asc = out.asc.max(axis + size * 0.1 + ia);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for enlarged, math-axis-centred big operators.
|
||||
fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox {
|
||||
let mut b = text_box(f, op, size * 1.5, false);
|
||||
b.asc = size * 1.05;
|
||||
b.desc = size * 0.35;
|
||||
for e in &mut b.els {
|
||||
if let Elem::Text { y, .. } = e {
|
||||
// Centre the glyph on the math axis.
|
||||
*y = size * 0.32;
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Oracle for ordinary sup/sub placement and big-operator limits above and below.
|
||||
fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option<Box<Node>>, sub: &Option<Box<Node>>, size: f64) -> MBox {
|
||||
let big = matches!(base, Node::Big(_));
|
||||
let b = recursive_layout_reference(f, base, size);
|
||||
let s2 = size * 0.7;
|
||||
let (bw, basc, bdesc) = (b.w, b.asc, b.desc);
|
||||
let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els };
|
||||
if big {
|
||||
// Limits above and below.
|
||||
let mut w = bw;
|
||||
if let Some(sp) = sup {
|
||||
let sb = recursive_layout_reference(f, sp, s2);
|
||||
let y = -basc - size * 0.1 - sb.desc;
|
||||
w = w.max(sb.w);
|
||||
let (sw, sa) = (sb.w, sb.asc);
|
||||
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
|
||||
out.asc = out.asc.max(-y + sa);
|
||||
}
|
||||
if let Some(sbn) = sub {
|
||||
let sb = recursive_layout_reference(f, sbn, s2);
|
||||
let y = bdesc + size * 0.1 + sb.asc;
|
||||
w = w.max(sb.w);
|
||||
let (sw, sd) = (sb.w, sb.desc);
|
||||
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
|
||||
out.desc = out.desc.max(y + sd);
|
||||
}
|
||||
// Centre the operator over the widest limit.
|
||||
let dx = (w - bw) / 2.0;
|
||||
let els = std::mem::take(&mut out.els);
|
||||
return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) };
|
||||
}
|
||||
let mut w = bw;
|
||||
if let Some(sp) = sup {
|
||||
let sb = recursive_layout_reference(f, sp, s2);
|
||||
let y = -(size * 0.42).max(basc - sb.asc * 0.6);
|
||||
w = w.max(bw + sb.w + size * 0.05);
|
||||
let sa = sb.asc;
|
||||
out.els.extend(sb.shifted(bw + size * 0.05, y));
|
||||
out.asc = out.asc.max(-y + sa);
|
||||
}
|
||||
if let Some(sbn) = sub {
|
||||
let sb = recursive_layout_reference(f, sbn, s2);
|
||||
let y = (size * 0.2).max(bdesc * 0.8);
|
||||
w = w.max(bw + sb.w + size * 0.05);
|
||||
let sd = sb.desc;
|
||||
out.els.extend(sb.shifted(bw + size * 0.05, y));
|
||||
out.desc = out.desc.max(y + sd);
|
||||
}
|
||||
out.w = w;
|
||||
out
|
||||
}
|
||||
|
||||
/// Oracle for content-scaled, centred delimiters.
|
||||
fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox {
|
||||
let inner = recursive_layout_reference(f, body, size);
|
||||
let h = (inner.asc + inner.desc).max(size);
|
||||
let k = h / size;
|
||||
let ds = size * k.max(1.0);
|
||||
let mk = |s: &str| -> MBox {
|
||||
if s.is_empty() {
|
||||
return MBox::default();
|
||||
}
|
||||
let mut b = text_box(f, s, ds, false);
|
||||
// Scaled delimiters centred on the content.
|
||||
let mid = (inner.desc - inner.asc) / 2.0;
|
||||
for e in &mut b.els {
|
||||
if let Elem::Text { y, .. } = e {
|
||||
*y = mid + ds * 0.25;
|
||||
}
|
||||
}
|
||||
b.asc = inner.asc.max(size * 0.72);
|
||||
b.desc = inner.desc.max(size * 0.22);
|
||||
b
|
||||
};
|
||||
let (l, r) = (mk(open), mk(close));
|
||||
hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)])
|
||||
}
|
||||
|
||||
/// Asserts fraction/nested-root, big scripts, relation, symbol, fence and raw `\text` parse shapes.
|
||||
#[test]
|
||||
fn parses_the_common_constructs() {
|
||||
let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Frac(_, _)));
|
||||
let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_))));
|
||||
assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel)));
|
||||
assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident)));
|
||||
let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")"));
|
||||
assert!(v.contains(&Node::Text("if ".into())));
|
||||
}
|
||||
|
||||
/// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence.
|
||||
#[test]
|
||||
fn right_prefixed_commands_do_not_close_a_fence() {
|
||||
let n = parse(r"\left( x \rightarrow y \right)");
|
||||
let Node::Group(v) = &n else { panic!() };
|
||||
assert_eq!(v.len(), 1, "{v:?}");
|
||||
let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") };
|
||||
assert!(o == "(" && c == ")", "{v:?}");
|
||||
let Node::Group(b) = &**body else { panic!("{v:?}") };
|
||||
assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]);
|
||||
// An unknown `\right…` command stays inside the fence too.
|
||||
let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() };
|
||||
assert_eq!(v.len(), 1, "{v:?}");
|
||||
assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}");
|
||||
// Real closers still close it.
|
||||
for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] {
|
||||
let Node::Group(v) = &parse(src) else { panic!() };
|
||||
assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_sizes_and_svg() {
|
||||
let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0);
|
||||
let s = String::from_utf8(svg).unwrap();
|
||||
assert!(s.starts_with("<svg") && s.contains("font-style=\"italic\">E<") && s.contains(">2<"));
|
||||
assert!(w > 30.0 && h > 10.0, "{w} {h}");
|
||||
// A fraction is taller than its numerator alone; a big operator with limits taller still.
|
||||
let (_, (_, h1)) = to_svg("x", 12.0);
|
||||
let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0);
|
||||
let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0);
|
||||
assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}");
|
||||
// The SVG parses and rasterises.
|
||||
let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0);
|
||||
assert!(designcraft_images::svg_tree(&svg).is_some());
|
||||
}
|
||||
|
||||
/// Hostile input must not overflow the stack: nesting past the cap stops descending instead
|
||||
/// of recursing once per group (100k levels used to abort the process).
|
||||
#[test]
|
||||
fn an_over_deep_expression_is_bounded() {
|
||||
for latex in [
|
||||
format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)),
|
||||
format!("{}x", r"\frac".repeat(100_000)),
|
||||
format!("{}x", r"\left(".repeat(100_000)),
|
||||
format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)),
|
||||
format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)),
|
||||
] {
|
||||
let (svg, (w, h)) = to_svg(&latex, 12.0);
|
||||
assert!(!svg.is_empty());
|
||||
assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}");
|
||||
}
|
||||
}
|
||||
/// Count actual tree shape without recursively comparing or formatting a deep AST.
|
||||
fn shape(root: &Node) -> ([usize; 6], usize) {
|
||||
let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z
|
||||
let mut deepest = 0;
|
||||
let mut work = vec![(root, 1)];
|
||||
while let Some((node, depth)) = work.pop() {
|
||||
deepest = deepest.max(depth);
|
||||
match node {
|
||||
Node::Group(nodes) => {
|
||||
counts[0] += 1;
|
||||
work.extend(nodes.iter().map(|node| (node, depth + 1)));
|
||||
}
|
||||
Node::Frac(a, b) => {
|
||||
counts[1] += 1;
|
||||
work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]);
|
||||
}
|
||||
Node::Sqrt(index, body) => {
|
||||
counts[2] += 1;
|
||||
work.push((body, depth + 1));
|
||||
if let Some(index) = index {
|
||||
work.push((index, depth + 1));
|
||||
}
|
||||
}
|
||||
Node::Fenced(_, body, _) => {
|
||||
counts[3] += 1;
|
||||
work.push((body, depth + 1));
|
||||
}
|
||||
Node::Scripts { base, sup, sub } => {
|
||||
counts[4] += 1;
|
||||
work.push((base, depth + 1));
|
||||
if let Some(sup) = sup {
|
||||
work.push((sup, depth + 1));
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
work.push((sub, depth + 1));
|
||||
}
|
||||
}
|
||||
Node::Sym(s, _) if s == "z" => counts[5] += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(counts, deepest)
|
||||
}
|
||||
|
||||
/// With `MAX_NESTING == 256`, all supported 255-level constructs stay nested and still render their sentinel.
|
||||
#[test]
|
||||
fn the_full_256_budget_keeps_deep_valid_formulas_intact() {
|
||||
assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability");
|
||||
let groups = MAX_NESTING - 1;
|
||||
let arguments = (MAX_NESTING - 1) / 2;
|
||||
let index_groups = MAX_NESTING - 3;
|
||||
let cases = [
|
||||
(format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2),
|
||||
(format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2),
|
||||
(format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2),
|
||||
(format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2),
|
||||
(format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4),
|
||||
];
|
||||
for (latex, counts, depth) in cases {
|
||||
assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings");
|
||||
let (svg, (w, h)) = to_svg(&latex, 12.0);
|
||||
assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}");
|
||||
assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render");
|
||||
}
|
||||
}
|
||||
|
||||
/// A capped list consumes nothing; capped root-index/body cursor behavior and the permissive first-`]` grammar remain unchanged.
|
||||
#[test]
|
||||
fn cap_and_index_returns_preserve_their_exact_cursor_semantics() {
|
||||
let chars: Vec<_> = "x".chars().collect();
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)]));
|
||||
assert_eq!(p.i, 1);
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![]));
|
||||
assert_eq!(p.i, 0, "a capped List does not consume its input");
|
||||
// Both the index subparse and radicand still have their entire old depth allowance.
|
||||
let source = r"\sqrt[x]{y}z";
|
||||
assert_eq!(
|
||||
parse_at(source, MAX_NESTING - 4),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(
|
||||
Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))),
|
||||
Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)]))
|
||||
),
|
||||
Node::Sym("z".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
// At this starting depth the index and braced body are capped. The outer cursor is
|
||||
// restored after ']', then the old non-consuming body cap leaves y/}/z as siblings.
|
||||
let chars: Vec<_> = source.chars().collect();
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
assert_eq!(
|
||||
p.parse(MAX_NESTING - 2),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))),
|
||||
Node::Sym("y".into(), Class::Ident),
|
||||
Node::Sym("}".into(), Class::Plain),
|
||||
Node::Sym("z".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
assert_eq!(p.i, chars.len());
|
||||
// Do not change the permissive first-']' index grammar into balanced bracket parsing.
|
||||
assert_eq!(
|
||||
parse(r"\sqrt[x[y]z]q"),
|
||||
Node::Group(vec![
|
||||
Node::Sqrt(
|
||||
Some(Box::new(Node::Group(vec![
|
||||
Node::Sym("x".into(), Class::Ident),
|
||||
Node::Sym("[".into(), Class::Plain),
|
||||
Node::Sym("y".into(), Class::Ident)
|
||||
]))),
|
||||
Box::new(Node::Sym("z".into(), Class::Ident))
|
||||
),
|
||||
Node::Sym("]".into(), Class::Plain),
|
||||
Node::Sym("q".into(), Class::Ident),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated scripts replace slots; a fence `^` stays literal; incomplete fractions, escaped braces and unknown commands retain the prior grammar.
|
||||
#[test]
|
||||
fn heap_continuations_preserve_small_grammar_and_script_precedence() {
|
||||
let sym = |s: &str| Node::Sym(s.into(), Class::Ident);
|
||||
assert_eq!(
|
||||
parse("x^{a}_{b}^{c}_{d}"),
|
||||
Node::Group(vec![Node::Scripts {
|
||||
base: Box::new(sym("x")),
|
||||
sup: Some(Box::new(Node::Group(vec![sym("c")]))),
|
||||
sub: Some(Box::new(Node::Group(vec![sym("d")]))),
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parse(r"\left( ^x \right)"),
|
||||
Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())])
|
||||
);
|
||||
assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))]));
|
||||
assert_eq!(
|
||||
parse(r"\text{a{b}}\{\}\_\unknown"),
|
||||
Node::Group(vec![
|
||||
Node::Text("a{b}".into()),
|
||||
Node::Sym("{".into(), Class::Punct),
|
||||
Node::Sym("}".into(), Class::Punct),
|
||||
Node::Sym("_".into(), Class::Plain),
|
||||
Node::Text(r"\unknown".into())
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// Iterative layout equals the frozen recursive oracle for representative formulas, including malformed inputs.
|
||||
#[test]
|
||||
fn heap_layout_preserves_the_existing_element_order_and_geometry() {
|
||||
let db = FontDb::global();
|
||||
let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") };
|
||||
for latex in [
|
||||
"E = mc^2",
|
||||
"x^{a}_{b}^{c}_{d}",
|
||||
r"\frac{a^2+b}{\sqrt[3]{x}}",
|
||||
r"\sum_{i=0}^{n} i^2",
|
||||
r"\int_a^b x",
|
||||
r"\left( x \rightarrow y \right) + \text{if } x",
|
||||
r"\left. x \right]",
|
||||
r"-x,+y;\quad z",
|
||||
r"\sqrt[x[y]z]q",
|
||||
r"\frac{x",
|
||||
r"\left( ^x \right)",
|
||||
] {
|
||||
let tree = parse(latex);
|
||||
let actual = layout(&f, &tree, 12.0);
|
||||
let expected = recursive_layout_reference(&f, &tree, 12.0);
|
||||
assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a deep five-way alternating AST for destructor stress tests.
|
||||
fn mixed_chain(depth: usize) -> Node {
|
||||
let mut node = Node::Sym("z".into(), Class::Ident);
|
||||
for i in 0..depth {
|
||||
node = match i % 5 {
|
||||
0 => Node::Group(vec![node]),
|
||||
1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))),
|
||||
2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)),
|
||||
3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) },
|
||||
_ => Node::Fenced("(".into(), Box::new(node), ")".into()),
|
||||
};
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Drops 50k-node chains, replaces one script slot, and unwinds parser continuations while preserving the retained slot.
|
||||
#[test]
|
||||
fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() {
|
||||
// Far beyond the parser budget: this directly proves the private destructor's traversal.
|
||||
drop(mixed_chain(50_000));
|
||||
for up in [false, true] {
|
||||
let old = Box::new(mixed_chain(50_000));
|
||||
let other = Box::new(Node::Text("other".into()));
|
||||
let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) };
|
||||
let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up);
|
||||
let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") };
|
||||
assert!(matches!(&**base, Node::Big(s) if s == "∑"));
|
||||
let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) };
|
||||
assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new"));
|
||||
assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other"));
|
||||
}
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
// Actual continuation variants own completed children while awaiting later input.
|
||||
let _partial = [
|
||||
ParseWork::FractionDenominator { numerator: mixed_chain(25_000) },
|
||||
ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) },
|
||||
];
|
||||
panic!("exercise partial-parser-state unwinding");
|
||||
});
|
||||
assert!(result.is_err(), "the intentional panic must actually unwind the owned frames");
|
||||
}
|
||||
Reference in New Issue
Block a user