markdown-render-units.spec.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. // @vitest-environment jsdom
  2. // Branch coverage for the mdast renderer that real parses cannot reach: the
  3. // grammar only emits references whose definitions exist, always stamps
  4. // positions and align arrays, and never emits bare list items — but the
  5. // renderer is a pure function over mdast, so hand-built trees exercise its
  6. // defensive arms directly.
  7. import { StrictMode } from 'react'
  8. import { cleanup, render } from '@testing-library/react'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import type * as Md from 'mdast'
  11. import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
  12. import {
  13. collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
  14. } from '../src/markdown/render.tsx'
  15. import type { MarkdownRenderContext } from '../src/markdown/render.tsx'
  16. afterEach(cleanup)
  17. function makeContext(): MarkdownRenderContext {
  18. return {
  19. streaming: false,
  20. codeLabels: undefined,
  21. targets: createReferenceTargets(),
  22. footnoteOrder: [],
  23. footnoteCounts: new Map(),
  24. }
  25. }
  26. function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement {
  27. const { container } = render(
  28. <div>{renderBlocks(nodes.map((node, key) => ({ node, key })), context)}</div>,
  29. )
  30. return container
  31. }
  32. const text = (value: string): Md.Text => ({ type: 'text', value })
  33. describe('renderBlocks over hand-built trees', () => {
  34. it('reverts unresolved references to their bracketed source', () => {
  35. const container = renderNodes([
  36. {
  37. type: 'paragraph',
  38. children: [
  39. { type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] },
  40. { type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] },
  41. { type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] },
  42. { type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' },
  43. { type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null },
  44. ],
  45. },
  46. ])
  47. expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]')
  48. expect(container.querySelector('a')).toBeNull()
  49. })
  50. it('keeps the first definition when identifiers repeat', () => {
  51. const targets = createReferenceTargets()
  52. collectReferenceTargets([
  53. { type: 'definition', identifier: 'dup', url: 'https://example.com/first' },
  54. { type: 'definition', identifier: 'dup', url: 'https://example.com/second' },
  55. { type: 'footnoteDefinition', identifier: 'fn', children: [] },
  56. { type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] },
  57. ], targets)
  58. expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first')
  59. expect(targets.footnotes.get('FN')?.children).toEqual([])
  60. })
  61. it('renders a bare list item, computing looseness from the item itself', () => {
  62. const item: Md.ListItem = {
  63. type: 'listItem',
  64. spread: null,
  65. children: [
  66. { type: 'paragraph', children: [text('alpha')] },
  67. { type: 'paragraph', children: [text('beta')] },
  68. ],
  69. }
  70. const container = renderNodes([item])
  71. // Two block children make the parentless item loose: paragraphs stay wrapped.
  72. expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta'])
  73. })
  74. it('renders spread-null lists and align-less tables', () => {
  75. const container = renderNodes([
  76. {
  77. type: 'list',
  78. ordered: false,
  79. spread: null,
  80. children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }],
  81. },
  82. {
  83. type: 'table',
  84. children: [
  85. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] },
  86. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] },
  87. ],
  88. },
  89. ])
  90. expect(container.querySelector('li')?.textContent).toBe('solo')
  91. expect(container.querySelector('th')?.getAttribute('style')).toBeNull()
  92. expect(container.querySelector('td')?.textContent).toBe('short')
  93. })
  94. it('pads rows against the alignment width with empty cells', () => {
  95. const container = renderNodes([
  96. {
  97. type: 'table',
  98. align: ['left', 'right'],
  99. children: [
  100. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] },
  101. ],
  102. },
  103. ])
  104. const cells = [...container.querySelectorAll('th')]
  105. expect(cells).toHaveLength(2)
  106. expect(cells[1]?.textContent).toBe('')
  107. })
  108. it('renders a checked item without any content as a bare checkbox', () => {
  109. const container = renderNodes([
  110. {
  111. type: 'list',
  112. ordered: false,
  113. children: [
  114. { type: 'listItem', checked: true, children: [] },
  115. { type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] },
  116. ],
  117. },
  118. ])
  119. const items = [...container.querySelectorAll('li.task-list-item')]
  120. expect(items).toHaveLength(2)
  121. for (const item of items) {
  122. expect(item.querySelector('input[type="checkbox"]')).not.toBeNull()
  123. expect(item.textContent?.trim()).toBe('')
  124. }
  125. })
  126. it('renders images with a null alt as an empty alt attribute', () => {
  127. const targets = createReferenceTargets()
  128. targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' })
  129. const container = renderNodes([
  130. { type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] },
  131. { type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] },
  132. ], { ...makeContext(), targets })
  133. const images = [...container.querySelectorAll('img')]
  134. expect(images.map(image => image.getAttribute('alt'))).toEqual(['', ''])
  135. })
  136. it('drops a definition nested in a list item without leaving a separator behind', () => {
  137. const container = renderNodes([
  138. {
  139. type: 'list',
  140. ordered: true,
  141. start: 3,
  142. children: [{
  143. type: 'listItem',
  144. children: [
  145. { type: 'paragraph', children: [text('body')] },
  146. { type: 'definition', identifier: 'x', url: 'https://example.com' },
  147. ],
  148. }],
  149. },
  150. ])
  151. expect(container.querySelector('ol')?.getAttribute('start')).toBe('3')
  152. // The two mdast children make the item loose (wrap newlines around the
  153. // paragraph); the dropped definition contributes nothing else.
  154. expect(container.querySelector('li')?.textContent).toBe('\nbody\n')
  155. })
  156. it('renders nothing for node types without a mapping', () => {
  157. const container = renderNodes([
  158. { type: 'yaml', value: 'front: matter' },
  159. { type: 'tableRow', children: [] },
  160. { type: 'paragraph', children: [text('after')] },
  161. ])
  162. expect(container.textContent).toBe('after')
  163. })
  164. })
  165. describe('renderFootnoteSection edge shapes', () => {
  166. it('skips referenced footnotes without definitions and returns null when none remain', () => {
  167. const context = makeContext()
  168. context.footnoteOrder.push('GHOST')
  169. context.footnoteCounts.set('GHOST', 1)
  170. expect(renderFootnoteSection(context)).toBeNull()
  171. })
  172. it('renders no back-reference markers for an uncounted footnote', () => {
  173. const context = makeContext()
  174. context.targets.footnotes.set('Q', {
  175. type: 'footnoteDefinition',
  176. identifier: 'q',
  177. children: [{ type: 'paragraph', children: [text('quiet')] }],
  178. })
  179. context.footnoteOrder.push('Q')
  180. const { container } = render(<div>{renderFootnoteSection(context)}</div>)
  181. expect(container.querySelector('li')?.textContent).toBe('\nquiet \n')
  182. })
  183. it('appends back-references after a non-paragraph body', () => {
  184. const context = makeContext()
  185. context.targets.footnotes.set('N', {
  186. type: 'footnoteDefinition',
  187. identifier: 'n',
  188. children: [{ type: 'code', value: 'code body', lang: null }],
  189. })
  190. context.footnoteOrder.push('N')
  191. context.footnoteCounts.set('N', 1)
  192. const { container } = render(<div>{renderFootnoteSection(context)}</div>)
  193. const item = container.querySelector('li')
  194. expect(item?.querySelector('.md-code-block')).not.toBeNull()
  195. expect(item?.textContent).toContain('↩')
  196. })
  197. })
  198. describe('MarkdownText under StrictMode', () => {
  199. it('streams identically when React double-invokes render work', () => {
  200. const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
  201. const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
  202. strict.rerender(<StrictMode><MarkdownText text={doc} streaming /></StrictMode>)
  203. const plain = render(<MarkdownText text={doc} streaming />)
  204. expect(strict.container.innerHTML).toBe(plain.container.innerHTML)
  205. strict.unmount()
  206. plain.unmount()
  207. })
  208. })