import { act, fireEvent, render, screen } from '@testing-library/react' import { useState } from 'react' import { describe, expect, it, vi } from 'vitest' import ElevationProfile from './ElevationProfile' import type { RouteProfileFocus } from '../../utils/routeGeometry' import * as elevationProfileModel from './elevationProfileModel' const samples = [ { distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 }, { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01 }, { distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03 }, ] function InteractiveProfile({ onFocus, samples: profileSamples = samples, externalFocus = null, height = 100 }: { onFocus?: (focus: RouteProfileFocus | null) => void samples?: typeof samples externalFocus?: RouteProfileFocus | null height?: number }) { const [focus, setFocus] = useState(null) return { setFocus(next); onFocus?.(next) }} formatFocus={point => `${point.distanceMeters} m · ${point.elevationMeters} m`} /> } describe('ElevationProfile', () => { it('positions samples by travelled distance rather than array index', () => { const { container } = render( , ) const path = container.querySelector('path[stroke]')?.getAttribute('d') expect(path).toBe('M0.0,96.0 L44.0,4.0 L440.0,59.2') }) it('renders nothing without at least two samples', () => { const { container } = render() expect(container).toBeEmptyDOMElement() }) it('maps pointer position to distance and shows the corresponding focus readout', () => { const onFocus = vi.fn() const frames: FrameRequestCallback[] = [] vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length }) render() const profile = screen.getByRole('group', { name: 'Elevation profile' }) vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({ x: 10, y: 0, left: 10, top: 0, right: 450, bottom: 100, width: 440, height: 100, toJSON: () => ({}), } as DOMRect) fireEvent.pointerMove(profile, { clientX: 230, pointerType: 'mouse' }) act(() => frames.shift()?.(16)) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 150, elevationMeters: 187.5, lat: 48.015, lng: 11.015, sampleIndex: 1 }) expect(profile.querySelector('[data-profile-focus-line]')).toBeInTheDocument() expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toBeInTheDocument() const readout = profile.parentElement?.querySelector('[data-profile-focus-readout]') expect(readout?.textContent).toBe('150 m · 187.5 m') expect(readout).toHaveClass('rounded-lg', 'border-edge-faint', 'bg-surface-card', 'text-content', 'shadow-elevated') expect(readout).toHaveStyle({ fontFamily: 'var(--font-system)', fontWeight: '500', pointerEvents: 'none' }) expect(readout).toHaveStyle({ maxWidth: 'min(164px, calc(100% - 8px))' }) expect(profile).toHaveStyle({ fontFamily: 'var(--font-system)' }) expect(profile.querySelector('[data-profile-focus-line]')).toHaveAttribute('stroke', 'var(--text-muted)') expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)') expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('') fireEvent.pointerMove(profile, { clientX: -50, pointerType: 'mouse' }) act(() => frames.shift()?.(32)) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 }) fireEvent.pointerMove(profile, { clientX: 900, pointerType: 'mouse' }) act(() => frames.shift()?.(48)) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 }) fireEvent.pointerLeave(profile) expect(onFocus).toHaveBeenLastCalledWith(null) vi.restoreAllMocks() }) it('supports focus, arrow keys, Home, End and Escape without pointer input', () => { const onFocus = vi.fn() render() const profile = screen.getByRole('group', { name: 'Elevation profile' }) profile.focus() expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 }) fireEvent.keyDown(profile, { key: 'ArrowRight' }) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }) fireEvent.keyDown(profile, { key: 'End' }) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 }) fireEvent.keyDown(profile, { key: 'Home' }) expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 }) fireEvent.keyDown(profile, { key: 'Escape' }) expect(onFocus).toHaveBeenLastCalledWith(null) expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('') }) it('is not keyboard-focusable or interactive without geographic sample coordinates', () => { render() expect(screen.queryByRole('group', { name: 'Elevation profile' })).toBeNull() expect(document.querySelector('svg[tabindex="0"]')).toBeNull() }) it('reuses static chart geometry for pointer, keyboard and external focus updates', () => { const prepared = vi.spyOn(elevationProfileModel, 'prepareElevationProfileChart') const callbacks = new Map() let nextFrameId = 0 const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { const id = ++nextFrameId callbacks.set(id, callback) return id }) const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { callbacks.delete(id) }) const onFocus = vi.fn() const { rerender, unmount } = render() const profile = screen.getByRole('group', { name: 'Elevation profile' }) expect(prepared).toHaveBeenCalledTimes(1) vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 440, bottom: 100, width: 440, height: 100, toJSON: () => ({}), } as DOMRect) fireEvent.pointerMove(profile, { clientX: 90, pointerType: 'mouse' }) fireEvent.pointerMove(profile, { clientX: 180, pointerType: 'mouse' }) fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' }) expect(raf).toHaveBeenCalledTimes(1) expect(prepared).toHaveBeenCalledTimes(1) const pendingFrameId = [...callbacks.keys()][0] fireEvent.pointerLeave(profile) expect(cancel).toHaveBeenCalledWith(pendingFrameId) expect(callbacks.size).toBe(0) fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' }) expect(raf).toHaveBeenCalledTimes(2) const [frameId, callback] = [...callbacks.entries()][0] act(() => callback(16)) callbacks.delete(frameId) expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({ sampleIndex: 1 })) expect(onFocus).toHaveBeenCalledTimes(2) expect(prepared).toHaveBeenCalledTimes(1) fireEvent.keyDown(profile, { key: 'ArrowRight' }) fireEvent.keyDown(profile, { key: 'ArrowLeft' }) expect(prepared).toHaveBeenCalledTimes(1) const externalFocus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 } rerender() expect(prepared).toHaveBeenCalledTimes(1) rerender() expect(prepared).toHaveBeenCalledTimes(2) unmount() }) it('bands the stretches it is given behind the line, placed by distance along the route', () => { const { container } = render( , ) const band = container.querySelector('[data-profile-band]') expect(band).toHaveAttribute('x', '220') expect(band).toHaveAttribute('width', '220') expect(band).toHaveAttribute('fill', 'var(--map-ferrata)') }) it('FE-ELEV-001: a band that brings its own colour is drawn in it, a little stronger than the via ferrata tint', () => { const { container } = render( , ) const band = container.querySelector('[data-profile-band]') expect(band).toHaveAttribute('fill', 'var(--hike-sac-t4)') expect(band).toHaveAttribute('fill-opacity', '0.22') }) it('FE-ELEV-002: other ways are drawn thin on axes shared with the route, so a longer one reaches the right edge', () => { const longer = [ { distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 }, { distanceMeters: 600, elevationMeters: 300, lat: 48.06, lng: 11.06 }, ] const { container } = render( , ) const other = container.querySelector('[data-profile-compare="b"]') expect(other).toHaveAttribute('d', 'M0.0,96.0 L440.0,4.0') expect(other).toHaveAttribute('stroke', 'var(--hike-variant-b)') const line = container.querySelector('path[stroke="currentColor"]')?.getAttribute('d') expect(line).toBe('M0.0,96.0 L73.3,50.0 L220.0,73.0') expect(container.querySelector('path[fill^="url("]')?.getAttribute('d')).toContain('L220.0,100 L0.0,100 Z') }) it('FE-ELEV-003: with the readout left to the caller no box is drawn over the chart, the focus line stays', () => { const focus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 } const { container } = render( , ) expect(container.querySelector('[data-profile-focus-readout]')).toBeNull() expect(container.querySelector('[data-profile-focus-line]')).not.toBeNull() }) it('FE-ELEV-004: keeps the spot a finger lifted from, while a mouse leaving clears it as before', () => { const onFocus = vi.fn() render( , ) const profile = screen.getByRole('group', { name: 'Elevation profile' }) fireEvent.pointerLeave(profile, { pointerType: 'touch' }) fireEvent.pointerCancel(profile, { pointerType: 'touch' }) expect(onFocus).not.toHaveBeenCalledWith(null) fireEvent.pointerLeave(profile, { pointerType: 'mouse' }) expect(onFocus).toHaveBeenLastCalledWith(null) }) })