test(web): cover TeX math rendering

This commit is contained in:
fz
2026-08-05 14:19:19 +08:00
parent 872b464ec2
commit d68ee2fb0e
9 changed files with 276 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ interface RemarkProcessor {
}
const previousBackslash: Previous = function (code) {
/* v8 ignore next -- micromark calls previous after an event has been emitted. */
return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape
}
@@ -20,6 +21,7 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the text construct is dispatched only for a backslash. */
if (code !== codes.backslash) return nok(code)
effects.enter('mathText')
effects.enter('mathTextSequence')
@@ -72,6 +74,7 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
return slash
function slash(code: number | null): State | undefined {
/* v8 ignore next -- this partial construct is attempted only at a backslash. */
if (code !== codes.backslash) return closeNok(code)
closeEffects.enter('mathTextSequence')
closeEffects.consume(code)
@@ -98,6 +101,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the flow construct is dispatched only for its marker. */
if (code !== marker) return nok(code)
effects.enter('mathFlow')
effects.enter('mathFlowFence')
@@ -124,6 +128,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code)
}
if (markdownLineEnding(code)) {
/* v8 ignore next -- micromark gives same-line dollar flow to remark-math before this continuation branch. */
return multiline
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
: nok(code)
@@ -218,7 +223,9 @@ const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) {
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (code === codes.eof) return ok(code)
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (!markdownLineEnding(code)) return nok(code)
effects.enter(types.lineEnding)
effects.consume(code)

View File

@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
afterEach(cleanup)
@@ -171,6 +173,91 @@ describe('MarkdownText', () => {
expect(container.querySelector('a')).toBeNull()
})
it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
const source = [
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(6)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
.toContain('\\tag{1}')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('table .katex')).not.toBeNull()
})
it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
const cases = [
{
source: '\\(\\alpha \\, \\beta\\)',
math: 1,
display: 0,
},
{
source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
math: 1,
display: 0,
},
{
source: '> \\[\n> \\frac{1}{5}\n> \\]',
math: 1,
display: 1,
},
{
source: '- \\[\n \\frac{1}{5}\n \\]',
math: 1,
display: 1,
},
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x\n\n$$x$$ trailing'} />)
expect(literal.container.querySelectorAll('.katex')).toHaveLength(1)
expect(literal.container.querySelector('.katex-display')).toBeNull()
expect(literal.container.textContent).toContain('[x')
expect(literal.container.textContent).toContain('xxx trailing')
})
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
const sources = [
'$$\n\\theta\n$$',
'$$$\\theta$$$',
' \\[\n \\theta\n \\]',
'\\(\\theta',
'> \\[\nnot a quoted continuation\n\\]',
]
for (const source of sources) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('registers the compatibility extension on a bare remark processor', () => {
const data: { micromarkExtensions?: Extension[] } = {}
remarkMathCompatibility.call({ data: () => data })
expect(data.micromarkExtensions).toHaveLength(1)
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'