// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006 vi.mock('../../api/websocket', () => ({ connect: vi.fn(), disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); // Leaflet does not work in jsdom — mock the entire library vi.mock('leaflet', () => { const mockMarker = { addTo: vi.fn().mockReturnThis(), bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), setIcon: vi.fn(), setZIndexOffset: vi.fn(), getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })), }; const mockMap = { remove: vi.fn(), invalidateSize: vi.fn(), fitBounds: vi.fn(), setView: vi.fn(), flyTo: vi.fn(), getZoom: vi.fn(() => 10), zoomIn: vi.fn(), zoomOut: vi.fn(), }; return { default: { map: vi.fn(() => mockMap), tileLayer: vi.fn(() => ({ addTo: vi.fn() })), marker: vi.fn(() => mockMarker), polyline: vi.fn(() => ({ addTo: vi.fn() })), divIcon: vi.fn(() => ({})), latLngBounds: vi.fn(() => ({})), }, map: vi.fn(() => mockMap), tileLayer: vi.fn(() => ({ addTo: vi.fn() })), marker: vi.fn(() => mockMarker), polyline: vi.fn(() => ({ addTo: vi.fn() })), divIcon: vi.fn(() => ({})), latLngBounds: vi.fn(() => ({})), }; }); import L from 'leaflet'; import React from 'react'; import { buildSettings } from '../../../tests/helpers/factories'; import { render } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; import type { JourneyMapHandle } from './JourneyMap'; import JourneyMap from './JourneyMap'; const entriesWithCoords = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, ]; const entriesWithoutCoords = [ { id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' }, ]; const mixedEntries = [...entriesWithCoords, ...entriesWithoutCoords]; beforeEach(() => { resetAllStores(); seedStore(useSettingsStore, { settings: buildSettings() }); vi.clearAllMocks(); }); describe('JourneyMap', () => { it('FE-COMP-JOURNEYMAP-001: renders map container', () => { const { container } = render(); // The component renders a div with a child div ref for the Leaflet map expect(container.firstChild).toBeInTheDocument(); expect(L.map).toHaveBeenCalled(); }); it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => { render(); // Two entries with valid lat/lng should produce two markers expect(L.marker).toHaveBeenCalledTimes(2); }); it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => { render(); // Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng)) expect(L.marker).not.toHaveBeenCalled(); }); it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => { render(); // With 2+ marker items, a route polyline is drawn expect(L.polyline).toHaveBeenCalled(); }); it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => { render(); // Each marker calls bindTooltip with the entry label const mockMarkerInstance = (L.marker as any).mock.results[0].value; expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith('Paris', expect.objectContaining({ direction: 'top' })); }); it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => { const ref = React.createRef(); render(); expect(ref.current).not.toBeNull(); expect(typeof ref.current!.focusMarker).toBe('function'); expect(typeof ref.current!.highlightMarker).toBe('function'); }); it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => { render(); // Each marker is created with L.divIcon containing SVG html expect(L.divIcon).toHaveBeenCalledTimes(2); const firstCall = (L.divIcon as any).mock.calls[0][0]; expect(firstCall.html).toContain(''); // Marker index label "1" for first entry expect(firstCall.html).toContain('>1<'); }); it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => { const entriesWithMood = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' }, { id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' }, ]; render(); // Markers are still created (mood does not prevent rendering) expect(L.marker).toHaveBeenCalledTimes(2); // Tooltips use the entry titles const mockMarker1 = (L.marker as any).mock.results[0].value; expect(mockMarker1.bindTooltip).toHaveBeenCalledWith('Happy Paris', expect.objectContaining({ direction: 'top' })); const mockMarker2 = (L.marker as any).mock.results[1].value; expect(mockMarker2.bindTooltip).toHaveBeenCalledWith('Sad Berlin', expect.objectContaining({ direction: 'top' })); }); it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => { const threeEntries = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, { id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' }, ]; render(); // Route polyline is drawn for items.length > 1 expect(L.polyline).toHaveBeenCalled(); const polylineCall = (L.polyline as any).mock.calls[0]; // Should contain coordinates for all three entries expect(polylineCall[0].length).toBe(3); // Verify dashed style expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' }); }); it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => { // Trigger requestAnimationFrame synchronously const origRAF = globalThis.requestAnimationFrame; globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; render(); const mockMap = (L.map as any).mock.results[0].value; // fitBounds is called inside requestAnimationFrame with the collected coordinates expect(mockMap.fitBounds).toHaveBeenCalled(); expect(L.latLngBounds).toHaveBeenCalled(); globalThis.requestAnimationFrame = origRAF; }); it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => { const singleEntry = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' }, ]; render(); // One marker created expect(L.marker).toHaveBeenCalledTimes(1); // No route polyline — polyline is only drawn when items.length > 1 expect(L.polyline).not.toHaveBeenCalled(); }); it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => { const { container } = render(); // The component renders zoom in (+) and zoom out (−) buttons const buttons = container.querySelectorAll('button'); expect(buttons.length).toBe(2); expect(buttons[0].textContent).toBe('+'); expect(buttons[1].textContent).toBe('−'); }); });