Files
TREK/client/src/components/shared/Tooltip.test.tsx
T
Maurice a7f5a114e6 fix(tours): the checklist keeps to its tour, its state and its focus
A decision answered after another tour was opened no longer replaces that
tour's checklist. A decision made offline on a checklist already older than
the route stays marked as such, and once online a bench from a write made
offline waits for the queue to replay, then takes in the server's. While
the checklist reads again it stays in view, locked, so a panel open on the
map keeps its note. A saved tour turned into a paddle tour asks to be saved
first, a vanished obstacle stays among the rows, Next follows the list
order and keeps the focus in the panel, a note closes only once saved, the
earlier choice in review is named for screen readers too, planning a
portage is offered to editors only, and the map card scrolls within the
screen. A tooltip of several lines breaks at them. A page test waits for
the tours row instead of reading it at once.
2026-10-17 02:07:22 +00:00

197 lines
7.0 KiB
TypeScript

// FE-W4TIP-001 to FE-W4TIP-015
import { createRef, useRef } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
import Tooltip from './Tooltip'
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
function hover(el: HTMLElement) {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
describe('Tooltip', () => {
it('FE-W4TIP-001: renders the trigger and no tooltip until hovered', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
expect(screen.getByRole('button', { name: 'x' })).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-002: opens only after the delay has elapsed', () => {
render(<Tooltip label="Delete" delay={250}><button>x</button></Tooltip>)
fireEvent.mouseEnter(screen.getByRole('button'))
act(() => { vi.advanceTimersByTime(200) })
expect(screen.queryByRole('tooltip')).toBeNull()
act(() => { vi.advanceTimersByTime(100) })
expect(screen.getByRole('tooltip')).toHaveTextContent('Delete')
})
it('FE-W4TIP-003: leaving cancels a pending open', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(trigger)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-004: keyboard focus opens and blur closes the tooltip', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'matches').mockImplementation(sel => sel === ':focus-visible')
fireEvent.focus(trigger)
act(() => { vi.advanceTimersByTime(300) })
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.blur(trigger)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-014: focus handed back without the keyboard (a dialog closing) opens nothing', () => {
render(<Tooltip label="Export"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'matches').mockReturnValue(false)
fireEvent.focus(trigger)
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-005: a disabled tooltip never opens', () => {
render(<Tooltip label="Delete" disabled><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-006: an empty label never opens', () => {
render(<Tooltip label=""><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-007: still chains the original handlers of the child', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
render(
<Tooltip label="Delete">
<button onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>x</button>
</Tooltip>,
)
const trigger = screen.getByRole('button')
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(trigger)
fireEvent.focus(trigger)
fireEvent.blur(trigger)
expect(onMouseEnter).toHaveBeenCalledOnce()
expect(onMouseLeave).toHaveBeenCalledOnce()
expect(onFocus).toHaveBeenCalledOnce()
expect(onBlur).toHaveBeenCalledOnce()
})
it('FE-W4TIP-008: forwards the node to an object ref on the child', () => {
const ref = createRef<HTMLButtonElement>()
render(<Tooltip label="Delete"><button ref={ref}>x</button></Tooltip>)
expect(ref.current).toBe(screen.getByRole('button'))
})
it('FE-W4TIP-009: forwards the node to a callback ref on the child', () => {
const seen: (HTMLElement | null)[] = []
render(<Tooltip label="Delete"><button ref={(n: HTMLButtonElement | null) => { seen.push(n) }}>x</button></Tooltip>)
expect(seen[0]).toBe(screen.getByRole('button'))
})
it('FE-W4TIP-010: positions the tooltip per placement and clamps it into the viewport', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 100, bottom: 130, left: 200, right: 260, width: 60, height: 30, x: 200, y: 100, toJSON: () => ({}),
} as DOMRect)
const { unmount } = render(<Tooltip label="Delete" placement="bottom"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ top: '136px' })
unmount()
render(<Tooltip label="Delete" placement="right"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '266px' })
})
it('FE-W4TIP-011: clamps a top/left placement to the viewport padding', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
const { unmount } = render(<Tooltip label="Delete" placement="top"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ top: '6px' })
unmount()
render(<Tooltip label="Delete" placement="left"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '6px' })
})
it('FE-W4TIP-012: clears a pending timer when the trigger unmounts', () => {
const clear = vi.spyOn(window, 'clearTimeout')
const { unmount } = render(<Tooltip label="Delete"><button>x</button></Tooltip>)
fireEvent.mouseEnter(screen.getByRole('button'))
unmount()
expect(clear).toHaveBeenCalled()
})
})
describe('Tooltip — inside a component that owns the ref', () => {
it('FE-W4TIP-013: keeps the owner ref pointing at the trigger', () => {
function Owner() {
const ref = useRef<HTMLButtonElement>(null)
return (
<Tooltip label="Delete">
<button ref={ref} onClick={() => { ref.current?.setAttribute('data-clicked', '1') }}>x</button>
</Tooltip>
)
}
render(<Owner />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveAttribute('data-clicked', '1')
})
it('FE-W4TIP-015: a label of several lines breaks at them and stays within the window, a single line does not wrap', () => {
const { unmount } = render(<Tooltip label={'3 open\nNo safety or legal clearance.'}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'pre-line' })
expect(screen.getByRole('tooltip').style.maxWidth).toContain('100vw')
unmount()
render(<Tooltip label="Delete"><button>y</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'nowrap' })
})
})