diff-block.client.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts'
  6. import { diffBlockLabels } from './labels.client.ts'
  7. import { diffTotals } from '../src/DiffBlock.tsx'
  8. function DiffBlock(props: Omit<ComponentProps<typeof LocalizedDiffBlock>, 'labels'>) {
  9. return <LocalizedDiffBlock {...props} labels={diffBlockLabels} />
  10. }
  11. afterEach(cleanup)
  12. beforeEach(() => {
  13. vi.useRealTimers()
  14. })
  15. function bodyRows(container: HTMLElement): string[] {
  16. return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
  17. }
  18. function changeRows(container: HTMLElement): string[] {
  19. return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
  20. }
  21. function added(count: number): string {
  22. return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
  23. }
  24. describe('DiffBlock structure', () => {
  25. it('renders a create as a path header and an added block (no removed side)', () => {
  26. const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
  27. const { container } = render(<DiffBlock diffs={diffs} />)
  28. expect(screen.getByText('notes/new.txt')).toBeTruthy()
  29. // No removed rows: both change lines are added.
  30. expect(changeRows(container)).toEqual(['hello', 'world'])
  31. expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
  32. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
  33. })
  34. it('renders an edit as a removed block above an added block', () => {
  35. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
  36. const { container } = render(<DiffBlock diffs={diffs} />)
  37. expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
  38. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
  39. expect(changeRows(container)).toEqual(['old', 'new'])
  40. })
  41. it('opens a same-file second hunk with a gap instead of repeating the path', () => {
  42. const diffs: DiffHunk[] = [
  43. { path: 'a.ts', oldText: 'x', newText: 'y' },
  44. { path: 'a.ts', oldText: 'p', newText: 'q' },
  45. ]
  46. const { container } = render(<DiffBlock diffs={diffs} />)
  47. // One path header, one gap row.
  48. expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
  49. expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
  50. })
  51. it('opens a new file with its own path header', () => {
  52. const diffs: DiffHunk[] = [
  53. { path: 'a.ts', oldText: 'x', newText: 'y' },
  54. { path: 'b.ts', oldText: 'p', newText: 'q' },
  55. ]
  56. const { container } = render(<DiffBlock diffs={diffs} />)
  57. expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
  58. expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
  59. })
  60. it('renders nothing for empty diffs', () => {
  61. const { container } = render(<DiffBlock diffs={[]} />)
  62. expect(container.firstChild).toBeNull()
  63. })
  64. it('treats a trailing newline as a terminator, not an extra blank line', () => {
  65. // A create whose newText ends in a newline is one added line, not two, and
  66. // the footer counts one — the phantom `+ ` empty line the naive split drew.
  67. const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
  68. expect(changeRows(container)).toEqual(['hello'])
  69. expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
  70. })
  71. it('renders a full deletion as removed-only with no phantom added line', () => {
  72. // newText '' is zero added lines: an empty string must contribute nothing.
  73. const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
  74. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
  75. expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
  76. })
  77. it('keeps a genuine interior blank line', () => {
  78. const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
  79. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
  80. })
  81. })
  82. describe('DiffBlock local changes', () => {
  83. it.each([128, 129])('renders and copies %i replacements with bounded comparison', async (count) => {
  84. const writeText = vi.fn().mockResolvedValue(undefined)
  85. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  86. const oldLines = ['shared context', ...Array.from({ length: count }, (_, i) => `old ${i}`)]
  87. const newLines = ['shared context', ...Array.from({ length: count }, (_, i) => `new ${i}`)]
  88. const diffs = [{ path: 'large.txt', oldText: oldLines.join('\n'), newText: newLines.join('\n') }]
  89. const total = count === 128 ? count : count + 1
  90. render(<DiffBlock diffs={diffs} maxLines={1000} />)
  91. expect(diffTotals(diffs)).toEqual({ added: total, removed: total })
  92. expect(screen.getByText(`└ +${total} -${total} · 1 file`)).toBeTruthy()
  93. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
  94. expect(writeText).toHaveBeenCalledWith(count === 128
  95. ? ['large.txt', ' shared context', ...oldLines.slice(1).map(line => `- ${line}`), ...newLines.slice(1).map(line => `+ ${line}`)].join('\n')
  96. : ['large.txt', ...oldLines.map(line => `- ${line}`), ...newLines.map(line => `+ ${line}`)].join('\n'))
  97. })
  98. it('keeps a sparse edit exact in a ten-thousand-line fragment', () => {
  99. const before = Array.from({ length: 10000 }, (_, i) => `line ${i}`)
  100. const after = [...before]
  101. after[5000] = 'changed'
  102. const diffs = [{ path: 'sparse.txt', oldText: before.join('\n'), newText: after.join('\n') }]
  103. const { container } = render(<DiffBlock diffs={diffs} />)
  104. expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
  105. expect(bodyRows(container)).toEqual([
  106. 'sparse.txt', 'line 4997', 'line 4998', 'line 4999', 'line 5000', 'changed',
  107. 'line 5001', 'line 5002', 'line 5003',
  108. ])
  109. })
  110. it('copies shared context once and counts only a changed line', async () => {
  111. const writeText = vi.fn().mockResolvedValue(undefined)
  112. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  113. const diffs = [{ path: 'settings.ts', oldText: 'start\nmode = 1\nend', newText: 'start\nmode = 2\nend' }]
  114. render(<DiffBlock diffs={diffs} />)
  115. expect(screen.getAllByText('start')).toHaveLength(1)
  116. expect(screen.getAllByText('end')).toHaveLength(1)
  117. expect(screen.getByText('└ +1 -1 · 1 file')).toBeTruthy()
  118. expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
  119. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
  120. expect(writeText).toHaveBeenCalledWith('settings.ts\n start\n- mode = 1\n+ mode = 2\n end')
  121. })
  122. it('keeps three neutral context lines around distant changes and separates hunks', () => {
  123. const before = Array.from({ length: 50 }, (_, i) => `item ${i}`)
  124. const after = before.map((line, i) => i === 10 || i === 40 ? `changed ${i}` : line)
  125. const { container } = render(<DiffBlock diffs={[{
  126. path: 'items.txt', oldText: before.join('\n'), newText: after.join('\n'),
  127. }]} maxLines={100} />)
  128. expect(bodyRows(container)).toEqual([
  129. 'items.txt', 'item 7', 'item 8', 'item 9', 'item 10', 'changed 10',
  130. 'item 11', 'item 12', 'item 13', '⋯', 'item 37', 'item 38', 'item 39',
  131. 'item 40', 'changed 40', 'item 41', 'item 42', 'item 43',
  132. ])
  133. expect(changeRows(container)).toEqual(['item 10', 'changed 10', 'item 40', 'changed 40'])
  134. expect(screen.getByText('└ +2 -2 · 1 file')).toBeTruthy()
  135. })
  136. it.each([
  137. ['a\nx\na\ny\na', 'a\nx\na\nz\na', 1, 1],
  138. ['a\nb', 'a\nx\nb', 1, 0],
  139. ['a\nx\nb', 'a\nb', 0, 1],
  140. ['same\n', 'same', 0, 0],
  141. ['same', 'same', 0, 0],
  142. ['', '', 0, 0],
  143. ['a\n\nb', 'a\nb', 0, 1],
  144. ])('counts ordered changes in %j → %j', (oldText, newText, added, removed) => {
  145. const diffs = [{ path: 'a.txt', oldText, newText }]
  146. render(<DiffBlock diffs={diffs} />)
  147. expect(diffTotals(diffs)).toEqual({ added, removed })
  148. expect(screen.getByText(`└ +${added} -${removed} · 1 file`)).toBeTruthy()
  149. })
  150. })
  151. describe('DiffBlock footer', () => {
  152. it('counts added and removed lines and one file', () => {
  153. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
  154. render(<DiffBlock diffs={diffs} />)
  155. expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
  156. })
  157. it('pluralizes the distinct-file count', () => {
  158. const diffs: DiffHunk[] = [
  159. { path: 'a.ts', oldText: null, newText: 'x' },
  160. { path: 'b.ts', oldText: null, newText: 'y' },
  161. ]
  162. render(<DiffBlock diffs={diffs} />)
  163. expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
  164. })
  165. })
  166. describe('DiffBlock height cap', () => {
  167. it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
  168. // One added line over the default cap forces the collapse.
  169. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
  170. // The path header counts as a row, so a body of maxLines added lines plus
  171. // the header is one over the cap.
  172. const { container } = render(<DiffBlock diffs={diffs} />)
  173. const toggle = screen.getByRole('button', { name: /展开其余/ })
  174. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  175. // Collapsed shows fewer rows than the full body.
  176. const collapsedCount = bodyRows(container).length
  177. expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
  178. fireEvent.click(toggle)
  179. expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
  180. expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
  181. })
  182. it('shows no expand control at or under the cap', () => {
  183. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
  184. render(<DiffBlock diffs={diffs} maxLines={16} />)
  185. expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
  186. })
  187. })
  188. describe('DiffBlock copy', () => {
  189. it('copies the prefixed diff text and flips the label on success', async () => {
  190. vi.useFakeTimers()
  191. const writeText = vi.fn().mockResolvedValue(undefined)
  192. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  193. const diffs: DiffHunk[] = [
  194. { path: 'a.ts', oldText: 'old', newText: 'new' },
  195. { path: 'a.ts', oldText: 'p', newText: 'q' },
  196. ]
  197. render(<DiffBlock diffs={diffs} />)
  198. const copy = screen.getByRole('button', { name: '复制' })
  199. await act(async () => { fireEvent.click(copy) })
  200. // Path header, del/add prefixes, and the same-file gap all reach the clipboard.
  201. expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
  202. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  203. await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
  204. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  205. })
  206. it('keeps the label on a refused clipboard write', async () => {
  207. Object.defineProperty(navigator, 'clipboard', {
  208. configurable: true,
  209. value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
  210. })
  211. render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
  212. const copy = screen.getByRole('button', { name: '复制' })
  213. await act(async () => { fireEvent.click(copy) })
  214. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  215. })
  216. it('ignores a second click while the copied label is showing', async () => {
  217. vi.useFakeTimers()
  218. const writeText = vi.fn().mockResolvedValue(undefined)
  219. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  220. render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
  221. const copy = screen.getByRole('button', { name: '复制' })
  222. await act(async () => { fireEvent.click(copy) })
  223. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
  224. expect(writeText).toHaveBeenCalledTimes(1)
  225. })
  226. })