diff-block.client.spec.tsx 8.1 KB

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