import katex from "katex";
import { describe, expect, it } from "vitest";
import { normalizeMarkdownMath } from "@/lib/markdown/math-normalize";
function mathRegions(md: string): string[] {
return [...md.matchAll(/\$\$[\s\S]*?\$\$|\$[^$\n]+\$/g)].map((m) => m[0]);
}
function renderAll(md: string): void {
for (const region of mathRegions(md)) {
const display = region.startsWith("$$");
const inner = region.slice(
display ? 2 : 1,
region.length - (display ? 2 : 1),
);
katex.renderToString(inner, { throwOnError: true, displayMode: display });
}
}
describe("normalizeMarkdownMath", () => {
it("expands MathJax-style \\newcommand and strips labels", () => {
const out = normalizeMarkdownMath(
"$$\\newcommand{msign}{\\mathop{\\text{msign}}}\\msign(\\boldsymbol{G})\\label{eq:a}$$",
);
expect(out).toContain("\\mathop{\\text{msign}}");
expect(out).not.toContain("\\newcommand");
expect(out).not.toContain("\\label");
expect(() => renderAll(out)).not.toThrow();
});
it("expands document-wide macros in later equations", () => {
const src = [
"$$\\newcommand{msign}{\\mathop{\\text{msign}}}\\msign(\\boldsymbol{G})$$",
"",
"later $$\\boldsymbol{W}^{\\top}\\msign(\\boldsymbol{G}) = \\boldsymbol{0}$$",
].join("\n");
const out = normalizeMarkdownMath(src);
const regions = mathRegions(out);
expect(regions).toHaveLength(2);
expect(regions[1]).toContain("\\mathop{\\text{msign}}");
expect(() => renderAll(out)).not.toThrow();
});
it("tolerates redefinition of KaTeX builtins like argmin", () => {
const src =
"$$\\newcommand{\\argmin}{\\mathop{\\text{argmin}}} \\boldsymbol{p} = \\argmin_{\\boldsymbol{q}} L(\\boldsymbol{p}, \\boldsymbol{q})$$";
const out = normalizeMarkdownMath(src);
expect(out).toContain("\\mathop{\\text{argmin}}_{\\boldsymbol{q}}");
expect(() => renderAll(out)).not.toThrow();
});
it("expands macros used across align cell boundaries", () => {
const src =
"$$\\begin{align}\\newcommand{diag}{\\mathop{\\text{diag}}} a & = \\diag(x) \\\\ b & = \\diag(y)\\end{align}$$";
const out = normalizeMarkdownMath(src);
expect(() => renderAll(out)).not.toThrow();
});
it("repairs htmd-escaped display math from older caches", () => {
const src =
"$$\\\\newcommand{\\\\rs}{\\\\rule\\[-1.2ex\\]{0pt}{3.5ex}} \\\\rs\\\\text{ok} x\\_1$$";
const out = normalizeMarkdownMath(src);
expect(out).toContain("\\rule[-1.2ex]{0pt}{3.5ex}");
expect(out).toContain("\\text{ok}");
expect(out).toContain("x_1");
expect(() => renderAll(out)).not.toThrow();
});
it("strips \\require and degrades \\cancel to its argument", () => {
const src =
"$$\\require{cancel}\\cancel{S(q_i)} = \\cancel{S(q_i)} + q_i$$";
const out = normalizeMarkdownMath(src);
expect(out).not.toContain("\\require");
expect(out).not.toContain("\\cancel");
expect(out).toContain("S(q_i)");
expect(() => renderAll(out)).not.toThrow();
});
it("resolves \\eqref to display equation numbers", () => {
const src =
"$$a = b\\label{eq:a}$$\n\ntext $$c = d\\label{eq:b}$$\n\nsee $\\eqref{eq:a}$ and $\\ref{eq:b}$";
const out = normalizeMarkdownMath(src);
expect(out).toContain("$(1)$");
expect(out).toContain("$(2)$");
expect(out).not.toContain("\\eqref");
expect(() => renderAll(out)).not.toThrow();
});
it("renders blog-style equation sequences without KaTeX errors", () => {
const src = [
"约束简化成",
"$$\\newcommand{tr}{\\mathop{\\text{tr}}}\\max_{\\boldsymbol{\\Phi}} \\tr(\\boldsymbol{G}^{\\top}\\boldsymbol{\\Phi}) \\qquad \\text{s.t.}\\qquad \\Vert\\boldsymbol{\\Phi}\\Vert_2 \\leq 1$$",
"此前我们的求解结果是",
"$$\\boldsymbol{\\Phi} = \\newcommand{msign}{\\mathop{\\text{msign}}}\\msign(\\boldsymbol{G} + \\boldsymbol{W}\\boldsymbol{X})\\label{eq:Phi}$$",
"其中$\\boldsymbol{X}$是对称矩阵,满足",
"$$\\boldsymbol{W}^{\\top}\\msign(\\boldsymbol{G} + \\boldsymbol{W}\\boldsymbol{X})+\\msign(\\boldsymbol{G} + \\boldsymbol{W}\\boldsymbol{X})^{\\top}\\boldsymbol{W} = \\boldsymbol{0}\\label{eq:X}$$",
"即式$\\eqref{eq:Phi}$与式$\\eqref{eq:X}$。",
].join("\n");
const out = normalizeMarkdownMath(src);
expect(out).toContain(
"\\mathop{\\text{msign}}(\\boldsymbol{G} + \\boldsymbol{W}\\boldsymbol{X})",
);
expect(() => renderAll(out)).not.toThrow();
});
it("puts display fences on their own lines so align renders", () => {
const out = normalizeMarkdownMath(
"$$ \\begin{align}1) &\\quad a = b \\\\[5pt] 2) &\\quad c = d\\end{align} $$",
);
expect(out).toContain("$$\n\\begin{align}");
expect(out).toContain("\\end{align}\n$$");
expect(() => renderAll(out)).not.toThrow();
});
it("keeps mid-sentence display math inline so blockquotes survive", () => {
const src =
"> 使得 $$ \\left\\Vert\\begin{bmatrix}\\boldsymbol{A} \\\\ \\boldsymbol{B}\\end{bmatrix}\\right\\Vert_2 \\leq 1 $$";
const out = normalizeMarkdownMath(src);
expect(out).toBe(
"> 使得 $$\\left\\Vert\\begin{bmatrix}\\boldsymbol{A} \\\\ \\boldsymbol{B}\\end{bmatrix}\\right\\Vert_2 \\leq 1$$",
);
const inner = out.slice(out.indexOf("$$") + 2, out.lastIndexOf("$$"));
katex.renderToString(inner, { throwOnError: true, displayMode: false });
});
it("re-fences block-positioned display math inside blockquotes with markers", () => {
const src = "> $$\n> \\begin{align}a & = b \\\\ c & = d\\end{align}\n> $$";
const out = normalizeMarkdownMath(src);
expect(out).toBe(
"> $$\n> \\begin{align}a & = b \\\\ c & = d\\end{align}\n> $$",
);
expect(() => renderAll(out)).not.toThrow();
});
it("leaves plain math and bare commands working", () => {
const out = normalizeMarkdownMath(
"value $\\pi_\\theta$ and $$x^2$$ plain 42",
);
expect(out).toContain("$\\pi_\\theta$");
expect(out).toContain("$$x^2$$");
expect(out).toContain("plain 42");
expect(() => renderAll(out)).not.toThrow();
});
});