Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 31 additions & 65 deletions frontend/src/__tests__/components/SearchResults.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,71 +321,37 @@ describe('SearchResults Component', () => {
expect(otherArticle).not.toHaveAttribute('aria-current');
});

it.each([
['below the visible list', { top: 600, bottom: 700 }, 250, true],
['above the visible list', { top: -200, bottom: -100 }, 0, true],
['inside the visible list', { top: 100, bottom: 200 }, null, true],
[
'below the visible list when automatic scrolling is disabled',
{ top: 600, bottom: 700 },
null,
false,
],
])(
'handles a highlighted compact result %s',
(_position, bounds, expectedScrollTop, autoScrollHighlightedResult) => {
const { rerender } = render(
<TestWrapper>
<SearchResults
results={mockFixtureData.slice(0, 2)}
isLoading={false}
totalResults={2}
currentPage={1}
variant="compact"
/>
</TestWrapper>
);

const highlightedArticle = screen
.getByText('Nondigitized paper map with library catalog link')
.closest('article') as HTMLElement;
const resultList = screen.getByTestId('map-results-scroll-container');
const scrollTo = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
vi.spyOn(window, 'scrollY', 'get').mockReturnValue(50);
vi.spyOn(resultList, 'getBoundingClientRect').mockReturnValue({
top: 0,
bottom: 500,
} as DOMRect);
vi.spyOn(highlightedArticle, 'getBoundingClientRect').mockReturnValue(
bounds as DOMRect
);

rerender(
<TestWrapper>
<SearchResults
results={mockFixtureData.slice(0, 2)}
isLoading={false}
totalResults={2}
currentPage={1}
variant="compact"
highlightedResourceId={mockFixtureData[0].id}
autoScrollHighlightedResult={autoScrollHighlightedResult}
/>
</TestWrapper>
);

if (expectedScrollTop !== null) {
expect(scrollTo).toHaveBeenCalledWith({
top: expectedScrollTop,
behavior: 'smooth',
});
} else {
expect(scrollTo).not.toHaveBeenCalled();
}
}
);
it('does not scroll when a compact map result is highlighted', () => {
const scrollTo = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const { rerender } = render(
<TestWrapper>
<SearchResults
results={mockFixtureData.slice(0, 2)}
isLoading={false}
totalResults={2}
currentPage={1}
variant="compact"
/>
</TestWrapper>
);

rerender(
<TestWrapper>
<SearchResults
results={mockFixtureData.slice(0, 2)}
isLoading={false}
totalResults={2}
currentPage={1}
variant="compact"
highlightedResourceId={mockFixtureData[0].id}
/>
</TestWrapper>
);

expect(scrollTo).not.toHaveBeenCalled();
});

it('lets compact map results contribute their full height to the page', () => {
render(
Expand Down
95 changes: 80 additions & 15 deletions frontend/src/__tests__/components/search/MapResultView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import L from 'leaflet';
import OverlappingMarkerSpiderfier from '@krozamdev/overlapping-marker-spiderfier';
import {
MapProvider,
useMap as useMapContext,
Expand Down Expand Up @@ -195,6 +196,19 @@ function MapStateProbe() {
);
}

function getOmsListener(
oms: MockOmsInstance,
eventName: string
): (marker: L.Marker) => void {
const listener = oms.addListener.mock.calls.find(
([event]) => event === eventName
)?.[1];
if (typeof listener !== 'function') {
throw new Error(`Missing ${eventName} listener`);
}
return listener as (marker: L.Marker) => void;
}

describe('MapResultView', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -268,9 +282,64 @@ describe('MapResultView', () => {

const icon = mockOmsInstances[0].markers[0].options.icon as L.DivIcon;
const iconHtml = String(icon.options.html);
expect(iconHtml).toContain('>11</span>');
expect(iconHtml).toContain('>11.</span>');
expect(iconHtml).toContain('rotate(-45deg)');
expect(iconHtml).not.toContain('translateX(-50%) rotate(45deg)');
expect(iconHtml).toContain('background: rgb(var(--color-primary));');
expect(iconHtml).toContain('border: 2px solid #fff;');
expect(iconHtml).not.toContain('#4f46e5');
expect(mockOmsInstances[0].markers[0].options.title).toBe('Result 11');
});

it('uses the spiderfier defaults', async () => {
render(
<TestWrapper>
<MapResultView results={mockResultsWithCentroid} />
</TestWrapper>
);

await waitFor(() => {
expect(mockOmsInstances[0]?.markers).toHaveLength(2);
});

expect(OverlappingMarkerSpiderfier).toHaveBeenCalledWith(mockMap);
expect(mockOmsInstances[0].addListener).not.toHaveBeenCalledWith(
'spiderfy',
expect.any(Function)
);
});

it('uses the governed active blue when a pin is highlighted', async () => {
const { rerender } = render(
<TestWrapper>
<MapResultView results={mockResultsWithCentroid} />
</TestWrapper>
);

await waitFor(() => {
expect(mockOmsInstances[0]?.markers).toHaveLength(2);
});

const marker = mockOmsInstances[0].markers[0];
const markerElement = document.createElement('div');
const pinShape = document.createElement('span');
pinShape.setAttribute('data-result-pin-shape', '');
markerElement.append(pinShape);
vi.spyOn(marker, 'getElement').mockReturnValue(markerElement);

rerender(
<TestWrapper>
<MapResultView
results={mockResultsWithCentroid}
highlightedResourceId="res-1"
/>
</TestWrapper>
);

await waitFor(() => {
expect(pinShape.style.background).toBe('rgb(var(--color-active))');
expect(pinShape.style.borderColor).toBe('rgb(var(--color-primary))');
});
});

it('keeps the spiderfier stable while a marker is hovered and selected', async () => {
Expand All @@ -292,7 +361,8 @@ describe('MapResultView', () => {
expect(mapState).toHaveAttribute('data-hovered-resource-id', 'res-1');
expect(mapState).toHaveAttribute('data-hovered-resource-source', 'map');

act(() => marker.fire('click'));
const handleOmsClick = getOmsListener(oms, 'click');
act(() => handleOmsClick(marker));
expect(mapState).toHaveAttribute('data-selected-resource-id', 'res-1');
expect(mockOmsInstances).toHaveLength(1);
expect(oms.clearMarkers).not.toHaveBeenCalled();
Expand All @@ -301,15 +371,15 @@ describe('MapResultView', () => {
expect(mapState).toHaveAttribute('data-hovered-resource-id', 'res-1');
expect(mapState).toHaveAttribute('data-hovered-resource-source', 'map');

act(() => marker.fire('click'));
act(() => handleOmsClick(marker));
expect(mapState).toHaveAttribute('data-selected-resource-id', '');
expect(mapState).toHaveAttribute('data-hovered-resource-id', '');
expect(mapState).toHaveAttribute('data-hovered-resource-source', '');
expect(mockOmsInstances).toHaveLength(1);
expect(oms.clearMarkers).not.toHaveBeenCalled();
});

it('keeps collapsed overlapping markers stable until click', async () => {
it('delegates marker clicks to the spiderfier', async () => {
render(
<TestWrapper>
<MapResultView results={mockOverlappingResults} />
Expand All @@ -322,23 +392,18 @@ describe('MapResultView', () => {
});
const marker = mockOmsInstances[0].markers[0];
const mapState = screen.getByTestId('map-state');
const fire = vi.spyOn(marker, 'fire');
const setIcon = vi.spyOn(marker, 'setIcon');

act(() => marker.fire('mouseover'));
expect(fire).not.toHaveBeenCalledWith('click');
expect(setIcon).not.toHaveBeenCalled();
expect(mapState).toHaveAttribute('data-hovered-resource-id', '');
expect(mapState).toHaveAttribute('data-hovered-resource-id', 'res-1');
expect(mapState).toHaveAttribute('data-hovered-resource-source', 'map');

act(() => marker.fire('click'));
expect(mapState).toHaveAttribute('data-selected-resource-id', '');

marker._omsData = {
usualPosition: marker.getLatLng(),
leg: {} as L.Polyline,
};
act(() => marker.fire('mouseover'));
expect(mapState).toHaveAttribute('data-hovered-resource-id', 'res-1');
const handleOmsClick = getOmsListener(mockOmsInstances[0], 'click');
act(() => handleOmsClick(marker));
expect(mapState).toHaveAttribute('data-selected-resource-id', 'res-1');
expect(mockMap.openPopup).toHaveBeenCalledTimes(1);
});

it('accepts highlightedResourceId and highlightedGeometry', async () => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/__tests__/pages/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,10 @@ describe('SearchPage Logic', () => {

expect(map).toContainElement(note);
expect(note).toHaveTextContent(
'This map only renders the current page of search results. Use pagination to review other matches.'
'This map shows the current page of search results'
);
expect(note.querySelector('.lucide-info')).toBeInTheDocument();
expect(note).toHaveClass('lg:w-auto', 'lg:whitespace-nowrap');
expect(resultsSummary).toContainElement(pagination);
expect(pagination).toHaveClass('whitespace-nowrap');
expect(resultsColumn).not.toContainElement(pagination);
Expand Down
49 changes: 1 addition & 48 deletions frontend/src/components/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { Link, useLocation } from 'react-router';
import type { GeoDocument } from '../types/api';
import { BookOpen } from 'lucide-react';
Expand Down Expand Up @@ -27,7 +27,6 @@ interface SearchResultsProps {
searchId?: string;
searchView?: 'list' | 'gallery' | 'map';
highlightedResourceId?: string | null;
autoScrollHighlightedResult?: boolean;
}

export function SearchResults({
Expand All @@ -40,7 +39,6 @@ export function SearchResults({
searchId,
searchView = 'list',
highlightedResourceId = null,
autoScrollHighlightedResult = false,
}: SearchResultsProps) {
const { showDetails } = useDebug();
const location = useLocation();
Expand All @@ -52,8 +50,6 @@ export function SearchResults({
} = useMap();
const { isBookmarked } = useBookmarks();
const [imageErrors, setImageErrors] = useState<Set<string>>(new Set());
const resultListRef = useRef<HTMLDivElement>(null);
const resultCardRefs = useRef<Map<string, HTMLElement>>(new Map());

const isCompact = variant === 'compact';
const thumbnailWrapperClass = isCompact ? 'w-24' : 'w-24 md:w-48';
Expand All @@ -66,41 +62,6 @@ export function SearchResults({
? 'text-sm line-clamp-2'
: 'text-sm line-clamp-2 md:text-xl';

useEffect(() => {
if (!isCompact || !highlightedResourceId || !autoScrollHighlightedResult)
return;

const highlightedCard = resultCardRefs.current.get(highlightedResourceId);
const resultList = resultListRef.current;
if (!highlightedCard || !resultList) return;

const cardBounds = highlightedCard.getBoundingClientRect();
const listBounds = resultList.getBoundingClientRect();
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
const headerBottom =
document.querySelector('header')?.getBoundingClientRect().bottom ?? 0;
const visibleListTop = Math.max(listBounds.top, headerBottom, 0);
const visibleListBottom = Math.min(listBounds.bottom, viewportHeight);
if (visibleListBottom <= visibleListTop) return;

let scrollDelta = 0;
if (cardBounds.top < visibleListTop) {
scrollDelta = cardBounds.top - visibleListTop;
} else if (cardBounds.bottom > visibleListBottom) {
scrollDelta = cardBounds.bottom - visibleListBottom;
}

if (scrollDelta !== 0) {
const targetScrollTop = Math.max(0, window.scrollY + scrollDelta);

window.scrollTo({
top: targetScrollTop,
behavior: 'smooth',
});
}
}, [autoScrollHighlightedResult, highlightedResourceId, isCompact]);

// Calculate absolute index in full result set (1-based)
const getAbsoluteIndex = (relativeIndex: number) => {
return (currentPage - 1) * perPage + relativeIndex + 1;
Expand Down Expand Up @@ -149,7 +110,6 @@ export function SearchResults({

return (
<div
ref={resultListRef}
data-testid={isCompact ? 'map-results-scroll-container' : undefined}
className={`min-w-0 space-y-6 ${
isCompact ? 'md:pt-1 md:pr-2 md:pb-1 md:pl-1' : ''
Expand Down Expand Up @@ -178,13 +138,6 @@ export function SearchResults({
return (
<article
key={result.id}
ref={(element) => {
if (element) {
resultCardRefs.current.set(result.id, element);
} else {
resultCardRefs.current.delete(result.id);
}
}}
className={`min-w-0 bg-white rounded-lg shadow-md hover:shadow-lg transition-all relative group ${
isHighlighted
? 'ring-2 ring-blue-500/80 bg-blue-50 shadow-md'
Expand Down
Loading
Loading