Files
codex/codex-rs/tui/src/markdown_render/math_display_tests.rs
T
Eric Traut 7b7d934408 Fix TUI math rendering for zero and big wedge expressions (#48551)
## Why

Inline `$0$` was left unrendered, and expressions containing `\bigwedge`, `\bigl`, or `\bigr` fell back to raw LaTeX.

## What changed

- Allow `$0$` through the inline math detection heuristic.
- Render `\bigwedge` as `⋀`, with limits stacked above and below in display math.
- Handle `\bigl` and `\bigr` like `\left` and `\right`.

## Testing

Add a regression snapshot covering inline zero, inline big wedge, and a multiline display expression with stacked limits and delimiters.

GitOrigin-RevId: 89f596ea5f38070274505cfa7ea757595d7ae137
2026-09-26 22:05:32 +00:00

378 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::MathMarkdown;
use super::render::render;
use crate::markdown_render::render_markdown_text_with_width;
use pretty_assertions::assert_eq;
use pulldown_cmark::Options;
fn plain(source: &str, width: usize) -> String {
render_markdown_text_with_width(source, Some(width))
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn unicode_math_snapshot() {
let source = r"The spectrum is $\lambda_1 \leq \lambda_2$ on $\mathbb{R}^n$.
$$
x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}
$$
$$\frac{1}{2}\frac{3}{4}$$
\[
\int_0^1 x^2\,dx=\frac{1}{3}
\]
$$
x
$$ trailing
$$
Inline \(\alpha^2 + \beta_{10}\), root $\sqrt{x^2+y^2}$,
and fraction $\frac{a}{b}$.
Unsupported: $\begin{matrix}a&b\end{matrix}$.
Code: `$\alpha$`; money: $5.00 and $10.00; shell: $HOME.
```latex
\frac{a}{b}
```
Shell examples: $HOME and echo $$.
After rejected equations: $\alpha$.
$$\beta$$";
insta::assert_snapshot!(plain(source, /*width*/ 80));
}
#[test]
fn issue_48403_renders_zero_and_multiline_display() {
insta::assert_snapshot!(plain(
r"Inline math: $0$; inline operator: $\bigwedge_{j=0}^{n}$
$$
F_{n}(s)\land
\bigwedge_{j=0}^{n}
\bigl(R(\land T \bigr)
\Rightarrow R.
$$",
/*width*/ 80
));
}
#[test]
fn unicode_math_schrodinger_equation_snapshot() {
insta::assert_snapshot!(plain(
r"\[
i\hbar \frac{\partial}{\partial t}\Psi(\mathbf r,t)
=
\hat H\Psi(\mathbf r,t)
=
\left[-\frac{\hbar^2}{2m}\nabla^2+V(\mathbf r,t)\right]\Psi(\mathbf r,t).
\]",
/*width*/ 100
));
}
#[test]
fn unicode_math_optimization_model() {
let source = r"Let \(X_{gpt}=x_{gpt}\), with \(X_{gp,-1}=I_{gp}\).
\[
\boxed{
\begin{aligned}
\underset{z,u,s,m\ge0}{\operatorname{minimize}}\quad
&\sum_{g,t}w_{\pi_{gt}}u_{gt}
+\lambda\sum_t\sum_{(g,p)\in\mathcal J_t}\kappa_{pt}m_{gpt}
+\mu\sum_{(g,t)\in\mathcal Q}s_{gt}
\\[2mm]
\text{subject to}\quad
&\widehat F_{gt}+u_{gt}-s_{gt}=D_{gt}
&&\forall g,t
\\
&\sum_gx_{gpt}\le S_{pt}-B_{pt}
&&\forall p,t
\\
&\sum_{g,p,k}z_{gpk\ell t}\le C_{\ell t}
&&\forall \ell,t
\\
&\sum_{\substack{g\in G_r\\p:\operatorname{region}(p)\in M_r}}x_{gpt}
\ge\sum_{g\in G_r,p}\alpha_{rg}x_{gpt}
&&\forall r,t
\\
&q_aL_{adt}\le L_{at}
&&\forall a,d,t
\\
&m_{gpt}\ge X_{gp,t-1}-X_{gpt}
&&\forall (g,p)\in\mathcal J_t
\end{aligned}}
\]
After the model: \(\alpha^2\).";
let rendered = plain(source, /*width*/ 240);
assert!(!rendered.contains('\\'), "{rendered}");
insta::assert_snapshot!(rendered);
}
#[test]
fn unicode_math_display_limits() {
insta::assert_snapshot!(plain(
r"\[
\underset{x,u,m\ge0}{\operatorname{minimize}}\quad
\sum_{g,t}x_{gpt}+\lambda\sum^{N}_{i=1}y_i
\]
\[
\sum_{i=1}^N\frac{x_i}{y_i}\overset{\text{def}}{=}\underset{j\in J}{\min} z_j
\]
\[
\sum_{j=\sum_i^n i}^N x_j
\]
\[
\sum_{ij}^{100}x+\sum^{ij}_{100}y
\]
\[
\frac{\sum_i x_i}{\sum_j y_j}+\sqrt{\sum_i x_i}+x^{\sum_i a_i}+x_{\underset{i}{\min}}+\sum_k z_k
\]
Inline: \(\sum_{i=1}^N x_i\), \(\underset{x\ge0}{\min} x\).",
/*width*/ 100
));
assert_eq!(
render(r"\sum_i^N x_i", /*display*/ true),
render(r"\sum^N_i x_i", /*display*/ true)
);
for source in [r"\sum_i_j", r"\sum^i^j", r"\sum_", r"\sum^_i"] {
assert_eq!(render(source, /*display*/ true), None, "{source}");
}
let source = r"\[\sum_{i,j,k=1}^{123456789}x_i\]";
assert_eq!(
plain(source, /*width*/ 12),
plain(&format!("`{source}`"), /*width*/ 12)
);
assert_eq!(
render(
&format!(
r"\begin{{aligned}}{}\end{{aligned}}",
r"\sum_i x_i\\".repeat(/*n*/ 9)
),
/*display*/ true
),
None
);
}
#[test]
fn unicode_math_grouped_scripts_and_annotations() {
insta::assert_snapshot!(plain(
r"Indices: \(x_{gpt} + x_{i,j} + w_{\pi_{gt}} + x^{q+1} + x_{g}^2\).
Native scripts: \(x_i^2 + y_{10}\). Sets: \(\mathcal Q_t\).
Annotations: \(\underset{i\in I}{\min} x_i\), \(\overset{\text{def}}{=}\).",
/*width*/ 100
));
}
#[test]
fn unicode_math_aligned_fraction_and_narrow_fallback() {
let formula = r"\[
\begin{aligned}
\frac{x}{z}&=\frac{1}{2} &&\text{first}\\
y_{gpt}&=3 &&\text{second}\\
\end{aligned}
\]";
insta::assert_snapshot!(format!(
"Wide:\n{}\n\nNarrow:\n{}",
plain(formula, /*width*/ 80),
plain(formula, /*width*/ 12)
));
}
#[test]
fn unicode_math_aligned_preserves_fraction_geometry() {
let source = r"\[
\begin{aligned}
\frac{x}{y}+\frac{1}{123456789}&=0\\
\frac{123456789}{1}+\frac{y}{x}&=1
\end{aligned}
\]
\[
\begin{aligned}\frac{a}{b}\end{aligned}=c
\]
\[
\begin{aligned}x&=\begin{aligned}\frac{a}{b}\end{aligned}\\y&=2\end{aligned}
\]";
let rendered = plain(source, /*width*/ 80);
insta::assert_snapshot!(rendered);
let spaced = source
.replace('&', " \n &")
.replace(r"\\", " \n \\\\")
.replace(r"\end{aligned}", " \n \\end{aligned}");
assert_eq!(plain(&spaced, /*width*/ 80), rendered);
}
#[test]
fn unicode_math_structured_bounds_and_invalid_input() {
let source = format!(
r"\begin{{aligned}}\frac{{a}}{{b}}{}&=0\end{{aligned}}",
"x".repeat(/*n*/ 252)
);
let rendered = render(&source, /*display*/ true).expect("fits the layout width limit");
assert_eq!(
render(&source.replace('&', " \n &"), /*display*/ true),
Some(rendered)
);
assert_eq!(
render(
&format!(
r"\begin{{aligned}}{}\end{{aligned}}",
"x&=1\\\\".repeat(/*n*/ 16)
),
/*display*/ true
),
Some(vec!["x =1"; 16].join("\n"))
);
for source in [
r"\begin{aligned}x&=1",
r"\begin{aligned}x&=1\end{matrix}",
r"\begin{aligned}[b]x&=1\end{aligned}",
r"\begin{aligned}x&={a&b}\end{aligned}",
r"\begin{aligned}x&=1\\[bad]y&=2\end{aligned}",
r"\begin{aligned}x&=1\\[NaNmm]y&=2\end{aligned}",
r"\underset{x}",
r"\substack{x&y}",
] {
assert_eq!(render(source, /*display*/ true), None, "{source}");
}
for source in [
format!(
r"\begin{{aligned}}{}\end{{aligned}}",
"x&=1\\\\".repeat(/*n*/ 17)
),
format!(
r"\begin{{aligned}}{}x\end{{aligned}}",
"x&".repeat(/*n*/ 17)
),
format!(r"\boxed{{{}}}", "x".repeat(/*n*/ 254)),
format!("{}x{}", r"\boxed{".repeat(/*n*/ 40), "}".repeat(/*n*/ 40)),
format!(r"\substack{{{}}}", "x\\\\".repeat(/*n*/ 17)),
] {
assert_eq!(render(&source, /*display*/ true), None, "{source}");
}
}
#[test]
fn unicode_math_narrow_layout_stays_meaningful() {
insta::assert_snapshot!(plain(
"$$\\frac{a+b}{c+d}$$\n\nWords $\\alpha^2$ more words.",
/*width*/ 12
));
for width in [80, 14] {
for prefix in [
"- a\n - b\n - c\n\n ",
"- a\n - b\n - c\n",
"> > > Quote\n",
] {
let formula = r"$$\frac{1234567890}{x}$$";
assert_eq!(
plain(&format!("{prefix}{formula}"), width),
plain(&format!("{prefix}`{formula}`"), width)
);
}
}
// A fraction too wide to retain its geometry stays source, using ordinary text wrapping.
assert_eq!(
plain("$$\\frac{abcdefghij}{k}$$", /*width*/ 12),
plain("`$$\\frac{abcdefghij}{k}$$`", /*width*/ 12)
);
}
#[test]
fn unicode_math_pending_display_tracks_original_offset() {
assert_eq!(
MathMarkdown::new("Prose\n\n$$\nx^2\n\n", Options::empty(), Some(80)).pending_start,
Some(7)
);
assert_eq!(
MathMarkdown::new("```\n$$\n", Options::empty(), Some(80)).pending_start,
None
);
assert_eq!(
MathMarkdown::new("echo $$\n", Options::empty(), Some(80)).pending_start,
None
);
}
#[test]
fn unicode_math_nested_fractions_and_oversized_pending_stay_source() {
for source in [r"\frac{\frac{a}{b}}{c}", r"\frac{a}{\frac{b}{c}}"] {
assert_eq!(render(source, /*display*/ true), None);
}
let source = format!("$$\n{}", "x".repeat(/*n*/ 5000));
assert_eq!(
MathMarkdown::new(&source, Options::empty(), Some(80)).pending_start,
None
);
}
#[test]
fn unicode_math_oversized_display_stays_literal() {
for (open, close) in [("$$", "$$"), ("\\[", "\\]")] {
for ending in ["", close] {
let source = format!("{open}\n{}{ending}", "# x\n- y\n".repeat(/*n*/ 600));
assert_eq!(plain(&source, /*width*/ 80), source.trim_end());
}
}
}
#[test]
fn unicode_math_pending_source_and_pid_boundaries() {
for (open, close) in [("$$", "$$"), ("\\[", "\\]")] {
assert_eq!(
plain(&format!("{open}\nx^2\n\n+y^2\n{close}"), /*width*/ 80),
"x² +y²"
);
let source = format!("{open}\nx\n{close} trailing\n{close}\n\nAfter $\\alpha$.");
assert_eq!(
plain(&source, /*width*/ 80),
format!("{open}\nx\n{close} trailing\n{close}\n\nAfter α.")
);
}
for source in ["$$\n# x\n", "$$\n- x\n", "\\[\n# x\n", "\\[\n- x\n"] {
assert_eq!(plain(source, /*width*/ 80), source.trim_end());
}
let source = format!("{}After $\\alpha$.", "prose \\[\n".repeat(/*n*/ 1000));
let math = MathMarkdown::new(&source, Options::empty(), Some(80));
assert_eq!(math.display_ranges.len(), 1);
assert!(math.replacements.is_empty());
assert_eq!(
plain(
"echo $$\n\n$$\nx^2\n$$\n\nAfter $\\alpha$.",
/*width*/ 80
),
"echo $$\n\nx²\n\nAfter α."
);
assert_eq!(
plain(
"$$\nprice=\\$$$\n\nAfter $\\alpha$.\n\n$$\\beta$$",
/*width*/ 80
),
"$$\nprice=\\$$$\n\nAfter α.\n\nβ"
);
}