Files
TREK/client/src/components/shared/Tooltip.test.tsx
T
Maurice 214984f398 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-21 22:29:56 +02:00

216 lines
7.8 KiB
TypeScript

// FE-W4TIP-001 to FE-W4TIP-017
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' })
})
})
describe('Tooltip — with a shortcut', () => {
it('FE-W4TIP-016: shows the keys beside the label, one chip per key', () => {
render(<Tooltip label="Assistant" shortcut={['⌘', 'K']}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
const tip = screen.getByRole('tooltip')
expect(tip).toHaveTextContent('Assistant')
const keys = tip.querySelectorAll('kbd > span')
expect(Array.from(keys).map(k => k.textContent)).toEqual(['⌘', 'K'])
})
it('FE-W4TIP-017: an empty shortcut leaves the plain label', () => {
render(<Tooltip label="Assistant" shortcut={[]}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull()
})
})