Files
scriptorium/internal/mathml/mathml_test.go
T

190 lines
8.1 KiB
Go
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.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package mathml
import (
"bytes"
"encoding/xml"
"testing"
)
// corpus is the project's own hand-written corpus: every case states a TeX
// input and the exact MathML Core the engine produces for it.
var corpus = []struct {
name string
input string
want string
}{
{"single letter", "x", `<mi>x</mi>`},
{"digits", "42", `<mn>42</mn>`},
{"decimal", "1.5", `<mn>1.5</mn>`},
{"sum", "a+b", `<mi>a</mi><mo>+</mo><mi>b</mi>`},
{"greek", `\alpha + \beta`, `<mi>α</mi><mo>+</mo><mi>β</mi>`},
{"uppercase greek", `\Gamma`, `<mi>Γ</mi>`},
{"relation", `a \leq b`, `<mi>a</mi><mo>≤</mo><mi>b</mi>`},
{"operator with limits", `\sum_{i=1}^{n} i`,
`<msubsup><mo movablelimits="true">∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup><mi>i</mi>`},
{"integral keeps side scripts", `\int_0^1 x`,
`<msubsup><mo>∫</mo><mn>0</mn><mn>1</mn></msubsup><mi>x</mi>`},
{"fraction", `\frac{a}{b}`, `<mfrac><mi>a</mi><mi>b</mi></mfrac>`},
{"fraction shorthand", `\frac12`, `<mfrac><mn>1</mn><mn>2</mn></mfrac>`},
{"binom", `\binom{n}{k}`,
`<mrow><mo fence="true" stretchy="true">(</mo><mfrac linethickness="0em"><mi>n</mi><mi>k</mi></mfrac><mo fence="true" stretchy="true">)</mo></mrow>`},
{"over infix", `a \over b`, `<mfrac><mi>a</mi><mi>b</mi></mfrac>`},
{"choose infix", `a \choose b`,
`<mrow><mo fence="true" stretchy="true">(</mo><mfrac linethickness="0em"><mi>a</mi><mi>b</mi></mfrac><mo fence="true" stretchy="true">)</mo></mrow>`},
{"sqrt", `\sqrt{x}`, `<msqrt><mi>x</mi></msqrt>`},
{"root with index", `\sqrt[3]{x}`, `<mroot><mi>x</mi><mn>3</mn></mroot>`},
{"sub and sup", `x_1^2`, `<msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup>`},
{"single digit script", `x^10`, `<msup><mi>x</mi><mn>1</mn></msup><mn>0</mn>`},
{"group", `{xy}`, `<mrow><mi>x</mi><mi>y</mi></mrow>`},
{"left right", `\left( x \right)`,
`<mrow><mo fence="true" stretchy="true">(</mo><mi>x</mi><mo fence="true" stretchy="true">)</mo></mrow>`},
{"left right invisible", `\left. x \right)`,
`<mrow><mo fence="true"></mo><mi>x</mi><mo fence="true" stretchy="true">)</mo></mrow>`},
{"function", `\sin x`, `<mi>sin</mi><mi>x</mi>`},
{"limit under", `\lim_{x \to 0}`,
`<msub><mi movablelimits="true">lim</mi><mrow><mi>x</mi><mo>→</mo><mn>0</mn></mrow></msub>`},
{"accent", `\hat{x}`, `<mover><mi>x</mi><mo stretchy="false">^</mo></mover>`},
{"vec accent", `\vec{v}`, `<mover><mi>v</mi><mo stretchy="false">→</mo></mover>`},
{"overline", `\overline{x}`, `<mover><mi>x</mi><mo stretchy="true">‾</mo></mover>`},
{"text", `\text{if}`, `<mtext>if</mtext>`},
{"text keeps spaces", `\text{hello world}`, `<mtext>hello world</mtext>`},
{"mathrm", `\mathrm{d}x`, `<mi mathvariant="normal">d</mi><mi>x</mi>`},
{"mathbb", `\mathbb{R}`, `<mi mathvariant="double-struck">R</mi>`},
{"operatorname", `\operatorname{sgn}`, `<mi>sgn</mi>`},
{"spacing", `a \, b`, `<mi>a</mi><mspace width="0.1667em"></mspace><mi>b</mi>`},
{"quad", `a \quad b`, `<mi>a</mi><mspace width="1em"></mspace><mi>b</mi>`},
{"prime", `x'`, `<mi>x</mi><mo>′</mo>`},
{"escaped brace", `\{x\}`, `<mo>{</mo><mi>x</mi><mo>}</mo>`},
{"overset", `\overset{a}{b}`, `<mover><mi>b</mi><mi>a</mi></mover>`},
{"matrix", `\begin{matrix} a & b \\ c & d \end{matrix}`,
`<mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable>`},
{"pmatrix", `\begin{pmatrix} a \\ b \end{pmatrix}`,
`<mrow><mo fence="true" stretchy="true">(</mo><mtable><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable><mo fence="true" stretchy="true">)</mo></mrow>`},
{"cases", `\begin{cases} a & b \\ c & d \end{cases}`,
`<mrow><mo fence="true" stretchy="true">{</mo><mtable columnalign="left"><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable><mo fence="true"></mo></mrow>`},
{"aligned", `\begin{aligned} a &= b \\ c &= d \end{aligned}`,
`<mtable columnalign="right left"><mtr><mtd><mi>a</mi></mtd><mtd><mo>=</mo><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mo>=</mo><mi>d</mi></mtd></mtr></mtable>`},
{"array spec", `\begin{array}{c|l} a & b \end{array}`,
`<mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr></mtable>`},
{"macro", `\newcommand{\R}{\mathbb{R}} \R`,
`<mi mathvariant="double-struck">R</mi>`},
{"macro with argument", `\newcommand{\ip}[2]{\langle #1, #2 \rangle} \ip{a}{b}`,
`<mo>⟨</mo><mi>a</mi><mo>,</mo><mi>b</mi><mo>⟩</mo>`},
{"def", `\def\dx{\mathrm{d}x} \dx`,
`<mi mathvariant="normal">d</mi><mi>x</mi>`},
{"escaping in text", `\text{a < b & c}`,
`<mtext>a &lt; b &amp; c</mtext>`},
{"unicode letter", `λ`, `<mi>λ</mi>`},
{"pmod", `x \pmod n`,
`<mi>x</mi><mrow><mo fence="true" stretchy="true">(</mo><mrow><mi>mod</mi><mspace width="0.2778em"></mspace><mi>n</mi></mrow><mo fence="true" stretchy="true">)</mo></mrow>`},
{"colon relation precomposed", `f \coloneqq g`,
`<mi>f</mi><mo>≔</mo><mi>g</mi>`},
{"colon relation linear", `f \coloneq g`,
`<mi>f</mi><mo>:−</mo><mi>g</mi>`},
{"colon relation double", `f \Coloneqq g`,
`<mi>f</mi><mo>∷=</mo><mi>g</mi>`},
{"eqqcolon", `a \eqqcolon b`,
`<mi>a</mi><mo>≕</mo><mi>b</mi>`},
}
func TestCorpus(t *testing.T) {
for _, tc := range corpus {
t.Run(tc.name, func(t *testing.T) {
got := string(Render([]byte(tc.input), false))
want := `<math xmlns="http://www.w3.org/1998/Math/MathML">` + tc.want + `</math>`
if got != want {
t.Errorf("input %q\ngot: %s\nwant: %s", tc.input, got, want)
}
})
}
}
// degrade holds the inputs whose constructs lie outside the mappable
// surface: nothing may disappear, everything stays as verbatim source in
// a marked element.
var degrade = []struct {
name string
input string
}{
{"unknown command", `\tikz{x}`},
{"unsupported environment", `\begin{tikzpicture} \draw (0,0); \end{tikzpicture}`},
{"unclosed group", `{x`},
{"unclosed environment", `\begin{matrix} a \end{pmatrix}`},
{"reserved character", `a # b`},
{"stray ampersand", `a & b`},
{"stray row end", `a \\ b`},
{"recursive macro", `\newcommand{\x}{\x}\x`},
{"boxed", `\boxed{x}`},
{"sideset", `\sideset{_a^b}{_c^d}\sum`},
{"tag", `\tag{1} x`},
}
func TestDegradation(t *testing.T) {
for _, tc := range degrade {
t.Run(tc.name, func(t *testing.T) {
out := Render([]byte(tc.input), false)
if !bytes.Contains(out, []byte("<merror>")) {
t.Errorf("input %q produced no merror:\n%s", tc.input, out)
}
if !wellFormed(out) {
t.Errorf("input %q produced malformed XML:\n%s", tc.input, out)
}
})
}
}
func TestVerbatimSourceKept(t *testing.T) {
out := string(Render([]byte(`a + \unknowncmd b`), false))
want := `<merror><mtext>\unknowncmd</mtext></merror>`
if !bytes.Contains([]byte(out), []byte(want)) {
t.Errorf("degraded construct lost its source:\n%s", out)
}
}
func TestDeterministicOutput(t *testing.T) {
source := []byte(`\frac{1}{2}\sqrt[3]{x}\begin{pmatrix} a & b \\ c & d \end{pmatrix}\sum_{i=1}^{n} i`)
first := Render(source, true)
for range 5 {
if next := Render(source, true); !bytes.Equal(first, next) {
t.Fatal("Render of the same source differs between calls")
}
}
}
// wellFormed checks that the output parses as XML.
func wellFormed(out []byte) bool {
dec := xml.NewDecoder(bytes.NewReader(out))
for {
_, err := dec.Token()
if err != nil {
return err.Error() == "EOF"
}
}
}
func TestAllCorpusWellFormed(t *testing.T) {
for _, tc := range corpus {
if out := Render([]byte(tc.input), false); !wellFormed(out) {
t.Errorf("input %q produced malformed XML:\n%s", tc.input, out)
}
}
}
func TestDisplayBlock(t *testing.T) {
if got := string(Render([]byte("x"), true)); got != `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>x</mi></math>` {
t.Errorf("display form = %s", got)
}
}
func TestMacroDepthBounded(t *testing.T) {
// A macro that doubles itself grows past any depth limit; the parser
// must degrade rather than hang or exhaust memory.
out := Render([]byte(`\newcommand{\a}{\a\a}`+"\n"+`\a`), false)
if !bytes.Contains(out, []byte("<merror>")) {
t.Errorf("unbounded macro produced no merror:\n%s", out)
}
}