| 1 | // Golden cases for the Young diagram / tableau macros in the math pipeline. |
| 2 | // |
| 3 | // Run: tsx src/__tests__/math-young-diagrams.test.ts |
| 4 | |
| 5 | import { createElement } from "react"; |
| 6 | import { renderToStaticMarkup } from "react-dom/server"; |
| 7 | import ReactMarkdown from "react-markdown"; |
| 8 | import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins"; |
| 9 | import { normalizeMath, resolveProtectedInlineMathSource } from "../components/mathNormalize"; |
| 10 | import { expandYoungDiagrams } from "../components/youngDiagrams"; |
| 11 | |
| 12 | let passed = 0; |
| 13 | let failed = 0; |
| 14 | |
| 15 | function check(label: string, fn: () => boolean) { |
| 16 | try { |
| 17 | if (fn()) { process.stdout.write(` PASS ${label} |
| 18 | `); passed += 1; } |
| 19 | else { process.stdout.write(` FAIL ${label} |
| 20 | `); failed += 1; } |
| 21 | } catch (e) { |
| 22 | process.stdout.write(` ERROR ${label}: ${(e as Error).message} |
| 23 | `); failed += 1; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function renderHtml(src: string): string { |
| 28 | return renderToStaticMarkup( |
| 29 | createElement(ReactMarkdown, { |
| 30 | remarkPlugins: reasonixRemarkPlugins, |
| 31 | rehypePlugins: reasonixRehypePlugins, |
| 32 | children: normalizeMath(src), |
| 33 | }), |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | // ── Young diagram / tableau macros ───────────────────────────────────────────── |
| 38 | // `\yng` (ytableau) and `\young` (youngtab) are common in physics — |
| 39 | // SU(N) irreps, tensor decompositions, character tables — but KaTeX |
| 40 | // doesn't bundle either macro package. The pre-pass translates them |
| 41 | // to KaTeX-compatible `\boxed{array}` forms inside the math body so |
| 42 | // the diagram renders as a grid of boxes. |
| 43 | |
| 44 | console.log("\nnormalizeMath — Young diagram macros"); |
| 45 | |
| 46 | check("\\yng(2,1) renders as (2,1) Young diagram", () => { |
| 47 | const html = renderHtml("$$\\yng(2,1)$$"); |
| 48 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 49 | }); |
| 50 | check("\\yng(2,1) in prose (no $ delimiters) gets wrapped and rendered", () => { |
| 51 | // A model that writes "the partition \\yng(2,1) corresponds to the |
| 52 | // (2,1) irrep" doesn't usually put $$ around the macro. The |
| 53 | // translator wraps bare \\yng in `$…$` so remark-math sees it as |
| 54 | // inline math and katex renders the diagram. |
| 55 | const html = renderHtml("The partition \\yng(2,1) is symmetric."); |
| 56 | return html.includes("katex") |
| 57 | && !html.includes("katex-error") |
| 58 | && !html.includes("reasonixInternal") |
| 59 | && html.includes('data-latex-source="\\yng(2,1)"'); |
| 60 | }); |
| 61 | check("\\yng inside \\(...\\) does not get double-wrapped", () => { |
| 62 | const out = resolveProtectedInlineMathSource(normalizeMath("\\(\\yng(2,1)\\)")); |
| 63 | return out.source === "$\\yng(2,1)$" |
| 64 | && out.rendered === "$\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$"; |
| 65 | }); |
| 66 | check("\\yng inside \\[...\\] stays display math without triple dollars", () => { |
| 67 | const out = resolveProtectedInlineMathSource(normalizeMath("\\[\\yng(2,1)\\]")); |
| 68 | return out.source === "$$\n\\yng(2,1)\n$$" |
| 69 | && out.rendered.startsWith("$$\n\\begin{array}{l}") |
| 70 | && out.rendered.endsWith("$$") |
| 71 | && !out.rendered.includes("$$$"); |
| 72 | }); |
| 73 | check("escaped dollar before bare \\yng does not suppress wrapping", () => { |
| 74 | const src = String.raw`Price is \$5; shape \yng(2,1)`; |
| 75 | const expected = String.raw`Price is \$5; shape $\begin{array}{l}\square \! \square \\[-0.525em] \square\end{array}$`; |
| 76 | const out = resolveProtectedInlineMathSource(normalizeMath(src)); |
| 77 | return out.source === String.raw`Price is \$5; shape $\yng(2,1)$` |
| 78 | && out.rendered === expected; |
| 79 | }); |
| 80 | check("digit-starting inline math with \\yng does not get nested wrappers", () => { |
| 81 | const out = resolveProtectedInlineMathSource(normalizeMath("$3\\,\\yng(2,1)$")); |
| 82 | return out.source === "$3\\,\\yng(2,1)$" |
| 83 | && out.rendered === "$3\\,\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$"; |
| 84 | }); |
| 85 | check("digit-starting inline math with \\young does not get nested wrappers", () => { |
| 86 | const out = resolveProtectedInlineMathSource(normalizeMath("$2 + \\young(ab,c)$")); |
| 87 | return out.source === "$2 + \\young(ab,c)$" |
| 88 | && out.rendered === "$2 + \\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 89 | }); |
| 90 | check("display math ending in digit closes before following bare \\yng", () => { |
| 91 | const out = resolveProtectedInlineMathSource(normalizeMath("$$x^2$$ \\yng(1)")); |
| 92 | return out.source === "$$\nx^2\n$$\n $\\yng(1)$" |
| 93 | && out.rendered === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$"; |
| 94 | }); |
| 95 | check("bare \\yng after inline math is separated from adjacent dollars", () => { |
| 96 | const out = resolveProtectedInlineMathSource(normalizeMath("$x$\\yng(1)")); |
| 97 | return out.source === "$x$ $\\yng(1)$" |
| 98 | && out.rendered === "$x$ $\\begin{array}{l}\\square\\end{array}$"; |
| 99 | }); |
| 100 | check("bare \\yng before inline math is separated from adjacent dollars", () => { |
| 101 | const out = resolveProtectedInlineMathSource(normalizeMath("\\yng(1)$x$")); |
| 102 | return out.source === "$\\yng(1)$ $x$" |
| 103 | && out.rendered === "$\\begin{array}{l}\\square\\end{array}$ $x$"; |
| 104 | }); |
| 105 | check("\\yng (2,1) with a space before parens gets wrapped and rendered", () => { |
| 106 | const html = renderHtml("The partition \\yng (2,1) is symmetric."); |
| 107 | return html.includes("katex") |
| 108 | && !html.includes("katex-error") |
| 109 | && !html.includes("reasonixInternal") |
| 110 | && html.includes('data-latex-source="\\yng (2,1)"'); |
| 111 | }); |
| 112 | check("\\yng(3,2,1) renders as (3,2,1) Young diagram", () => { |
| 113 | const html = renderHtml("$$\\yng(3,2,1)$$"); |
| 114 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 115 | }); |
| 116 | check("\\yng(2,1){a&b\\\\c\\\\d&e} renders filled Young tableau", () => { |
| 117 | const html = renderHtml("$$\\yng(2,1){a&b\\\\c\\\\d&e}$$"); |
| 118 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 119 | }); |
| 120 | check("\\young(2 1) compatibility shorthand renders as (2,1) diagram", () => { |
| 121 | const html = renderHtml("$$\\young(2 1)$$"); |
| 122 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 123 | }); |
| 124 | check("\\young(ab,c) labelled youngtab syntax renders labels", () => { |
| 125 | const html = renderHtml("$$\\young(ab,c)$$"); |
| 126 | return html.includes("katex-display") |
| 127 | && !html.includes("katex-error") |
| 128 | && !html.includes("reasonixInternal") |
| 129 | && html.includes('data-latex-source="\\young(ab,c)"') |
| 130 | && ["a", "b", "c"].every((label) => html.includes(label)); |
| 131 | }); |
| 132 | check("\\young(ab,c) labelled cells keep boxes", () => { |
| 133 | const out = expandYoungDiagrams("\\young(ab,c)"); |
| 134 | return out.includes("\\boxed{a}") |
| 135 | && out.includes("\\boxed{b}") |
| 136 | && out.includes("\\boxed{c}"); |
| 137 | }); |
| 138 | check("\\young(abcd,:cd,:c) skew placeholders are invisible offsets", () => { |
| 139 | const out = expandYoungDiagrams("\\young(abcd,:cd,:c)"); |
| 140 | return out.includes("\\hphantom{\\boxed{x}}") |
| 141 | && !out.includes("\\boxed{:}"); |
| 142 | }); |
| 143 | check("\\yng(4,3,2,1) renders as (4,3,2,1) Young diagram", () => { |
| 144 | const html = renderHtml("$$\\yng(4,3,2,1)$$"); |
| 145 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 146 | }); |
| 147 | check("\\yng(3,2,1) uses left-aligned array (rows start at same x)", () => { |
| 148 | // A Young diagram's shorter rows must start at the same x-position |
| 149 | // as the longest row's first cell — `{l}` (left) instead of `{c}` |
| 150 | // (centered) gives that layout. Without this, the diagram looks |
| 151 | // like each row is independently centred, which isn't a Young |
| 152 | // diagram. |
| 153 | const out = expandYoungDiagrams("\\yng(3,2,1)"); |
| 154 | return out.includes("\\begin{array}{l}") |
| 155 | && !out.includes("\\begin{array}{c}"); |
| 156 | }); |
| 157 | check("expandYoungDiagrams uses flush cells (\\! cancels \\,) ", () => { |
| 158 | // Adjacent \square boxes should be flush — the convention for Young |
| 159 | // diagrams. The translator uses `\!` (negative thin space, -0.1667em) |
| 160 | // which exactly cancels `\,` so cells touch without visible gap. |
| 161 | // `\,` (positive thin space) would leave a gap. |
| 162 | const out = expandYoungDiagrams("\\yng(3)"); |
| 163 | return out.includes("\\!") && !out.includes("\\, "); |
| 164 | }); |
| 165 | check("expandYoungDiagrams uses flush rows (\\[-0.525em] closes the math-axis gap)", () => { |
| 166 | // The math axis positions a \square glyph centred on the row |
| 167 | // baseline, which leaves a visible ~0.4em gap between the bottom of |
| 168 | // one row's box and the top of the next row's box when the default |
| 169 | // 1.2em baseline-to-baseline spacing is used. Using `\\[-0.4em]` |
| 170 | // between rows pulls each subsequent row up by the math-axis offset, |
| 171 | // so consecutive rows touch. (Earlier versions tried wrapping each |
| 172 | // cell in `\raisebox{-0.35em}` which does NOT close the gap — |
| 173 | // uniform translation can't change the relative distance between |
| 174 | // row baselines.) |
| 175 | const out = expandYoungDiagrams("\\yng(2,1)"); |
| 176 | return out.includes("\\\\[-0.525em]"); |
| 177 | }); |
| 178 | check("expandYoungDiagrams substitutes correct array form", () => { |
| 179 | // Direct unit test on the translator — no need to go through the |
| 180 | // full pipeline for this assertion. |
| 181 | const out = expandYoungDiagrams("\\yng(2,1)"); |
| 182 | return out.includes("\\begin{array}{l}") |
| 183 | && out.includes("\\square") |
| 184 | && out.includes(" \\\\[-0.525em] "); |
| 185 | }); |
| 186 | check("expandYoungDiagrams handles \\yng with content", () => { |
| 187 | // Bare \yng in prose gets wrapped in `$…$` so remark-math sees it as |
| 188 | // math; macros already inside a `$…$` block just substitute the inner |
| 189 | // form (the surrounding delimiters are preserved). |
| 190 | // Cells are joined with `\!` (negative thin space) so adjacent |
| 191 | // boxes are flush. Row separators use `\\[-0.525em]` (per-row |
| 192 | // negative spacing) so consecutive rows touch — the visible |
| 193 | // glyph height of `\square` is 0.675em (measured from katex's |
| 194 | // single-glyph strut), and the default 1.2em baseline spacing |
| 195 | // leaves 0.525em of gap. `\\[-0.525em]` subtracts exactly that. |
| 196 | const out = expandYoungDiagrams("\\yng(2,1){a&b\\\\c}"); |
| 197 | return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 198 | }); |
| 199 | check("expandYoungDiagrams handles labelled \\young rows", () => { |
| 200 | const out = expandYoungDiagrams("\\young(ab,c)"); |
| 201 | return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 202 | }); |
| 203 | check("expandYoungDiagrams treats comma-separated numeric \\young as labels, not a 12-cell row", () => { |
| 204 | const out = expandYoungDiagrams("\\young(12,3)"); |
| 205 | return out === "$\\begin{array}{l}\\boxed{1} \\! \\boxed{2} \\\\[-0.525em] \\boxed{3}\\end{array}$"; |
| 206 | }); |
| 207 | check("expandYoungDiagrams leaves invalid negative \\yng shape alone", () => { |
| 208 | const out = expandYoungDiagrams("\\yng(-1)"); |
| 209 | return out === "\\yng(-1)"; |
| 210 | }); |
| 211 | check("expandYoungDiagrams leaves oversized \\yng shape alone", () => { |
| 212 | const out = expandYoungDiagrams("\\yng(513)"); |
| 213 | return out === "\\yng(513)"; |
| 214 | }); |
| 215 | check("expandYoungDiagrams leaves non-Young macros alone", () => { |
| 216 | const out = expandYoungDiagrams("\\frac{a}{b}"); |
| 217 | return out === "\\frac{a}{b}"; |
| 218 | }); |
| 219 | |
| 220 | // ── Summary ─────────────────────────────────────────────────────────────────── |
| 221 | |
| 222 | console.log(` |
| 223 | ${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 224 | if (failed > 0) process.exit(1); |
| 225 |