hover-card.spec.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. // @vitest-environment jsdom
  2. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
  5. afterEach(cleanup)
  6. beforeEach(() => { vi.useFakeTimers() })
  7. afterEach(() => { vi.useRealTimers() })
  8. /** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */
  9. function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void {
  10. const wrapper = anchor.parentElement as HTMLElement
  11. wrapper.getBoundingClientRect = () => ({
  12. top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
  13. width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
  14. })
  15. }
  16. function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
  17. const view = render(
  18. <HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
  19. )
  20. const anchor = screen.getByText('row')
  21. stubAnchorRect(anchor, { top: 40, right: 200 })
  22. return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
  23. }
  24. describe('HoverCard', () => {
  25. it('opens after the dwell delay, positioned right of the anchor', () => {
  26. const { wrapper } = mount()
  27. fireEvent.pointerEnter(wrapper)
  28. expect(screen.queryByText('card body')).toBeNull()
  29. act(() => { vi.advanceTimersByTime(499) })
  30. expect(screen.queryByText('card body')).toBeNull()
  31. act(() => { vi.advanceTimersByTime(1) })
  32. const card = screen.getByText('card body').parentElement as HTMLElement
  33. expect(card.parentElement).toBe(document.body)
  34. expect(card.style.left).toBe('208px')
  35. expect(card.style.top).toBe('40px')
  36. })
  37. it('honors a custom openDelayMs', () => {
  38. const { wrapper } = mount({ openDelayMs: 50 })
  39. fireEvent.pointerEnter(wrapper)
  40. act(() => { vi.advanceTimersByTime(50) })
  41. expect(screen.getByText('card body')).toBeTruthy()
  42. })
  43. it('pointerleave before the delay cancels the pending open', () => {
  44. const { wrapper } = mount()
  45. fireEvent.pointerEnter(wrapper)
  46. fireEvent.pointerLeave(wrapper)
  47. act(() => { vi.advanceTimersByTime(1000) })
  48. expect(screen.queryByText('card body')).toBeNull()
  49. })
  50. it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
  51. const { wrapper } = mount()
  52. fireEvent.pointerEnter(wrapper)
  53. act(() => { vi.advanceTimersByTime(500) })
  54. expect(screen.getByText('card body')).toBeTruthy()
  55. fireEvent.pointerLeave(wrapper)
  56. expect(screen.queryByText('card body')).toBeNull()
  57. fireEvent.pointerEnter(wrapper)
  58. act(() => { vi.advanceTimersByTime(500) })
  59. expect(screen.getByText('card body')).toBeTruthy()
  60. })
  61. it('a press inside the anchor dismisses the card without waiting for disabled', () => {
  62. const { wrapper } = mount()
  63. fireEvent.pointerEnter(wrapper)
  64. act(() => { vi.advanceTimersByTime(500) })
  65. expect(screen.getByText('card body')).toBeTruthy()
  66. fireEvent.pointerDown(screen.getByText('row'))
  67. expect(screen.queryByText('card body')).toBeNull()
  68. // The pending timer is also cleared: no reopen after the dwell.
  69. act(() => { vi.advanceTimersByTime(1000) })
  70. expect(screen.queryByText('card body')).toBeNull()
  71. })
  72. it('disabled suppresses opening entirely', () => {
  73. const { wrapper } = mount({ disabled: true })
  74. fireEvent.pointerEnter(wrapper)
  75. act(() => { vi.advanceTimersByTime(1000) })
  76. expect(screen.queryByText('card body')).toBeNull()
  77. })
  78. it('flipping disabled true closes an open card', () => {
  79. const { view, wrapper } = mount()
  80. fireEvent.pointerEnter(wrapper)
  81. act(() => { vi.advanceTimersByTime(500) })
  82. expect(screen.getByText('card body')).toBeTruthy()
  83. view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
  84. expect(screen.queryByText('card body')).toBeNull()
  85. })
  86. it('corrects the bottom-edge clamp once the mounted card height is measurable', () => {
  87. // First placement reads height 0 (card not yet mounted) and keeps the
  88. // anchor top; the post-mount correction re-clamps with the real height.
  89. window.innerHeight = 300
  90. const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
  91. Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 })
  92. try {
  93. const { wrapper } = mount()
  94. stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
  95. fireEvent.pointerEnter(wrapper)
  96. act(() => { vi.advanceTimersByTime(500) })
  97. const card = screen.getByText('card body').parentElement as HTMLElement
  98. // 300 - 120 - 8 = 172, instead of the anchor top 280.
  99. expect(card.style.top).toBe('172px')
  100. } finally {
  101. Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
  102. }
  103. })
  104. it('clamps inside placement itself when the card is already measured (resize path)', () => {
  105. window.innerHeight = 300
  106. const { wrapper } = mount()
  107. stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
  108. fireEvent.pointerEnter(wrapper)
  109. act(() => { vi.advanceTimersByTime(500) })
  110. const card = screen.getByText('card body').parentElement as HTMLElement
  111. Object.defineProperty(card, 'offsetHeight', { value: 120 })
  112. act(() => { fireEvent.resize(window) })
  113. expect(card.style.top).toBe('172px')
  114. })
  115. it('repositions on capture-phase scroll while open and stops listening after close', () => {
  116. const { wrapper } = mount()
  117. fireEvent.pointerEnter(wrapper)
  118. act(() => { vi.advanceTimersByTime(500) })
  119. stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 })
  120. act(() => { fireEvent.scroll(document) })
  121. const card = screen.getByText('card body').parentElement as HTMLElement
  122. expect(card.style.left).toBe('308px')
  123. expect(card.style.top).toBe('90px')
  124. fireEvent.pointerLeave(wrapper)
  125. expect(screen.queryByText('card body')).toBeNull()
  126. })
  127. it('unmount clears a pending open timer', () => {
  128. const { view, wrapper } = mount()
  129. fireEvent.pointerEnter(wrapper)
  130. view.unmount()
  131. act(() => { vi.advanceTimersByTime(1000) })
  132. expect(screen.queryByText('card body')).toBeNull()
  133. })
  134. })