|
|
@@ -2,6 +2,7 @@
|
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
|
|
|
|
|
afterEach(cleanup)
|
|
|
beforeEach(() => { vi.useFakeTimers() })
|
|
|
@@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
|
|
+function mount(props: {
|
|
|
+ openDelayMs?: number
|
|
|
+ disabled?: boolean
|
|
|
+ copyText?: string
|
|
|
+ copyLabel?: string
|
|
|
+ copiedLabel?: string
|
|
|
+} = {}) {
|
|
|
const view = render(
|
|
|
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
|
|
|
)
|
|
|
@@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
|
|
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
|
|
|
}
|
|
|
|
|
|
+/** Install the async browser clipboard and restore its prior host shape. */
|
|
|
+function installClipboard(writeText: (text: string) => Promise<void>): () => void {
|
|
|
+ const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
|
|
+ Object.defineProperty(navigator, 'clipboard', {
|
|
|
+ configurable: true,
|
|
|
+ value: { writeText },
|
|
|
+ })
|
|
|
+ return () => {
|
|
|
+ if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
|
|
+ else Object.defineProperty(navigator, 'clipboard', prior)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
describe('HoverCard', () => {
|
|
|
it('opens after the dwell delay, positioned right of the anchor', () => {
|
|
|
const { wrapper } = mount()
|
|
|
@@ -54,18 +74,47 @@ describe('HoverCard', () => {
|
|
|
expect(screen.queryByText('card body')).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
|
|
|
+ it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
|
|
|
const { wrapper } = mount()
|
|
|
fireEvent.pointerEnter(wrapper)
|
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
|
expect(screen.getByText('card body')).toBeTruthy()
|
|
|
fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ act(() => { vi.advanceTimersByTime(1) })
|
|
|
expect(screen.queryByText('card body')).toBeNull()
|
|
|
fireEvent.pointerEnter(wrapper)
|
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
|
expect(screen.getByText('card body')).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
+ it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
|
|
|
+ // The portaled card is a React child of the wrapper, so the pointer
|
|
|
+ // arriving on it re-enters the wrapper — the gesture the 8px anchor gap
|
|
|
+ // used to make impossible.
|
|
|
+ const { wrapper } = mount()
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('re-entering while open does not queue a second dwell', () => {
|
|
|
+ const { wrapper } = mount()
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
|
|
+ // A dwell restarted by the redundant enter would reopen the card here.
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ expect(screen.queryByText('card body')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
|
|
|
const { wrapper } = mount()
|
|
|
fireEvent.pointerEnter(wrapper)
|
|
|
@@ -78,6 +127,237 @@ describe('HoverCard', () => {
|
|
|
expect(screen.queryByText('card body')).toBeNull()
|
|
|
})
|
|
|
|
|
|
+ it('a press on the card starts a selection instead of dismissing it', () => {
|
|
|
+ // The card is a React child of the wrapper, so capture-phase presses on
|
|
|
+ // it reach the wrapper's dismissal handler too; they must not close it,
|
|
|
+ // or the first pointerdown of a text-selection drag would kill the card.
|
|
|
+ const { wrapper } = mount()
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ fireEvent.pointerDown(screen.getByText('card body'))
|
|
|
+ // Still mounted after a grace's worth of time: no close was armed either.
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a completed card selection instead of treating its click as copy', async () => {
|
|
|
+ const writeText = vi.fn(async () => {})
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ const selection = window.getSelection()
|
|
|
+ if (selection === null) throw new Error('jsdom selection API unavailable')
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ const card = screen.getByRole('button', { name: 'Copy: card body' })
|
|
|
+ const selectedText = screen.getByText('card body')
|
|
|
+ const cardRange = document.createRange()
|
|
|
+ cardRange.selectNodeContents(selectedText)
|
|
|
+ selection.addRange(cardRange)
|
|
|
+ await act(async () => { fireEvent.click(card) })
|
|
|
+ expect(writeText).not.toHaveBeenCalled()
|
|
|
+ expect(selection.toString()).toBe('card body')
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+
|
|
|
+ // Firefox supports multiple selection ranges: any range intersecting
|
|
|
+ // this card wins, not only the first.
|
|
|
+ selection.removeAllRanges()
|
|
|
+ const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
|
|
|
+ isCollapsed: false,
|
|
|
+ rangeCount: 2,
|
|
|
+ getRangeAt: vi.fn((index: number) => ({
|
|
|
+ intersectsNode: () => index === 1,
|
|
|
+ })),
|
|
|
+ } as unknown as Selection)
|
|
|
+ await act(async () => { fireEvent.click(card) })
|
|
|
+ expect(writeText).not.toHaveBeenCalled()
|
|
|
+ getSelection.mockRestore()
|
|
|
+
|
|
|
+ // A non-collapsed selection elsewhere does not block this card.
|
|
|
+ const anchorRange = document.createRange()
|
|
|
+ anchorRange.selectNodeContents(screen.getByText('row'))
|
|
|
+ selection.addRange(anchorRange)
|
|
|
+ await act(async () => { fireEvent.click(card) })
|
|
|
+ expect(writeText).toHaveBeenCalledWith('card body')
|
|
|
+ } finally {
|
|
|
+ selection.removeAllRanges()
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('a press while closed leaves the card closed', () => {
|
|
|
+ mount()
|
|
|
+ fireEvent.pointerDown(screen.getByText('row'))
|
|
|
+ act(() => { vi.advanceTimersByTime(1000) })
|
|
|
+ expect(screen.queryByText('card body')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('copies its configured value and shows success only for the feedback window', async () => {
|
|
|
+ const writeText = vi.fn(async () => {})
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({
|
|
|
+ copyText: '/full/path',
|
|
|
+ copyLabel: 'Copy path',
|
|
|
+ copiedLabel: 'Copied',
|
|
|
+ })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
|
|
|
+ const status = screen.getByRole('status')
|
|
|
+ expect(status.textContent).toBe('')
|
|
|
+ expect(card.contains(status)).toBe(false)
|
|
|
+ Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
|
|
|
+ await act(async () => { fireEvent.click(card) })
|
|
|
+ expect(writeText).toHaveBeenCalledWith('/full/path')
|
|
|
+ expect(status.textContent).toBe('Copied')
|
|
|
+ expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
|
|
+ expect(card.style.minHeight).toBe('96px')
|
|
|
+ // Repeated activation while feedback is visible neither rewrites nor
|
|
|
+ // extends the one-second success window.
|
|
|
+ await act(async () => { fireEvent.click(card) })
|
|
|
+ expect(writeText).toHaveBeenCalledOnce()
|
|
|
+ act(() => { vi.advanceTimersByTime(999) })
|
|
|
+ expect(status.textContent).toBe('Copied')
|
|
|
+ act(() => { vi.advanceTimersByTime(1) })
|
|
|
+ expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
|
|
+ expect(card.style.minHeight).toBe('')
|
|
|
+ expect(status.textContent).toBe('')
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('supports button keys and ignores unrelated keys', async () => {
|
|
|
+ const writeText = vi.fn(async () => {})
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ const card = screen.getByRole('button')
|
|
|
+ fireEvent.keyDown(card, { key: 'Escape' })
|
|
|
+ expect(writeText).not.toHaveBeenCalled()
|
|
|
+ await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) })
|
|
|
+ expect(writeText).toHaveBeenCalledOnce()
|
|
|
+ act(() => { vi.advanceTimersByTime(1000) })
|
|
|
+ await act(async () => { fireEvent.keyDown(card, { key: ' ' }) })
|
|
|
+ expect(writeText).toHaveBeenCalledTimes(2)
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps its content when the clipboard rejects the write', async () => {
|
|
|
+ const writeText = vi.fn(async () => { throw new Error('denied') })
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
|
|
+ expect(screen.queryByText('Copied')).toBeNull()
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('unmount clears copied feedback', async () => {
|
|
|
+ const writeText = vi.fn(async () => {})
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { view, wrapper } = mount({ copyText: 'value' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
|
|
+ expect(vi.getTimerCount()).toBe(1)
|
|
|
+ view.unmount()
|
|
|
+ expect(vi.getTimerCount()).toBe(0)
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('clears copied feedback when the card closes', async () => {
|
|
|
+ const writeText = vi.fn(async () => {})
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
|
|
+ expect(screen.getByRole('status').textContent).toBe('Copied')
|
|
|
+ fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
|
|
+ expect(screen.queryByText('Copied')).toBeNull()
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not create copied feedback after an in-flight write unmounts', async () => {
|
|
|
+ let acceptWrite: (() => void) | undefined
|
|
|
+ const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { view, wrapper } = mount({ copyText: 'value' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ fireEvent.click(screen.getByRole('button'))
|
|
|
+ expect(writeText).toHaveBeenCalledOnce()
|
|
|
+ view.unmount()
|
|
|
+ await act(async () => { acceptWrite?.() })
|
|
|
+ expect(vi.getTimerCount()).toBe(0)
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not restore copied feedback after an in-flight card closes', async () => {
|
|
|
+ let acceptWrite: (() => void) | undefined
|
|
|
+ const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ fireEvent.click(screen.getByRole('button'))
|
|
|
+ fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ await act(async () => { acceptWrite?.() })
|
|
|
+ expect(vi.getTimerCount()).toBe(0)
|
|
|
+ expect(screen.getByText('card body')).toBeTruthy()
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('coalesces activations while the clipboard write is in flight', async () => {
|
|
|
+ let acceptWrite: (() => void) | undefined
|
|
|
+ const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
|
|
+ const restoreClipboard = installClipboard(writeText)
|
|
|
+ try {
|
|
|
+ const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
|
|
+ fireEvent.pointerEnter(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ const card = screen.getByRole('button')
|
|
|
+ fireEvent.click(card)
|
|
|
+ fireEvent.click(card)
|
|
|
+ expect(writeText).toHaveBeenCalledOnce()
|
|
|
+ await act(async () => { acceptWrite?.() })
|
|
|
+ expect(screen.getByRole('status').textContent).toBe('Copied')
|
|
|
+ } finally {
|
|
|
+ restoreClipboard()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
it('disabled suppresses opening entirely', () => {
|
|
|
const { wrapper } = mount({ disabled: true })
|
|
|
fireEvent.pointerEnter(wrapper)
|
|
|
@@ -135,6 +415,7 @@ describe('HoverCard', () => {
|
|
|
expect(card.style.left).toBe('308px')
|
|
|
expect(card.style.top).toBe('90px')
|
|
|
fireEvent.pointerLeave(wrapper)
|
|
|
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
|
|
expect(screen.queryByText('card body')).toBeNull()
|
|
|
})
|
|
|
|