markdown-body.client.spec.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. // @vitest-environment jsdom
  2. /** Markdown preview uses one accumulated document across page arrivals and EOF. */
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import { cleanup, render } from '@testing-library/react'
  5. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { MarkdownBody, type MarkdownBodyProps } from '../src/client/markdown/MarkdownBody.tsx'
  7. import { en, zh } from '../src/client/markdown/locales.ts'
  8. import type { DocumentContent } from '../src/client/document/contract.ts'
  9. afterEach(cleanup)
  10. function content(pageTexts: readonly string[], eof: boolean): DocumentContent {
  11. let offset = 1
  12. const pages = pageTexts.map((text) => {
  13. const lines = text.split('\n').length
  14. const page = { offset, text, lines }
  15. offset += lines
  16. return page
  17. })
  18. return { kind: 'text', text: pageTexts.join('\n'), pages, eof }
  19. }
  20. // The body reads content and locale only; the other standard seats belong to the slot integration tests.
  21. function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate(en)): MarkdownBodyProps {
  22. return { resourceAddress: 'dsh-resource://file/session/markdown/notes.md', content: value, wrap: false, t } as MarkdownBodyProps
  23. }
  24. describe('MarkdownBody', () => {
  25. it('renders GFM headings, tables, task lists, strikeout, and localized code and footnote chrome', () => {
  26. const text = [
  27. '# Notes', '', '| Item | Value |', '| --- | --- |', '| a | 1 |', '',
  28. '- [x] Done', '- [ ] Pending', '', '~~Old~~ and **new**.', '',
  29. '```ts', 'const answer = 42', '```', '', 'Note[^one].', '', '[^one]: Detail.',
  30. ].join('\n')
  31. const view = render(<MarkdownBody {...props(content([text], true))} />)
  32. expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined()
  33. expect(view.getAllByRole('columnheader').map(node => node.textContent)).toEqual(['Item', 'Value'])
  34. expect(view.getAllByRole('cell').map(node => node.textContent)).toEqual(['a', '1'])
  35. const tasks = view.getAllByRole('checkbox') as HTMLInputElement[]
  36. expect(tasks.map(task => task.checked)).toEqual([true, false])
  37. expect(tasks.every(task => task.disabled)).toBe(true)
  38. expect(view.container.querySelector('del')?.textContent).toBe('Old')
  39. expect(view.container.querySelector('strong')?.textContent).toBe('new')
  40. expect(view.getByRole('button', { name: 'Copy' })).toBeDefined()
  41. expect(view.getByRole('heading', { name: 'Footnotes' })).toBeDefined()
  42. expect(view.container.querySelector('pre')?.textContent).toBe('const answer = 42')
  43. })
  44. it('retains the Markdown and highlighted fence elements as pages extend and close a fence', () => {
  45. const first = '# Notes\n\nIntroduction.\n\n```ts\nconst first = 1'
  46. const second = 'const second = 2\n```\n\nFollowing paragraph.'
  47. const view = render(<MarkdownBody {...props(content([first], false))} />)
  48. const document = view.container.querySelector('[data-document-markdown]')
  49. const heading = view.getByRole('heading', { name: 'Notes' })
  50. const code = view.container.querySelector('pre.shiki')
  51. const firstLine = code?.querySelector('.line')
  52. expect(firstLine?.textContent).toBe('const first = 1')
  53. view.rerender(<MarkdownBody {...props(content([first, second], false))} />)
  54. expect(view.container.querySelector('[data-document-markdown]')).toBe(document)
  55. expect(view.getByRole('heading', { name: 'Notes' })).toBe(heading)
  56. expect(view.container.querySelectorAll('pre')).toHaveLength(1)
  57. expect(view.container.querySelector('pre.shiki')).toBe(code)
  58. expect(code?.querySelector('.line')).toBe(firstLine)
  59. expect(code?.textContent).toBe('const first = 1\nconst second = 2')
  60. expect(view.getByText('Following paragraph.')).toBeDefined()
  61. view.rerender(<MarkdownBody {...props(content([first, second], true))} />)
  62. expect(view.container.querySelector('pre.shiki')).toBe(code)
  63. expect(code?.querySelector('.line')).toBe(firstLine)
  64. })
  65. it('resolves cross-page references and math when the cumulative text reaches EOF', () => {
  66. const first = 'Read [the guide][guide].\n\nFirst.\n\nSecond.\n\nThird.\n\nFourth.'
  67. const second = '\n[guide]: https://example.test/guide\n\nValue $x^2$.'
  68. const view = render(<MarkdownBody {...props(content([first], false))} />)
  69. view.rerender(<MarkdownBody {...props(content([first, second], false))} />)
  70. expect(view.container.querySelector('.katex')).toBeNull()
  71. view.rerender(<MarkdownBody {...props(content([first, second], true))} />)
  72. expect(view.container.querySelector('a[href="https://example.test/guide"]')?.textContent).toBe('the guide')
  73. expect(view.container.querySelector('.katex')).not.toBeNull()
  74. })
  75. it('updates labels when translations change without replacing the translation function', () => {
  76. let dictionary = en
  77. const t: MarkdownBodyProps['t'] = key => Object.hasOwn(dictionary, key) ? dictionary[key as keyof typeof dictionary] : key
  78. const text = '```ts\nconst answer = 42\n```\n\nNote[^one].\n\n[^one]: Detail.'
  79. const value = content([text], true)
  80. const view = render(<MarkdownBody {...props(value, t)} />)
  81. expect(view.getByRole('button', { name: 'Copy' })).toBeDefined()
  82. dictionary = zh
  83. view.rerender(<MarkdownBody {...props(value, t)} />)
  84. expect(view.getByRole('button', { name: '复制' })).toBeDefined()
  85. expect(view.getByRole('heading', { name: '脚注' })).toBeDefined()
  86. })
  87. it('renders empty text and leaves non-text deliveries to their selected implementation', () => {
  88. const view = render(<MarkdownBody {...props({ kind: 'text', text: '', pages: [], eof: true })} />)
  89. expect(view.container.querySelector('[data-document-markdown]')?.textContent).toBe('')
  90. view.rerender(<MarkdownBody {...props({ kind: 'bytes', data: new TextEncoder().encode('text') })} />)
  91. expect(view.container.childElementCount).toBe(0)
  92. })
  93. })