/** * Render tests for the presentation layer (W3): the \(...\)/\[...\] math * delimiter contract, single-newline line breaks, and mermaid rendering. * * Same idiom as render-cards.test.tsx: static HTML via react-dom/server, no * DOM. tsx note: tsx uses the CLASSIC JSX transform, so `React` must be a * resolvable global — the shim below provides it. */ import React from "react"; (globalThis as Record).React = React; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; import { MessageResponse } from "../components/ai-elements/message.tsx"; import { MermaidError } from "../components/mermaid-error.tsx"; import { transformMathDelimiters } from "../src/core/math-delims.ts"; /** Render through MessageResponse in static mode (no streaming/remend artifacts). */ function render(md: string): string { return renderToStaticMarkup({md}); } describe("math delimiter transform (pure unit)", () => { it("converts inline \\(...\\) to the two-dollar inline form", () => { assert.equal( transformMathDelimiters("a \\(x^2\\) b"), "a $$x^2$$ b", ); }); it("converts block-positioned \\[...\\] to flow math in place", () => { assert.equal( transformMathDelimiters("text\n\n\\[\n\\int x\n\\]\n\nmore"), "text\n\n$$\n\\int x\n$$\n\nmore", ); }); it("pads mid-paragraph \\[...\\) with blank lines (forces a clean block)", () => { assert.equal( transformMathDelimiters("mid \\[x^2\\] para"), "mid \n\n$$\nx^2\n$$\n\n para", ); }); it("degrades indented display math (list items) to the inline form", () => { // An indented $$ flow block half-parses inside list continuations; // inline keeps the math inside the item. assert.equal( transformMathDelimiters("- item\n \\[\n x^2\n \\]\n- next"), "- item\n $$x^2$$\n- next", ); }); it("leaves currency untouched", () => { const md = "It costs $5 and $10 more. $$big$$ sums too."; assert.equal(transformMathDelimiters(md), md); }); it("leaves escaped delimiters untouched (\\\\( is a literal backslash)", () => { assert.equal( transformMathDelimiters("escaped \\\\( and \\\\[ stay"), "escaped \\\\( and \\\\[ stay", ); }); it("does not transform inside fenced code blocks", () => { const md = "```js\n\\(x\\) and \\[y\\]\n```"; assert.equal(transformMathDelimiters(md), md); }); it("does not transform inside inline code spans", () => { const md = "run `\\(x\\)` locally"; assert.equal(transformMathDelimiters(md), md); }); it("leaves unbalanced delimiters untouched (mid-stream chunks)", () => { assert.equal( transformMathDelimiters("partial \\[ mid-stream"), "partial \\[ mid-stream", ); assert.equal( transformMathDelimiters("open \\( no close"), "open \\( no close", ); }); it("still converts a later balanced pair when an earlier one is unbalanced", () => { assert.equal( transformMathDelimiters("\\[ partial\n\nbut \\(ok\\)"), "\\[ partial\n\nbut $$ok$$", ); }); }); describe("render — standard dollar math + legacy compatibility (streamdown + KaTeX)", () => { it("renders \\(...\\) as inline KaTeX", () => { const html = render("The map \\(f(x) = x^2\\) is smooth."); assert.ok(html.includes("katex"), `inline math should render, got: ${html.slice(0, 300)}`); assert.ok(!html.includes("katex-display"), "parens math is inline, never display"); }); it("renders \\[x^2\\] as display math", () => { const html = render("Intro:\n\n\\[\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n\\]\n\nOutro."); assert.ok(html.includes("katex-display"), "bracket math on its own lines is display math"); }); it("renders mid-paragraph \\[...\\] as display math without leaking dollars", () => { const html = render("before \\[x^2\\] after"); assert.ok(html.includes("katex-display"), "mid-paragraph display math is hoisted to a block"); assert.ok(!html.includes("$$"), "no literal $$ leaks"); assert.ok(!html.includes("katex-error"), "no KaTeX parse errors"); }); it("renders display math inside list items without eating the list", () => { const html = render("- item one\n \\[x^2\\]\n- item two"); assert.ok(html.includes("katex"), "indented display math degrades to inline KaTeX"); assert.ok(html.includes("item two"), "the following list item survives"); const items = html.match(/data-streamdown="list-item"/g) ?? []; assert.equal(items.length, 2, "both list items render"); }); it("keeps escaped currency literal", () => { const html = render("It costs \\$5 and \\$10 more."); assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); assert.match(html, /\$5 and \$10/); }); it("renders standard $...$ math inline", () => { const html = render("The map $f(x) = x^2$ is smooth."); assert.ok(html.includes("katex"), "single-dollar math renders"); assert.ok(!html.includes("katex-display"), "single-dollar math is inline"); }); it("renders standard dollar commands and fenced display math", () => { const html = render("The symbol $\\Omega$ appears here.\n\n$$\nS = k_B \\ln \\Omega\n$$"); assert.ok(html.includes("katex-display"), "standalone dollar fences render display math"); assert.ok(html.includes("Ω"), "the command renders as the Greek symbol"); assert.ok(!html.includes("katex-error"), "both formulas are valid"); }); it("keeps dollar examples in inline code and code fences literal", () => { const html = render("Use `$x$` here.\n\n```text\n$$\ny = x^2\n$$\n```"); assert.ok(!html.includes("katex"), "code examples are not math"); assert.ok(html.includes("$x$"), "inline code retains its delimiters"); }); it("keeps \\(...\\) inside a code fence literal", () => { const html = render("```\n\\(x^2\\) stays literal\n```"); assert.ok(!html.includes("katex"), "code fences are never math"); assert.ok(html.includes("code-block"), "it renders as a code block"); }); it("leaves an unbalanced \\[ mid-stream untouched", () => { const html = render("partial \\[ mid-stream"); assert.ok(!html.includes("katex") && !html.includes("$$"), "no math, no dollars"); }); it("renders math inside headings and keeps the heading", () => { const html = render("## The map \\(f(x)\\)"); assert.ok(html.includes(" { it("renders a single newline as a visible line break", () => { const html = render("line one\nline two"); assert.match(html, //, "single \\n becomes a hard break"); }); it("keeps single newlines inside code blocks as code lines (no
)", () => { const html = render("```js\nline one\nline two\n```"); assert.ok(html.includes("code-block"), "code block renders"); assert.ok(!html.includes(" { const html = render("- alpha\n- beta"); const items = html.match(/data-streamdown="list-item"/g) ?? []; assert.equal(items.length, 2, "two list items"); assert.ok(!html.includes(" { const html = render("| a | b |\n|---|---|\n| 1 | 2 |"); assert.ok(html.includes(" { it("renders a mermaid fence as a diagram container, not a raw code block", () => { // SSR renders the lazy chunk's Suspense skeleton (the diagram itself // mounts client-side after hydration) — so assert on what must NOT // happen: the mermaid language must not take the code-block path, and // the chart source must not leak as visible code. const html = render("```mermaid\ngraph TD\nA --> B\n```"); assert.ok( !html.includes('data-streamdown="code-block"'), "mermaid fences must not render as code blocks", ); assert.ok(!html.includes("graph TD"), "chart source is not leaked as text"); }); it("still renders ordinary code fences as code blocks (contrast)", () => { const html = render("```js\nconst x = 1;\n```"); assert.ok(html.includes('data-streamdown="code-block"'), "js fences keep the code-block path"); }); it("renders the error fallback for invalid mermaid: error + source + retry", () => { // The fallback component mounts client-side when mermaid fails to // parse; render it directly to pin its contract. const html = renderToStaticMarkup( B"} error="Parse error on line 1: unexpected text" retry={() => {}} />, ); assert.match(html, /Diagram failed to render/); assert.match(html, /Parse error on line 1/); assert.match(html, /Show diagram source/); assert.match(html, /not a diagram/); assert.match(html, />Retry (the dead Bold-color fix)", () => { // Streamdown's default `strong` renders — and the app's ink token rule // (.lh-prose :where(strong, b), globals.css) matches strong/b // ELEMENTS, so bold never took the --lh-ink-strong color (neither the // factory #ee928c nor a settings boldColor). The mdComponents override // renders the semantic element. it("bold renders a real element inside .lh-prose", () => { const html = render("plain **bold** plain"); assert.ok( html.includes('bold'), "bold renders as a semantic strong element", ); assert.ok(html.includes("lh-prose"), "the markdown surface carries .lh-prose (the token rule's anchor)"); }); it("no span-for-bold regression: the font-semibold span is gone", () => { const html = render("plain **bold** plain"); assert.ok(!html.includes('class="font-semibold"'), "streamdown's span/600 default must not render bold"); }); it("italic stays native (the case that always worked)", () => { const html = render("plain *emph* plain"); assert.ok(html.includes(""), "italic renders a native em element"); }); }); // ─── escaped dollars inside math (owner, 2026-10-12) ──────────────────────── // // micromark's mathText closer is code-span logic — ANY size-matched `$` run // closes, `\$` included — so `$x = \$5$` closed at the escape and errored. // KaTeX itself renders `\$` fine (and its own auto-render scanner skips // escaped dollars). The fix normalizes at the render seam: inside balanced // inline spans, `\$` becomes the dollar-free `\text{\textdollar}` BEFORE // tokenization, so the scanner's closer is the one the author meant. describe("escaped dollars inside math — transform units", () => { it("replaces \\$ inside a balanced inline span", () => { assert.equal( transformMathDelimiters("It costs $x = \\$5$ at market."), "It costs $x = \\text{\\textdollar}5$ at market.", ); }); it("replaces every escaped dollar in the span, including the \\text{\\$} form", () => { assert.equal( transformMathDelimiters("$\\$3 + \\text{\\$}4 = \\$7$ exactly."), "$\\text{\\textdollar}3 + \\text{\\text{\\textdollar}}4 = \\text{\\textdollar}7$ exactly.", ); }); it("does NOT replace even-backslash dollars (row break + dollar keeps closing)", () => { // `$a\\$` closes at that `$` in micromark today — the parity is load- // bearing; replacing it would change span ends for legitimate TeX. const md = "$a\\\\$b$ tail"; assert.equal(transformMathDelimiters(md), md); }); it("leaves prose escaped dollars and bare currency untouched", () => { const md = "It costs \\$5 flat, then $5 and $10 more."; assert.equal(transformMathDelimiters(md), md); }); it("leaves display spans untouched — a lone $ can't close $$, \\$ already survives", () => { const md = "$$x = \\$5,\\ q = \\$12$$"; assert.equal(transformMathDelimiters(md), md); }); it("leaves code spans, fences, and unbalanced spans untouched", () => { assert.equal(transformMathDelimiters("Type `$x = \\$5$` literally."), "Type `$x = \\$5$` literally."); assert.equal(transformMathDelimiters("```\n$x = \\$5$\n```"), "```\n$x = \\$5$\n```"); assert.equal(transformMathDelimiters("Broken: $x = \\$5"), "Broken: $x = \\$5"); }); it("clean spans stay byte-identical; only the escape-bearing span changes", () => { const md = "$a$ costs $5\\$10$ and $b$"; assert.equal( transformMathDelimiters(md), "$a$ costs $5\\text{\\textdollar}10$ and $b$", ); }); }); describe("escaped dollars inside math — rendering", () => { it("renders $x = \\$5$ as one clean math span with a visible dollar", () => { const html = render("It costs $x = \\$5$ at market."); assert.equal((html.match(/class="katex"/g) ?? []).length, 1, "one math span"); assert.ok(!html.includes("katex-error"), "no KaTeX errors"); assert.ok(html.includes("\\text{\\textdollar}"), "the dollar-free command reaches KaTeX"); }); it("the session cascade case renders every later span cleanly", () => { // The latex-testing session's Test 6: the early close used to error // and could swallow the following text. const html = render("Line A: $x = \\$5$.\n\nLine B: $y = 2$.\n\nWhat is $2 + 2$?"); assert.equal((html.match(/class="katex"/g) ?? []).length, 3, "all three spans render"); assert.ok(!html.includes("katex-error"), "no KaTeX errors"); assert.ok(html.includes("Line B:"), "prose between spans survives"); }); it("renders the \\text{\\$} form (Test 5's text-mode dollar)", () => { const html = render("Price: $\\text{\\$}12$ today."); assert.equal((html.match(/class="katex"/g) ?? []).length, 1); assert.ok(!html.includes("katex-error")); }); it("legacy \\(...\\$...\\) converts and renders with the dollar intact", () => { const html = render("Old form \\(x = \\$5\\) renders."); assert.equal((html.match(/class="katex"/g) ?? []).length, 1); assert.ok(!html.includes("katex-error")); }); it("code spans keep the escaped dollar literal", () => { const html = render("Type `$x = \\$5$` literally."); assert.equal((html.match(/class="katex"/g) ?? []).length, 0, "code is not math"); assert.ok(html.includes("$x = \\$5$"), "the literal source stays visible"); }); });