返回 DeepSeek-Reasonix
math-golden.test.ts
根目录 / desktop / frontend / src / __tests__ / math-golden.test.ts
1 // Golden-case verification for the math rendering pipeline.
2 //
3 // Run: tsx src/__tests__/math-golden.test.ts
4 //
5 // We import the *production* modules (mathNormalize, latexNormalize,
6 // mathClassify) rather than reimplementing them inline, so this file
7 // catches regressions in the actual code path that runs inside <Markdown>.
8
9 import { createElement } from "react";
10 import { renderToStaticMarkup } from "react-dom/server";
11 import ReactMarkdown from "react-markdown";
12 import katex from "katex";
13 import { latexNormalizeForKatex, stripMathDelimiters } from "../components/latexNormalize";
14 import { classifyInlineMath, isLikelyInlineMath } from "../components/mathClassify";
15 import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
16 import {
17 normalizeMath,
18 resolveProtectedInlineMathSource,
19 restoreProtectedInlineMathSource,
20 } from "../components/mathNormalize";
21 import { expandYoungDiagrams } from "../components/youngDiagrams";
22
23 let passed = 0;
24 let failed = 0;
25
26 function check(label: string, fn: () => boolean) {
27 try {
28 if (fn()) { process.stdout.write(` PASS ${label}\n`); passed += 1; }
29 else { process.stdout.write(` FAIL ${label}\n`); failed += 1; }
30 } catch (e) {
31 process.stdout.write(` ERROR ${label}: ${(e as Error).message}\n`); failed += 1;
32 }
33 }
34
35 function eq(a: unknown, b: unknown, label: string) {
36 if (a === b) {
37 process.stdout.write(` PASS ${label}\n`);
38 passed += 1;
39 } else {
40 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
41 failed += 1;
42 }
43 }
44
45 // ── stripMathDelimiters ────────────────────────────────────────────────────────
46
47 console.log("\nstripMathDelimiters");
48 eq(stripMathDelimiters("\\(x+1\\)"), "x+1", "\\(...\\)");
49 eq(stripMathDelimiters("\\[E=mc^2\\]"), "E=mc^2", "\\[...\\]");
50 eq(stripMathDelimiters("$$\\frac{a}{b}$$"), "\\frac{a}{b}", "$$...$$");
51 eq(stripMathDelimiters("$x_i^2$"), "x_i^2", "$...$");
52 eq(stripMathDelimiters("plain text"), "plain text", "no delimiters");
53 eq(stripMathDelimiters("$a|b$"), "a|b", "inline with pipe");
54
55 // ── latexNormalizeForKatex ─────────────────────────────────────────────────────
56
57 console.log("\nlatexNormalizeForKatex");
58 eq(latexNormalizeForKatex("x+1"), "x+1", "plain unchanged");
59 eq(latexNormalizeForKatex("\\text{baryon #}"), "\\text{baryon \\#}", "escapes # in \\text");
60 eq(latexNormalizeForKatex("\\text{cost is $5}"), "\\text{cost is \\textdollar{}5}", "escapes $ in \\text");
61 eq(latexNormalizeForKatex("\\text{a & b % c_d ^ e ~ f}"),
62 "\\text{a \\& b \\% c\\_d \\textasciicircum{} e \\textasciitilde{} f}",
63 "escapes & % _ ^ ~ in \\text");
64 eq(latexNormalizeForKatex("\\text{already\\_escaped}"), "\\text{already\\_escaped}", "no double-escape");
65 eq(latexNormalizeForKatex("\\alpha + \\beta"), "\\alpha + \\beta", "non-text commands");
66 eq(latexNormalizeForKatex("a | b"), "a \\vert b", "| to \\vert without doubled space");
67 eq(latexNormalizeForKatex("|x|"), "\\vert x\\vert", "|x| keeps command boundary");
68 eq(latexNormalizeForKatex("\\text{foo \\$ bar}"), "\\text{foo \\$ bar}", "already escaped $");
69 eq(latexNormalizeForKatex("100%"), "100\\%", "raw % escaped to \\% (KaTeX comment-char fix)");
70 eq(latexNormalizeForKatex("x = 50%"), "x = 50\\%", "% at end of math escaped");
71 eq(latexNormalizeForKatex("a%b"), "a\\%b", "% between letters escaped");
72 eq(latexNormalizeForKatex("a\\%b"), "a\\%b", "already-escaped \\% not double-escaped");
73 eq(latexNormalizeForKatex("\\textrm{test #}"), "\\textrm{test \\#}", "\\textrm also handled");
74 eq(latexNormalizeForKatex("\\textbf{hello world}"), "\\textbf{hello world}", "\\textbf no special chars");
75 eq(latexNormalizeForKatex("\\tfrac{a}{b}"), "\\tfrac{a}{b}", "nested braces in command");
76 eq(latexNormalizeForKatex("\\|x\\|"), "\\|x\\|", "\\| is left alone (readCommand handles \\|, not | branch)");
77 eq(latexNormalizeForKatex("\\\\|x|"), "\\\\\\vert x\\vert", "\\\\| line break + pipe: both | → \\vert");
78
79 // ── latexNormalizeForKatex — array column-spec pipes (regression) ──────────────
80 // Inside \begin{array}{c|c} the | means "draw a vertical rule" — it must
81 // NOT be rewritten to \vert, or KaTeX fails with "Unknown column alignment:
82 // \vert". The whole {...} preamble is copied verbatim.
83 eq(latexNormalizeForKatex("\\begin{array}{c|c} a & b \\\\ c & d \\end{array}"),
84 "\\begin{array}{c|c} a & b \\\\ c & d \\end{array}", "array column-spec | preserved (c|c)");
85 eq(latexNormalizeForKatex("\\begin{array}{|c|c|} a & b \\end{array}"),
86 "\\begin{array}{|c|c|} a & b \\end{array}", "array column-spec ||| preserved");
87 eq(latexNormalizeForKatex("\\begin{array}{cc|c} a & b & c \\end{array}"),
88 "\\begin{array}{cc|c} a & b & c \\end{array}", "array column-spec cc|c preserved");
89 eq(latexNormalizeForKatex("\\begin{array}{c|c} a & b \\end{array} |x|"),
90 "\\begin{array}{c|c} a & b \\end{array} \\vert x\\vert", "pipe OUTSIDE array still → \\vert");
91 eq(latexNormalizeForKatex("\\begin{tabular}{c|c} a & b \\end{tabular}"),
92 "\\begin{tabular}{c|c} a & b \\end{tabular}", "tabular column-spec | preserved");
93
94 // ── latexNormalizeForKatex — ket-pipe disambiguation (regression) ─────────────
95 // In GFM Markdown tables, | is the column delimiter, so kets are written as
96 // \|uud\rangle. But \| is the "parallel-to" double bar ‖ in LaTeX, not a ket
97 // bar. We convert \| to \vert when it's a ket opener (\|...\rangle) or bra
98 // closer (\langle...\|), but leave matched \|...\| norms alone.
99 eq(latexNormalizeForKatex("\\|uud\\rangle"), "\\vert uud\\rangle", "ket \\|uud\\rangle → \\vert");
100 eq(latexNormalizeForKatex("\\|\\alpha\\rangle"), "\\vert \\alpha\\rangle", "ket \\|\\alpha\\rangle → \\vert");
101 eq(latexNormalizeForKatex("\\|u\\uparrow d\\rangle"), "\\vert u\\uparrow d\\rangle", "ket with content → \\vert");
102 eq(latexNormalizeForKatex("\\frac{1}{\\sqrt{2}}\\|\\psi\\rangle"), "\\frac{1}{\\sqrt{2}}\\vert \\psi\\rangle", "ket in fraction → \\vert");
103 eq(latexNormalizeForKatex("\\|a\\rangle + \\|b\\rangle"), "\\vert a\\rangle + \\vert b\\rangle", "two kets both → \\vert");
104 // Norms (matched \|...\| pair) must KEEP the double bar
105 eq(latexNormalizeForKatex("\\|x\\|"), "\\|x\\|", "norm \\|x\\| preserved (double bar)");
106 eq(latexNormalizeForKatex("\\|v\\|^2"), "\\|v\\|^2", "norm \\|v\\|^2 preserved");
107 eq(latexNormalizeForKatex("\\|\\vec{v}\\|"), "\\|\\vec{v}\\|", "norm with content preserved");
108 // Bra closers (\langle...\|)
109 eq(latexNormalizeForKatex("\\langle\\psi\\|"), "\\langle\\psi\\vert", "bra \\langle\\psi\\| → \\vert");
110 // Inner product: \langle x \| y \rangle — the \| between bra and ket content
111 eq(latexNormalizeForKatex("\\langle x \\| y \\rangle"), "\\langle x \\vert y \\rangle", "inner product \\| → \\vert");
112
113 // ── latexNormalizeForKatex — \tag → align conversion (regression for KaTeX "Multiple \tag") ──
114 eq(latexNormalizeForKatex("a = b \\tag{10}"), "a = b \\tag{10}", "\\tag without aligned passes through");
115 eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\\\ \\end{aligned}"),
116 "\\begin{aligned} a &= b \\\\ \\end{aligned}", "aligned without \\tag unchanged");
117 eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\end{aligned}"),
118 "\\begin{align} a &= b \\tag{10}\\\\ c &= d \\end{align}", "aligned with \\tag → align");
119 eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\tag{11} \\end{aligned}"),
120 "\\begin{align} a &= b \\tag{10}\\\\ c &= d \\tag{11} \\end{align}", "aligned with multiple \\tag → align");
121 eq(latexNormalizeForKatex("\\boxed{\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\end{aligned}}"),
122 "\\boxed{\\begin{align} a &= b \\tag{10}\\\\ c &= d \\end{align}}", "boxed aligned with \\tag → boxed align");
123 eq(latexNormalizeForKatex("\\begin{gathered} a = b \\tag{10}\\\\ c = d \\end{gathered}"),
124 "\\begin{gather} a = b \\tag{10}\\\\ c = d \\end{gather}", "gathered with \\tag → gather");
125
126 // ── isLikelyInlineMath (mathClassify) ──────────────────────────────────────────
127
128 console.log("\nisLikelyInlineMath — math");
129 check("$x$ (single var)", () => isLikelyInlineMath("x") === true);
130 check("$E=mc^2$", () => isLikelyInlineMath("E=mc^2") === true);
131 check("$x_i^2$", () => isLikelyInlineMath("x_i^2") === true);
132 check("$\\alpha$", () => isLikelyInlineMath("\\alpha") === true);
133 check("$a \\le b$", () => isLikelyInlineMath("a \\le b") === true);
134 check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
135 check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
136 check("$x+1$", () => isLikelyInlineMath("x+1") === true);
137
138 console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
139 check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
140 check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
141 check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
142 check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
143 check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
144 check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
145 check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
146 check("$+$", () => isLikelyInlineMath("+") === true);
147 check("$=$", () => isLikelyInlineMath("=") === true);
148
149 console.log("\nisLikelyInlineMath — numeric syntax and contextual currency");
150 check("$5 defaults to literal without math context", () => isLikelyInlineMath("5") === false);
151 check("$10 defaults to literal without math context", () => isLikelyInlineMath("10") === false);
152 check("$10.50 defaults to literal without math context", () => isLikelyInlineMath("10.50") === false);
153 check("$100% defaults to math", () => isLikelyInlineMath("100%") === true);
154 check("costs $5$ is contextual currency", () =>
155 classifyInlineMath("5", { before: "it costs ", after: " today" }) === "currency");
156 check("price is $10.50$ each is contextual currency", () =>
157 classifyInlineMath("10.50", { before: "price is ", after: " each" }) === "currency");
158 check("10–$20$ MeV remains math", () =>
159 classifyInlineMath("20", { before: "10–", after: " MeV" }) === "math");
160 check("$20$ MeV uses a scientific unit as positive math context", () =>
161 classifyInlineMath("20", { after: " MeV" }) === "math");
162 check("$5$ cm uses an SI-prefixed unit as positive math context", () =>
163 classifyInlineMath("5", { after: " cm" }) === "math");
164 check("$2$ L uses a scientific unit symbol as positive math context", () =>
165 classifyInlineMath("2", { after: " L" }) === "math");
166 check("$3$ dB uses a common scientific unit as positive math context", () =>
167 classifyInlineMath("3", { after: " dB" }) === "math");
168 check("x = $2$ uses an operator as positive math context", () =>
169 classifyInlineMath("2", { before: "x = " }) === "math");
170 check("URL", () => isLikelyInlineMath("https://example.com") === false);
171 check("prose text", () => isLikelyInlineMath("hello world today") === false);
172 check("prose $x y z$ (spaces)", () => isLikelyInlineMath("x y z") === false);
173 check("$PATH$ env token", () => isLikelyInlineMath("PATH") === false);
174 check("$TODO$ word token", () => isLikelyInlineMath("TODO") === false);
175 check("$OK$ word token", () => isLikelyInlineMath("OK") === false);
176 check("$v1$ version token", () => isLikelyInlineMath("v1") === false);
177 check("$foo$ plain word", () => isLikelyInlineMath("foo") === false);
178
179 console.log("\nisLikelyInlineMath — single-letter regression");
180 check("lowercase $x$ → math", () => isLikelyInlineMath("x") === true);
181 check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyInlineMath("I") === true);
182 check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
183 check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
184
185 console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
186 check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
187 check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
188 check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
189 check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
190 check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
191 check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
192 check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
193
194 console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
195 // LLMs frequently emit minimal LaTeX in math contexts that the older
196 // classifier rejected as currency / word tokens. These tests pin down the
197 // deliberately-permissive rules for common math patterns while keeping
198 // context-free pure numbers literal until the AST policy sees a math signal.
199 check("single-digit $1$, $2$, $5$ → literal without context", () => isLikelyInlineMath("1") === false);
200 check("multi-digit $42$ → literal without context", () => isLikelyInlineMath("42") === false);
201 check("$2.5x$ is math (number with variable)", () => isLikelyInlineMath("2.5x") === true);
202 check("$10\%$ is math (percentage with LaTeX)", () => isLikelyInlineMath("10\\%") === true);
203 check("$2.5x dollars$ → NOT math (prefix-only numeric variable)", () => isLikelyInlineMath("2.5x dollars") === false);
204 check("$10\\% off$ → NOT math (prefix-only escaped percentage)", () => isLikelyInlineMath("10\\% off") === false);
205 check("$5\\cdot3$ is math (number with LaTeX command)", () => isLikelyInlineMath("5\\cdot3") === true);
206
207 check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath("A, B") === true);
208 check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
209 check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
210 check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
211 check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
212 check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
213 check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
214 check("$S$ with surrounding prose (regression)", () => {
215 return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
216 });
217 check("one-sided comparison $< B$ → math", () => isLikelyInlineMath("< B") === true);
218 check("one-sided comparison $<= 0$ → math", () => isLikelyInlineMath("<= 0") === true);
219 check("one-sided comparison $> 5$ → math", () => isLikelyInlineMath("> 5") === true);
220 check("one-sided comparison $A <$ → math", () => isLikelyInlineMath("A <") === true);
221 check("one-sided equality $=1$ → math", () => isLikelyInlineMath("=1") === true);
222 check("one-sided signed equality $=-1$ → math", () => isLikelyInlineMath("=-1") === true);
223 check("one-sided equality is fully anchored", () => isLikelyInlineMath("=1 dollar") === false);
224 check("$< B$ with surrounding prose", () => {
225 return normalizeMath("A 的每个元素 $< B$ 的每个元素") === "A 的每个元素 $< B$ 的每个元素";
226 });
227
228 // ── KaTeX end-to-end rendering ────────────────────────────────────────────────
229
230 const chiralSource = String.raw`
231 \underbrace{N}_{\text{baryon #}}
232 =
233 \underbrace{\frac{1+\tau_3}{2}}_{\text{isospin}}
234 +
235 \underbrace{g_A \gamma^\mu \gamma_5}_{\text{axial}}
236 +
237 \underbrace{SU(2)_L \times SU(2)_R}_{\text{chiral}}
238 `;
239
240 function renderDisplay(source: string): string {
241 return katex.renderToString(latexNormalizeForKatex(source), {
242 throwOnError: true,
243 displayMode: true,
244 });
245 }
246
247 console.log("\nKaTeX renderToString — end to end");
248 check("chiral decomposition renders", () => {
249 const html = renderDisplay(chiralSource);
250 return !html.includes("katex-error")
251 && ["baryon", "isospin", "axial", "chiral"].every((label) => html.includes(label));
252 });
253 check("\\|x\\| renders as double bars", () => {
254 const html = renderDisplay(String.raw`\|x\|`);
255 return !html.includes("katex-error") && html.includes("∥");
256 });
257
258 // ── normalizeMath pre-pass (LLM delimiters + classifier) ───────────────────────
259 // These exercise the *production* normalizeMath, not a copy of it.
260
261 console.log("\nnormalizeMath — LLM delimiter conversion");
262 eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
263 eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
264 eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
265
266 console.log("\nnormalizeMath — \\slashed conversion (regression)");
267 // KaTeX has no \slashed (Feynman slash notation). The pre-pass preserves it
268 // verbatim; the AST policy rewrites it to \not only for rendering.
269 eq(normalizeMath("$\\slashed{p}$"), "$\\slashed{p}$", "\\slashed{p} deferred to AST policy");
270 eq(normalizeMath("$\\slashed{\\partial}$"), "$\\slashed{\\partial}$", "\\slashed{\\partial} deferred to AST policy");
271 eq(
272 normalizeMath("The momentum $\\slashed{p}$ is conserved"),
273 "The momentum $\\slashed{p}$ is conserved",
274 "\\slashed in prose deferred to AST policy",
275 );
276 eq(normalizeMath("$\\slashed\\epsilon(0)$"), "$\\slashed\\epsilon(0)$", "unbraced \\slashed normalisation deferred to AST policy");
277 eq(normalizeMath("$\\slashed a$"), "$\\slashed a$", "unbraced \\slashed letter normalisation deferred to AST policy");
278
279 console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
280 // User-reported: "…decomposes as$$\n\mathbf{6}…" — block math glued to prose.
281 // Without a blank line, remark-math parses the opening $$ as an empty math node
282 // and the formula leaks out as literal text. normalizeMath must insert a blank
283 // line before any $$ preceded by a letter/closing bracket/etc.
284 check("inline $$ after prose", () => {
285 const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
286 return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
287 });
288 check("inline $$ after closing bracket", () => {
289 const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
290 return out.startsWith("(octet)\n$$");
291 });
292 check("inline $$ after closing brace (\\end{...}$$)", () => {
293 // A display equation ending with }$$ must be extracted as a unit.
294 // The closing $$ must not be split off, or the equation body is emptied.
295 const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
296 return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
297 });
298 check("inline $$ after comma on same line as content", () => {
299 // User-reported (2026-06-12, soft-pion chat): the model wrote the
300 // closing $$ of a display block on the same line as the trailing
301 // comma of the equation content, like
302 // …D(q^2),$$
303 // with $P=…$
304 // Without a blank line before the closing $$, micromark-extension-math
305 // does not recognise the closing fence (it only checks for $$ at
306 // the start of a new line) and consumes the rest of the document
307 // as math, which then fails to render with "Can't use function '$'
308 // in math mode" on the stray $ inside the equation body.
309 const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
310 return out.includes("D(q^2),\n$$");
311 });
312 check("well-formed $$ already on own line is normalised consistently", () => {
313 // Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
314 // `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
315 // remark-math-parseable form: opening $$ on its own line, body, closing
316 // $$ on its own line.
317 const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
318 const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
319 const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
320 return valid(inline) && valid(block);
321 });
322 check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
323 return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
324 });
325 check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
326 const out = normalizeMath("c^2$$ x $$");
327 return out === "c^2$$ x $$";
328 });
329 eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
330 eq(
331 normalizeMath("first$$a$$ middle $$b$$ end"),
332 "first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
333 "multiple display blocks on one line are all normalised",
334 );
335
336 console.log("\nnormalizeMath — semantic dollar decisions deferred to AST policy");
337 eq(normalizeMath("costs $1$ today"), "costs $1$ today", "$1$ preserved for contextual classification");
338 eq(normalizeMath("env $PATH$ here"), "env $PATH$ here", "$PATH$ preserved for AST literal restoration");
339 eq(normalizeMath("solve $x^2 + y^2 = z^2$ please"), "solve $x^2 + y^2 = z^2$ please", "$x^2+y^2$ is math");
340 eq(normalizeMath("$\\alpha + \\beta$"), "$\\alpha + \\beta$", "$\\alpha+\\beta$ is math");
341 eq(normalizeMath("price is $10.50$ each"), "price is $10.50$ each", "$10.50$ preserved for contextual classification");
342 eq(normalizeMath("$I$ think"), "$I$ think", "$I$ is math (uppercase single letter)");
343 eq(normalizeMath("it costs $5 and $10 total"), "it costs $5 and $10 total", "multiple prose dollars preserved for parser-aware policy");
344
345 console.log("\nnormalizeMath — Markdown code regions stay literal");
346 eq(normalizeMath("`$PATH$`"), "`$PATH$`", "inline code with env token");
347 eq(normalizeMath("Use `$HOME` and `$PATH$`."), "Use `$HOME` and `$PATH$`.", "multiple inline code spans");
348 eq(normalizeMath("```sh\necho $PATH$\n```"), "```sh\necho $PATH$\n```", "fenced code with env token");
349 eq(normalizeMath("```\necho $PATH$\n```\n\nsolve $x^2$"), "```\necho $PATH$\n```\n\nsolve $x^2$", "fenced code protected while prose math renders");
350 eq(normalizeMath("Code: `r.replace(/\\$\\$/, ...)`"), "Code: `r.replace(/\\$\\$/, ...)`", "escaped $ in inline code stays literal");
351 eq(normalizeMath("```javascript\nr = r.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, ...);\n```"), "```javascript\nr = r.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, ...);\n```", "regex patterns with $ in code blocks stay literal");
352 eq(normalizeMath("Code: `` `${DOLLAR}${m}${DOLLAR}` ``"), "Code: `` `${DOLLAR}${m}${DOLLAR}` ``", "template literals with $ in inline code stay literal");
353
354 // ── normalizeMath — text-mode source protection (regression for PR #3287) ─────
355 // A stray inner $ must be hidden until remark-math establishes the outer
356 // boundary, while the AST policy retains the exact source for copying.
357
358 console.log("\nnormalizeMath — text-mode escapes (regression)");
359 check("$\\text{cost is $5}$ inner $ is parser-safe and reversible", () => {
360 const out = normalizeMath("$\\text{cost is $5}$");
361 return !out.slice(1, -1).includes("$")
362 && restoreProtectedInlineMathSource(out.slice(1, -1)) === "\\text{cost is $5}";
363 });
364 check("$\\text{baryon #}$ # escape is deferred to AST policy", () => {
365 return normalizeMath("$\\text{baryon #}$") === "$\\text{baryon #}$";
366 });
367 check("$\\text{a & b}$ & escape is deferred to AST policy", () => {
368 return normalizeMath("$\\text{a & b}$") === "$\\text{a & b}$";
369 });
370 check("$\\text{cost is \\$5}$ escaped dollar stays literal", () => {
371 return normalizeMath("$\\text{cost is \\$5}$") === "$\\text{cost is \\$5}$";
372 });
373 check("$\\textrm{cost is \\$5}$ escaped dollar stays literal", () => {
374 return normalizeMath("$\\textrm{cost is \\$5}$") === "$\\textrm{cost is \\$5}$";
375 });
376 check("$\\sqrt{x}$ non-text command preserved", () => {
377 return normalizeMath("$\\sqrt{x}$") === "$\\sqrt{x}$";
378 });
379
380 // ── normalizeMath — TEXT_MODE_PAIR trailing content ──────────────────────────────
381 // $\cmd{...} + extra$ should be handled as a whole, not split at inner $.
382
383 console.log("\nnormalizeMath — TEXT_MODE_PAIR trailing content");
384 check("$\\text{cost is $5} + x^2$ inner $ escaped with trailing", () => {
385 const out = normalizeMath("$\\text{cost is $5} + x^2$");
386 return restoreProtectedInlineMathSource(out.slice(1, -1))
387 === "\\text{cost is $5} + x^2";
388 });
389 check("$\\text{a} | b$ pipe after text command", () => {
390 const out = normalizeMath("$\\text{a} | b$");
391 return restoreProtectedInlineMathSource(out.slice(1, -1)) === "\\text{a} | b";
392 });
393 check("$\\text{abc}$ simple text-mode (no trailing)", () => {
394 return normalizeMath("$\\text{abc}$") === "$\\text{abc}$";
395 });
396
397 // ── normalizeMath — GFM pipe protection (raw | marked, \\| preserved) ──────────
398
399 console.log("\nnormalizeMath — pipe handling");
400 check("$|x+1|$ absolute value", () => {
401 const out = normalizeMath("$|x+1|$");
402 return restoreProtectedInlineMathSource(out.slice(1, -1)) === "|x+1|";
403 });
404 check("$\\|x\\|$ norm preserved (no \\vert mangling)", () => {
405 return normalizeMath("$\\|x\\|$") === "$\\|x\\|$";
406 });
407
408 // ── normalizeMath — % in math (KaTeX comment-char) ─────────────────────────────
409 // KaTeX treats unescaped % as a LaTeX comment to end-of-line, silently
410 // truncating `$x = 50%$` to `$x = 50$`. Top-level % must be escaped.
411
412 console.log("\nnormalizeMath — % in math");
413 eq(normalizeMath("$x = 50%$"), "$x = 50%$", "trailing % escape deferred to AST policy");
414 eq(normalizeMath("$100%$"), "$100%$", "pure percentage preserved for AST math policy");
415 eq(normalizeMath("$10\\%$"), "$10\\%$", "already-escaped \\% left alone");
416
417 // ── normalizer + AST policy — end-to-end KaTeX render of common LLM outputs ───
418
419 console.log("\nnormalizeMath → KaTeX end-to-end");
420 function katexOf(normalized: string, display: boolean): boolean {
421 let inner: string;
422 if (normalized.startsWith("$$") && normalized.endsWith("$$")) {
423 inner = normalized.slice(2, -2);
424 display = true;
425 } else if (normalized.startsWith("$") && normalized.endsWith("$")) {
426 inner = normalized.slice(1, -1);
427 } else {
428 return false; // no math delimiters — nothing for KaTeX to render
429 }
430 try {
431 const source = restoreProtectedInlineMathSource(inner);
432 katex.renderToString(latexNormalizeForKatex(source), {
433 throwOnError: true,
434 displayMode: display,
435 });
436 return true;
437 } catch {
438 return false;
439 }
440 }
441
442 const e2e: Array<[string, string]> = [
443 ["$\\text{cost is $5}$", "text mode with literal $"],
444 ["$\\text{baryon #}$", "text mode with #"],
445 ["$\\text{a & b}$", "text mode with &"],
446 ["$\\|x\\|$", "norm"],
447 ["$|x+1|$", "abs value"],
448 ["$x=1$", "simple equation"],
449 ["$\\frac{a}{b}$", "fraction"],
450 ["$\\alpha + \\beta$", "greek letters"],
451 ["$ \\sqrt{x} $", "sqrt with surrounding spaces"],
452 ["$$E=mc^2$$", "display equation"],
453 ["\\(\\alpha\\)", "LLM-native inline delimiter"],
454 ["\\[\\sum_{i=1}^n i\\]", "LLM-native display delimiter"],
455 ["$$ |a| = |b| $$", "display with absolute values"],
456 ["$$\\boxed{\\begin{aligned}\nr_A E_\\pi(k;0) &= B(k^2) \\\\\nF_R(k;0) + 2r_A F_\\pi(k;0) &= A(k^2)\n\\end{aligned}}$$", "boxed aligned (no \\tag)"],
457 ["$$\\boxed{\\begin{aligned}\nr_A E_\\pi(k;0) &= B(k^2) \\tag{10}\\\\\nF_R(k;0) + 2r_A F_\\pi(k;0) &= A(k^2) \\tag{11}\n\\end{aligned}}$$", "boxed aligned with \\tag → align (no error)"],
458 ["\\[\\boxed{\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}}\\]", "LLM-native boxed aligned"],
459 // Array with column-spec pipe — regression: |→\vert used to corrupt {c|c}
460 // into {c\vert c} (KaTeX: "Unknown column alignment"). Must render cleanly.
461 ["$$\\begin{array}{c|c} a & b \\\\ c & d \\end{array}$$", "array with c|c column spec"],
462 ["$$\\begin{array}{cc|c} a & b & c \\\\ d & e & f \\end{array}$$", "array with cc|c column spec"],
463 ["$$\\begin{array}{|c|c|} a & b \\\\ c & d \\end{array}$$", "array with |c|c| column spec"],
464 // Ket with \| delimiter (common in GFM tables where | must be escaped)
465 ["$\\|\\psi\\rangle$", "ket with \\| → single bar (regression)"],
466 ["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
467 ["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
468 ["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
469 ["$S'$", "primed letter S'"],
470 ["$f'(x)$", "primed function call"],
471 ["$[56]$", "bracketed irrep label"],
472 ["$[56,0^+]$", "bracketed irrep label with charge"],
473 ];
474 for (const [src, label] of e2e) {
475 check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
476 }
477
478 // Inputs that contain no math delimiters must survive normalizeMath
479 // unchanged — KaTeX isn't involved here.
480 console.log("\nnormalizeMath — non-math inputs pass through");
481 type Passthrough = { src: string; expected: string; label: string };
482 const passthrough: Passthrough[] = [
483 { src: "costs $100$ today", expected: "costs $100$ today", label: "multi-digit currency preserved for AST policy" },
484 { src: "line break \\\\[4pt] here", expected: "line break \\\\[4pt] here", label: "LaTeX line-break spacing" },
485 { src: "hello world", expected: "hello world", label: "plain text" },
486 ];
487 for (const { src, expected, label } of passthrough) {
488 check(`${label}: ${src}`, () => normalizeMath(src) === expected);
489 }
490
491 // ── remark-math render boundary ────────────────────────────────────────────────
492 // These cases cross the real react-markdown → remark-math → Reasonix AST
493 // policy → rehype-katex boundary. The policy restores literal nodes after
494 // parsing, so rejected content cannot be reparsed as math.
495
496 console.log("\nnormalizeMath → remark-math render boundary");
497
498 function renderHtml(src: string): string {
499 return renderToStaticMarkup(
500 createElement(ReactMarkdown, {
501 remarkPlugins: reasonixRemarkPlugins,
502 rehypePlugins: reasonixRehypePlugins,
503 children: normalizeMath(src),
504 }),
505 );
506 }
507
508 check("currency '$5 and $6' renders as literal dollars, not math", () => {
509 const html = renderHtml("These two apples cost $5 and $6");
510 return !html.includes("katex") && html.includes("$5") && html.includes("$6");
511 });
512 check("paired currency 'costs $1$ today' drops the spurious closing delimiter", () => {
513 const html = renderHtml("costs $1$ today");
514 return !html.includes("katex") && html.includes("$1 today") && !html.includes("$1$");
515 });
516 check("paired decimal currency uses surrounding price context", () => {
517 const html = renderHtml("price is $10.50$ each");
518 return !html.includes("katex") && html.includes("$10.50 each") && !html.includes("$10.50$");
519 });
520 check("Chinese paired currency uses localized price context", () => {
521 const html = renderHtml("价格是$5$");
522 return !html.includes("katex") && html.includes("价格是$5") && !html.includes("$5$");
523 });
524 check("full-width punctuation keeps paired currency literal", () => {
525 const html = renderHtml("价格:$5$");
526 return !html.includes("katex") && html.includes("价格:$5") && !html.includes("$5$");
527 });
528 check("parentheses do not hide preceding currency context", () => {
529 const html = renderHtml("The price ($5$) includes tax.");
530 return !html.includes("katex") && html.includes("The price ($5) includes tax.");
531 });
532 check("braces do not hide preceding currency context", () => {
533 const html = renderHtml("The price {$5$} includes tax.");
534 return !html.includes("katex") && html.includes("The price {$5} includes tax.");
535 });
536 check("curly quotes do not hide preceding currency context", () => {
537 const html = renderHtml("The price ‘$5$’ includes tax.");
538 return !html.includes("katex") && html.includes("The price ‘$5’ includes tax.");
539 });
540 check("full-width parentheses do not hide Chinese currency context", () => {
541 const html = renderHtml("价格($5$)含税。");
542 return !html.includes("katex") && html.includes("价格($5)含税。");
543 });
544 check("parenthesized suffix currency unit repairs paired dollars", () => {
545 const html = renderHtml("It is $5$ (USD).");
546 return !html.includes("katex") && html.includes("It is $5 (USD).");
547 });
548 check("dash-separated cash suffix repairs paired dollars", () => {
549 const html = renderHtml("It is $5$—cash.");
550 return !html.includes("katex") && html.includes("It is $5—cash.");
551 });
552 check("cash context keeps paired currency literal", () => {
553 const html = renderHtml("I have $5$ in cash");
554 return !html.includes("katex") && html.includes("$5 in cash") && !html.includes("$5$");
555 });
556 check("bold markup does not hide preceding currency context", () => {
557 const html = renderHtml("costs **$5$** today");
558 return !html.includes("katex") && html.includes("<strong>$5</strong>");
559 });
560 check("emphasis does not hide surrounding currency context", () => {
561 const html = renderHtml("price is *$10.50$* each");
562 return !html.includes("katex") && html.includes("<em>$10.50</em>");
563 });
564 check("ambiguous paired numbers remain literal without positive math context", () => {
565 const html = renderHtml("from $5$ to $10$");
566 return !html.includes("katex") && html.includes("$5$") && html.includes("$10$");
567 });
568 check("env var $PATH$ renders as literal, not math", () => {
569 const html = renderHtml("env $PATH$ here");
570 return !html.includes("katex") && html.includes("$PATH$");
571 });
572 check("range endpoint 10–$20$ MeV renders numeric math", () => {
573 const html = renderHtml("10–$20$ MeV");
574 return html.includes("katex") && html.includes("<mn>20</mn>");
575 });
576 check("standalone $42$ remains literal without positive math context", () => {
577 const html = renderHtml("$42$ elements");
578 return !html.includes("katex") && html.includes("$42$ elements");
579 });
580 check("scientific unit makes a paired number mathematical", () => {
581 const html = renderHtml("$20$ MeV");
582 return html.includes("katex") && html.includes("<mn>20</mn>");
583 });
584 check("centimetres make a paired number mathematical", () => {
585 const html = renderHtml("$5$ cm");
586 return html.includes("katex") && html.includes("<mn>5</mn>");
587 });
588 check("litres make a paired number mathematical", () => {
589 const html = renderHtml("$2$ L");
590 return html.includes("katex") && html.includes("<mn>2</mn>");
591 });
592 check("decibels make a paired number mathematical", () => {
593 const html = renderHtml("$3$ dB");
594 return html.includes("katex") && html.includes("<mn>3</mn>");
595 });
596 check("parenthesized scientific quantity remains mathematical", () => {
597 const html = renderHtml("A vector ($5$ m) long.");
598 return html.includes("katex") && html.includes("<mn>5</mn>");
599 });
600 check("parenthesized ambiguous value retains mathematical wrapper context", () => {
601 const html = renderHtml("The value ($5$) is exact.");
602 return html.includes("katex") && html.includes("<mn>5</mn>");
603 });
604 check("one-sided equality $=1$ renders as math", () => {
605 const html = renderHtml("set $=1$ here");
606 return html.includes("katex") && html.includes("<mo>=</mo>");
607 });
608 check("one-sided equality does not prefix-match prose", () => {
609 const html = renderHtml("set $=1 dollar$ here");
610 return !html.includes("katex") && html.includes("$=1 dollar$");
611 });
612 check("inline code remains outside math policy", () => {
613 const html = renderHtml("code `$42$` and env `$PATH$`");
614 return !html.includes("katex") && html.includes("<code>$42$</code>") && html.includes("<code>$PATH$</code>");
615 });
616 check("AST policy applies KaTeX percent normalisation", () => {
617 const html = renderHtml("$x = 50%$");
618 return html.includes("katex")
619 && !html.includes("katex-error")
620 && html.includes('data-latex-source="x = 50%"')
621 && html.includes('encoding="application/x-tex">x = 50%</annotation>');
622 });
623 check("AST policy applies unbraced slashed normalisation", () => {
624 const html = renderHtml("$\\slashed a$");
625 return html.includes("katex")
626 && !html.includes("katex-error")
627 && html.includes('data-latex-source="\\slashed a"')
628 && html.includes('encoding="application/x-tex">\\slashed a</annotation>');
629 });
630 check("AST policy preserves braced slashed source after rendering", () => {
631 const html = renderHtml("$\\slashed{p}$");
632 return html.includes("katex")
633 && !html.includes("katex-error")
634 && html.includes('data-latex-source="\\slashed{p}"')
635 && html.includes('encoding="application/x-tex">\\slashed{p}</annotation>');
636 });
637 check("parser-safe text-mode math restores the exact copy source", () => {
638 const html = renderHtml("$\\text{cost is $5}$");
639 return html.includes("katex")
640 && !html.includes("katex-error")
641 && html.includes('data-latex-source="\\text{cost is $5}"')
642 && html.includes('encoding="application/x-tex">\\text{cost is $5}</annotation>');
643 });
644 check("real inline math $x^2$ still renders as KaTeX", () => {
645 const html = renderHtml("the value $x^2$ here");
646 return html.includes("katex");
647 });
648 check("inline math with asymmetric delimiter padding still renders", () => {
649 const html = renderHtml("before $\\alpha $ after");
650 return html.includes("katex")
651 && html.includes('data-latex-source="\\alpha "')
652 && html.includes('encoding="application/x-tex">\\alpha </annotation>');
653 });
654 check("inline math with multiple delimiter spaces still renders", () => {
655 const html = renderHtml("before $ x $ after");
656 return html.includes("katex")
657 && html.includes('data-latex-source=" x "')
658 && html.includes('encoding="application/x-tex"> x </annotation>');
659 });
660 check("GFM table preserves inline absolute-value math and every cell", () => {
661 const html = renderHtml("| Expr | Value |\n| --- | --- |\n| $|x|$ | abs |");
662 return html.includes("<table>")
663 && html.includes("katex")
664 && html.includes('data-latex-source="|x|"')
665 && html.includes('encoding="application/x-tex">|x|</annotation>')
666 && html.includes("<td>abs</td>")
667 && (html.match(/<td>/g) ?? []).length === 2;
668 });
669 check("display math preserves original TeX in the KaTeX root and annotation", () => {
670 const html = renderHtml("$$|x|$$");
671 return html.includes('class="katex-display" data-latex-source="|x|"')
672 && html.includes('encoding="application/x-tex">|x|</annotation>');
673 });
674 check("inline Young diagrams preserve their authored macro source", () => {
675 const html = renderHtml("before $V=\\yng(2,1)$ after");
676 return html.includes("katex")
677 && !html.includes("katex-error")
678 && !html.includes("reasonixInternal")
679 && html.includes('data-latex-source="V=\\yng(2,1)"')
680 && html.includes('encoding="application/x-tex">V=\\yng(2,1)</annotation>');
681 });
682 check("display Young tableaux preserve their authored macro source", () => {
683 const html = renderHtml("$$\\young(ab,c)$$");
684 return html.includes("katex-display")
685 && !html.includes("katex-error")
686 && !html.includes("reasonixInternal")
687 && html.includes('data-latex-source="\\young(ab,c)"')
688 && html.includes('encoding="application/x-tex">\\young(ab,c)</annotation>');
689 });
690 check("Young source survives nested pipe protection without cross-assignment", () => {
691 const html = renderHtml("$V=\\yng(2,1) | x + \\young(ab,c)$");
692 const source = "V=\\yng(2,1) | x + \\young(ab,c)";
693 return html.includes("katex")
694 && !html.includes("katex-error")
695 && !html.includes("reasonixInternal")
696 && html.includes(`data-latex-source="${source}"`)
697 && html.includes(`encoding="application/x-tex">${source}</annotation>`);
698 });
699 check("multiple formulas restore their own source without cross-assignment", () => {
700 const html = renderHtml("$|x|$ then $x = 50%$");
701 const annotations = html.match(/<annotation encoding="application\/x-tex">.*?<\/annotation>/g) ?? [];
702 return annotations.length === 2
703 && annotations[0].includes(">|x|</annotation>")
704 && annotations[1].includes(">x = 50%</annotation>");
705 });
706 check("blockquote display math does not swallow following inline math", () => {
707 const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
708 return html.includes("katex-display")
709 && !html.includes("katex-error")
710 && html.includes(">x</mi>");
711 });
712 check("multi-line blockquote display math strips quote markers from the formula", () => {
713 const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
714 return html.includes("katex-display")
715 && !html.includes("katex-error")
716 && !html.includes("&gt; E")
717 && html.includes(">x</mi>");
718 });
719
720 // ── Young diagram / tableau macros ─────────────────────────────────────────────
721 // `\yng` (ytableau) and `\young` (youngtab) are common in physics —
722 // SU(N) irreps, tensor decompositions, character tables — but KaTeX
723 // doesn't bundle either macro package. The pre-pass translates them
724 // to KaTeX-compatible `\boxed{array}` forms inside the math body so
725 // the diagram renders as a grid of boxes.
726
727 console.log("\nnormalizeMath — Young diagram macros");
728
729 check("\\yng(2,1) renders as (2,1) Young diagram", () => {
730 const html = renderHtml("$$\\yng(2,1)$$");
731 return html.includes("katex-display") && !html.includes("katex-error");
732 });
733 check("\\yng(2,1) in prose (no $ delimiters) gets wrapped and rendered", () => {
734 // A model that writes "the partition \\yng(2,1) corresponds to the
735 // (2,1) irrep" doesn't usually put $$ around the macro. The
736 // translator wraps bare \\yng in `$…$` so remark-math sees it as
737 // inline math and katex renders the diagram.
738 const html = renderHtml("The partition \\yng(2,1) is symmetric.");
739 return html.includes("katex")
740 && !html.includes("katex-error")
741 && !html.includes("reasonixInternal")
742 && html.includes('data-latex-source="\\yng(2,1)"');
743 });
744 check("\\yng inside \\(...\\) does not get double-wrapped", () => {
745 const out = resolveProtectedInlineMathSource(normalizeMath("\\(\\yng(2,1)\\)"));
746 return out.source === "$\\yng(2,1)$"
747 && out.rendered === "$\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$";
748 });
749 check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
750 const out = resolveProtectedInlineMathSource(normalizeMath("\\[\\yng(2,1)\\]"));
751 return out.source === "$$\n\\yng(2,1)\n$$"
752 && out.rendered.startsWith("$$\n\\begin{array}{l}")
753 && out.rendered.endsWith("$$")
754 && !out.rendered.includes("$$$");
755 });
756 check("escaped dollar before bare \\yng does not suppress wrapping", () => {
757 const src = String.raw`Price is \$5; shape \yng(2,1)`;
758 const expected = String.raw`Price is \$5; shape $\begin{array}{l}\square \! \square \\[-0.525em] \square\end{array}$`;
759 const out = resolveProtectedInlineMathSource(normalizeMath(src));
760 return out.source === String.raw`Price is \$5; shape $\yng(2,1)$`
761 && out.rendered === expected;
762 });
763 check("digit-starting inline math with \\yng does not get nested wrappers", () => {
764 const out = resolveProtectedInlineMathSource(normalizeMath("$3\\,\\yng(2,1)$"));
765 return out.source === "$3\\,\\yng(2,1)$"
766 && out.rendered === "$3\\,\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$";
767 });
768 check("digit-starting inline math with \\young does not get nested wrappers", () => {
769 const out = resolveProtectedInlineMathSource(normalizeMath("$2 + \\young(ab,c)$"));
770 return out.source === "$2 + \\young(ab,c)$"
771 && out.rendered === "$2 + \\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$";
772 });
773 check("display math ending in digit closes before following bare \\yng", () => {
774 const out = resolveProtectedInlineMathSource(normalizeMath("$$x^2$$ \\yng(1)"));
775 return out.source === "$$\nx^2\n$$\n $\\yng(1)$"
776 && out.rendered === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
777 });
778 check("bare \\yng after inline math is separated from adjacent dollars", () => {
779 const out = resolveProtectedInlineMathSource(normalizeMath("$x$\\yng(1)"));
780 return out.source === "$x$ $\\yng(1)$"
781 && out.rendered === "$x$ $\\begin{array}{l}\\square\\end{array}$";
782 });
783 check("bare \\yng before inline math is separated from adjacent dollars", () => {
784 const out = resolveProtectedInlineMathSource(normalizeMath("\\yng(1)$x$"));
785 return out.source === "$\\yng(1)$ $x$"
786 && out.rendered === "$\\begin{array}{l}\\square\\end{array}$ $x$";
787 });
788 check("\\yng (2,1) with a space before parens gets wrapped and rendered", () => {
789 const html = renderHtml("The partition \\yng (2,1) is symmetric.");
790 return html.includes("katex")
791 && !html.includes("katex-error")
792 && !html.includes("reasonixInternal")
793 && html.includes('data-latex-source="\\yng (2,1)"');
794 });
795 check("\\yng(3,2,1) renders as (3,2,1) Young diagram", () => {
796 const html = renderHtml("$$\\yng(3,2,1)$$");
797 return html.includes("katex-display") && !html.includes("katex-error");
798 });
799 check("\\yng(2,1){a&b\\\\c\\\\d&e} renders filled Young tableau", () => {
800 const html = renderHtml("$$\\yng(2,1){a&b\\\\c\\\\d&e}$$");
801 return html.includes("katex-display") && !html.includes("katex-error");
802 });
803 check("\\young(2 1) compatibility shorthand renders as (2,1) diagram", () => {
804 const html = renderHtml("$$\\young(2 1)$$");
805 return html.includes("katex-display") && !html.includes("katex-error");
806 });
807 check("\\young(ab,c) labelled youngtab syntax renders labels", () => {
808 const html = renderHtml("$$\\young(ab,c)$$");
809 return html.includes("katex-display")
810 && !html.includes("katex-error")
811 && !html.includes("reasonixInternal")
812 && html.includes('data-latex-source="\\young(ab,c)"')
813 && ["a", "b", "c"].every((label) => html.includes(label));
814 });
815 check("\\young(ab,c) labelled cells keep boxes", () => {
816 const out = expandYoungDiagrams("\\young(ab,c)");
817 return out.includes("\\boxed{a}")
818 && out.includes("\\boxed{b}")
819 && out.includes("\\boxed{c}");
820 });
821 check("\\young(abcd,:cd,:c) skew placeholders are invisible offsets", () => {
822 const out = expandYoungDiagrams("\\young(abcd,:cd,:c)");
823 return out.includes("\\hphantom{\\boxed{x}}")
824 && !out.includes("\\boxed{:}");
825 });
826 check("\\yng(4,3,2,1) renders as (4,3,2,1) Young diagram", () => {
827 const html = renderHtml("$$\\yng(4,3,2,1)$$");
828 return html.includes("katex-display") && !html.includes("katex-error");
829 });
830 check("\\yng(3,2,1) uses left-aligned array (rows start at same x)", () => {
831 // A Young diagram's shorter rows must start at the same x-position
832 // as the longest row's first cell — `{l}` (left) instead of `{c}`
833 // (centered) gives that layout. Without this, the diagram looks
834 // like each row is independently centred, which isn't a Young
835 // diagram.
836 const out = expandYoungDiagrams("\\yng(3,2,1)");
837 return out.includes("\\begin{array}{l}")
838 && !out.includes("\\begin{array}{c}");
839 });
840 check("expandYoungDiagrams uses flush cells (\\! cancels \\,) ", () => {
841 // Adjacent \square boxes should be flush — the convention for Young
842 // diagrams. The translator uses `\!` (negative thin space, -0.1667em)
843 // which exactly cancels `\,` so cells touch without visible gap.
844 // `\,` (positive thin space) would leave a gap.
845 const out = expandYoungDiagrams("\\yng(3)");
846 return out.includes("\\!") && !out.includes("\\, ");
847 });
848 check("expandYoungDiagrams uses flush rows (\\[-0.525em] closes the math-axis gap)", () => {
849 // The math axis positions a \square glyph centred on the row
850 // baseline, which leaves a visible ~0.4em gap between the bottom of
851 // one row's box and the top of the next row's box when the default
852 // 1.2em baseline-to-baseline spacing is used. Using `\\[-0.4em]`
853 // between rows pulls each subsequent row up by the math-axis offset,
854 // so consecutive rows touch. (Earlier versions tried wrapping each
855 // cell in `\raisebox{-0.35em}` which does NOT close the gap —
856 // uniform translation can't change the relative distance between
857 // row baselines.)
858 const out = expandYoungDiagrams("\\yng(2,1)");
859 return out.includes("\\\\[-0.525em]");
860 });
861 check("expandYoungDiagrams substitutes correct array form", () => {
862 // Direct unit test on the translator — no need to go through the
863 // full pipeline for this assertion.
864 const out = expandYoungDiagrams("\\yng(2,1)");
865 return out.includes("\\begin{array}{l}")
866 && out.includes("\\square")
867 && out.includes(" \\\\[-0.525em] ");
868 });
869 check("expandYoungDiagrams handles \\yng with content", () => {
870 // Bare \yng in prose gets wrapped in `$…$` so remark-math sees it as
871 // math; macros already inside a `$…$` block just substitute the inner
872 // form (the surrounding delimiters are preserved).
873 // Cells are joined with `\!` (negative thin space) so adjacent
874 // boxes are flush. Row separators use `\\[-0.525em]` (per-row
875 // negative spacing) so consecutive rows touch — the visible
876 // glyph height of `\square` is 0.675em (measured from katex's
877 // single-glyph strut), and the default 1.2em baseline spacing
878 // leaves 0.525em of gap. `\\[-0.525em]` subtracts exactly that.
879 const out = expandYoungDiagrams("\\yng(2,1){a&b\\\\c}");
880 return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$";
881 });
882 check("expandYoungDiagrams handles labelled \\young rows", () => {
883 const out = expandYoungDiagrams("\\young(ab,c)");
884 return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$";
885 });
886 check("expandYoungDiagrams treats comma-separated numeric \\young as labels, not a 12-cell row", () => {
887 const out = expandYoungDiagrams("\\young(12,3)");
888 return out === "$\\begin{array}{l}\\boxed{1} \\! \\boxed{2} \\\\[-0.525em] \\boxed{3}\\end{array}$";
889 });
890 check("expandYoungDiagrams leaves invalid negative \\yng shape alone", () => {
891 const out = expandYoungDiagrams("\\yng(-1)");
892 return out === "\\yng(-1)";
893 });
894 check("expandYoungDiagrams leaves oversized \\yng shape alone", () => {
895 const out = expandYoungDiagrams("\\yng(513)");
896 return out === "\\yng(513)";
897 });
898 check("expandYoungDiagrams leaves non-Young macros alone", () => {
899 const out = expandYoungDiagrams("\\frac{a}{b}");
900 return out === "\\frac{a}{b}";
901 });
902
903 // ── Summary ───────────────────────────────────────────────────────────────────
904
905 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
906 if (failed > 0) process.exit(1);
907
907 lines TYPESCRIPT