read-block.client.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import type { ComponentProps } from 'react'
  5. import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts'
  6. import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
  7. import { readBlockLabels } from './labels.client.ts'
  8. function ReadBlock(props: Omit<ComponentProps<typeof LocalizedReadBlock>, 'labels'>) {
  9. return <LocalizedReadBlock {...props} labels={readBlockLabels} />
  10. }
  11. afterEach(cleanup)
  12. beforeEach(() => {
  13. vi.useRealTimers()
  14. })
  15. function lines(count: number, first = 1): ReadBlockLine[] {
  16. return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
  17. }
  18. function rowTexts(container: HTMLElement): string[] {
  19. return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
  20. }
  21. function gutters(container: HTMLElement): string[] {
  22. return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
  23. }
  24. describe('highlightLines', () => {
  25. it('tokenizes a registered grammar into per-line css-variables runs', () => {
  26. const result = highlightLines('const x = 1\n// c', 'ts')
  27. expect(result).not.toBeUndefined()
  28. expect(result).toHaveLength(2)
  29. // The keyword run carries a color style through a --shiki-* custom property.
  30. const keyword = result![0]!.find(span => span.text === 'const')
  31. expect(keyword?.style?.color).toContain('var(--shiki-')
  32. // Whitespace between tokens is a run of its own; the comment is line two.
  33. expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
  34. expect(result![1]!.map(span => span.text).join('')).toBe('// c')
  35. })
  36. it('colors every run through a --shiki-* custom property', () => {
  37. // The css-variables theme colors even the whitespace run (as the foreground
  38. // token), so every run is a styled span; the plain fallback is the whole
  39. // unknown-language path, not a per-run one.
  40. const result = highlightLines('const x = 1', 'ts')
  41. for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
  42. })
  43. it('drops the trailing terminator line so the run count matches the source lines', () => {
  44. // `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
  45. // line array has one entry, so the terminator line is dropped.
  46. const result = highlightLines('const a = 1\n', 'ts')
  47. expect(result).toHaveLength(1)
  48. })
  49. it('keeps a genuinely blank final line when the source ends in two newlines', () => {
  50. const result = highlightLines('a\n\n', 'ts')
  51. expect(result).toHaveLength(2)
  52. expect(result![1]).toEqual([])
  53. })
  54. it('returns undefined for an unknown or absent language', () => {
  55. expect(highlightLines('x', 'cobol')).toBeUndefined()
  56. expect(highlightLines('x', undefined)).toBeUndefined()
  57. })
  58. it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
  59. // A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
  60. // not, so the first call renders plain and imports the grammar, and a
  61. // subscriber fires once it registers, after which the same call highlights.
  62. let notified = 0
  63. const stop = subscribeGrammarLoaded(() => { notified += 1 })
  64. // First touch: grammar not loaded yet, so plain fallback while it imports.
  65. expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
  66. // The import + loadLanguageSync resolve on a microtask; wait for the notify.
  67. await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
  68. expect(grammarLoadCount()).toBeGreaterThan(0)
  69. const result = highlightLines('def f(): pass', 'py')
  70. expect(result).not.toBeUndefined()
  71. // `def` is a python keyword and carries a --shiki-* color once highlighted.
  72. const keyword = result!.flat().find(span => span.text === 'def')
  73. expect(keyword?.style?.color).toContain('var(--shiki-')
  74. stop()
  75. })
  76. })
  77. describe('ReadBlock rows', () => {
  78. it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
  79. const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
  80. expect(gutters(view.container)).toEqual(['41', '42', '43'])
  81. expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
  82. })
  83. it('highlights the content for a known language into token spans', () => {
  84. const view = render(
  85. <ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
  86. )
  87. const content = view.container.querySelector('[class^="_content_"]')
  88. expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  89. expect(content?.textContent).toBe('const a = 1')
  90. })
  91. it('renders the content as bare text with no span wrappers for an unknown language', () => {
  92. const view = render(
  93. <ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
  94. )
  95. const content = view.container.querySelector('[class^="_content_"]')
  96. expect(content?.querySelectorAll('span').length).toBe(0)
  97. expect(content?.textContent).toBe('IDENT DIVISION.')
  98. })
  99. it('renders bare text when no language is given', () => {
  100. const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
  101. const content = view.container.querySelector('[class^="_content_"]')
  102. expect(content?.querySelectorAll('span').length).toBe(0)
  103. expect(view.getByText('plain')).toBeTruthy()
  104. })
  105. })
  106. describe('ReadBlock banner', () => {
  107. it('shows the label, the language, and the count note when the read is a window', () => {
  108. const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
  109. expect(view.getByText('src/a.ts')).toBeTruthy()
  110. expect(view.getByText('ts')).toBeTruthy()
  111. expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
  112. })
  113. it('omits the count note when the window is the whole file', () => {
  114. const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
  115. expect(view.queryByText(/显示/u)).toBeNull()
  116. })
  117. it('draws an empty label and empty language when neither is given', () => {
  118. const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
  119. expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
  120. expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
  121. })
  122. })
  123. describe('ReadBlock height cap', () => {
  124. it('renders every line and no expand control under the cap', () => {
  125. const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
  126. expect(rowTexts(view.container)).toHaveLength(4)
  127. expect(view.container.querySelector('[aria-expanded]')).toBeNull()
  128. })
  129. it('slices head and tail over the cap and expands on click', () => {
  130. const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
  131. // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
  132. expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
  133. const toggle = view.getByRole('button', { name: '展开其余 6 行' })
  134. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  135. expect(toggle.textContent).toBe('… 其余 6 行')
  136. fireEvent.click(toggle)
  137. expect(rowTexts(view.container)).toHaveLength(10)
  138. const collapse = view.getByRole('button', { name: '收起内容' })
  139. expect(collapse.getAttribute('aria-expanded')).toBe('true')
  140. expect(collapse.textContent).toBe('收起')
  141. fireEvent.click(collapse)
  142. expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
  143. })
  144. it('renders the head slice alone when the cap leaves no tail', () => {
  145. const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
  146. expect(gutters(view.container)).toEqual(['1'])
  147. expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
  148. })
  149. it('caps at the documented default when maxLines is absent', () => {
  150. const view = render(
  151. <ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
  152. )
  153. expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
  154. expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
  155. })
  156. })
  157. describe('ReadBlock copy', () => {
  158. it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
  159. vi.useFakeTimers()
  160. const writeText = vi.fn().mockResolvedValue(undefined)
  161. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  162. render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
  163. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  164. expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
  165. await act(async () => {
  166. await Promise.resolve()
  167. })
  168. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  169. // While the ok label is showing, further clicks are no-ops.
  170. fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
  171. expect(writeText).toHaveBeenCalledTimes(1)
  172. await vi.advanceTimersByTimeAsync(1000)
  173. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  174. })
  175. it('copies the whole window while the height cap hides its middle', async () => {
  176. const writeText = vi.fn().mockResolvedValue(undefined)
  177. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  178. render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
  179. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  180. expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
  181. expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
  182. })
  183. it('does not claim success when the host refuses the write', async () => {
  184. Object.defineProperty(navigator, 'clipboard', {
  185. configurable: true,
  186. value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
  187. })
  188. render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
  189. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  190. await act(async () => {
  191. await Promise.resolve()
  192. })
  193. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  194. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  195. })
  196. it('merges className onto the wrapper', () => {
  197. const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
  198. expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
  199. })
  200. it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
  201. // A successful read of an empty file settles to lines: [] with card:'read',
  202. // so this branch is reachable; copying then would clear the clipboard.
  203. const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
  204. expect(view.queryByRole('button', { name: '复制' })).toBeNull()
  205. })
  206. })