diff-block.spec.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. // @vitest-environment jsdom
  2. // DiffBlock: the per-file hunk rows (path header, removed block, added block),
  3. // the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
  4. // its singular/plural, the head/tail height cap and its expand control, the
  5. // empty-diffs null render, and the copy control writing the prefixed diff text
  6. // on both the accepted and the refused clipboard paths. writeClipboard's own
  7. // return contract is pinned in terminal-block.spec.tsx (the shared seam), so
  8. // only its DOM consequence is asserted here.
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  11. import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
  12. afterEach(cleanup)
  13. beforeEach(() => {
  14. vi.useRealTimers()
  15. })
  16. /** The rendered body rows, one string per visible line (CSS-module class prefix). */
  17. function bodyRows(container: HTMLElement): string[] {
  18. return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
  19. }
  20. /** Only the changed rows (add/del), excluding the path header and gap chrome. */
  21. function changeRows(container: HTMLElement): string[] {
  22. return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
  23. }
  24. /** `count` numbered added lines as one hunk's newText. */
  25. function added(count: number): string {
  26. return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
  27. }
  28. describe('DiffBlock structure', () => {
  29. it('renders a create as a path header and an added block (no removed side)', () => {
  30. const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
  31. const { container } = render(<DiffBlock diffs={diffs} />)
  32. expect(screen.getByText('notes/new.txt')).toBeTruthy()
  33. // No removed rows: both change lines are added.
  34. expect(changeRows(container)).toEqual(['hello', 'world'])
  35. expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
  36. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
  37. })
  38. it('renders an edit as a removed block above an added block', () => {
  39. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
  40. const { container } = render(<DiffBlock diffs={diffs} />)
  41. expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
  42. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
  43. expect(changeRows(container)).toEqual(['old', 'new'])
  44. })
  45. it('opens a same-file second hunk with a gap instead of repeating the path', () => {
  46. const diffs: DiffHunk[] = [
  47. { path: 'a.ts', oldText: 'x', newText: 'y' },
  48. { path: 'a.ts', oldText: 'p', newText: 'q' },
  49. ]
  50. const { container } = render(<DiffBlock diffs={diffs} />)
  51. // One path header, one gap row.
  52. expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
  53. expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
  54. })
  55. it('opens a new file with its own path header', () => {
  56. const diffs: DiffHunk[] = [
  57. { path: 'a.ts', oldText: 'x', newText: 'y' },
  58. { path: 'b.ts', oldText: 'p', newText: 'q' },
  59. ]
  60. const { container } = render(<DiffBlock diffs={diffs} />)
  61. expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
  62. expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
  63. })
  64. it('renders nothing for empty diffs', () => {
  65. const { container } = render(<DiffBlock diffs={[]} />)
  66. expect(container.firstChild).toBeNull()
  67. })
  68. it('treats a trailing newline as a terminator, not an extra blank line', () => {
  69. // A create whose newText ends in a newline is one added line, not two, and
  70. // the footer counts one — the phantom `+ ` empty line the naive split drew.
  71. const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
  72. expect(changeRows(container)).toEqual(['hello'])
  73. expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
  74. })
  75. it('renders a full deletion as removed-only with no phantom added line', () => {
  76. // newText '' is zero added lines: an empty string must contribute nothing.
  77. const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
  78. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
  79. expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
  80. })
  81. it('keeps a genuine interior blank line', () => {
  82. const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
  83. expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
  84. })
  85. })
  86. describe('DiffBlock footer', () => {
  87. it('counts added and removed lines and one file', () => {
  88. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
  89. render(<DiffBlock diffs={diffs} />)
  90. expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
  91. })
  92. it('pluralizes the distinct-file count', () => {
  93. const diffs: DiffHunk[] = [
  94. { path: 'a.ts', oldText: null, newText: 'x' },
  95. { path: 'b.ts', oldText: null, newText: 'y' },
  96. ]
  97. render(<DiffBlock diffs={diffs} />)
  98. expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
  99. })
  100. })
  101. describe('DiffBlock height cap', () => {
  102. it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
  103. // One added line over the default cap forces the collapse.
  104. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
  105. // The path header counts as a row, so a body of maxLines added lines plus
  106. // the header is one over the cap.
  107. const { container } = render(<DiffBlock diffs={diffs} />)
  108. const toggle = screen.getByRole('button', { name: /展开其余/ })
  109. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  110. // Collapsed shows fewer rows than the full body.
  111. const collapsedCount = bodyRows(container).length
  112. expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
  113. fireEvent.click(toggle)
  114. expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
  115. expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
  116. })
  117. it('shows no expand control at or under the cap', () => {
  118. const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
  119. render(<DiffBlock diffs={diffs} maxLines={16} />)
  120. expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
  121. })
  122. })
  123. describe('DiffBlock copy', () => {
  124. it('copies the prefixed diff text and flips the label on success', async () => {
  125. vi.useFakeTimers()
  126. const writeText = vi.fn().mockResolvedValue(undefined)
  127. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  128. const diffs: DiffHunk[] = [
  129. { path: 'a.ts', oldText: 'old', newText: 'new' },
  130. { path: 'a.ts', oldText: 'p', newText: 'q' },
  131. ]
  132. render(<DiffBlock diffs={diffs} />)
  133. const copy = screen.getByRole('button', { name: '复制' })
  134. await act(async () => { fireEvent.click(copy) })
  135. // Path header, del/add prefixes, and the same-file gap all reach the clipboard.
  136. expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
  137. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  138. await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
  139. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  140. })
  141. it('keeps the label on a refused clipboard write', async () => {
  142. Object.defineProperty(navigator, 'clipboard', {
  143. configurable: true,
  144. value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
  145. })
  146. render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
  147. const copy = screen.getByRole('button', { name: '复制' })
  148. await act(async () => { fireEvent.click(copy) })
  149. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  150. })
  151. it('ignores a second click while the copied label is showing', async () => {
  152. vi.useFakeTimers()
  153. const writeText = vi.fn().mockResolvedValue(undefined)
  154. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  155. render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
  156. const copy = screen.getByRole('button', { name: '复制' })
  157. await act(async () => { fireEvent.click(copy) })
  158. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
  159. expect(writeText).toHaveBeenCalledTimes(1)
  160. })
  161. })