Files
TREK/client/src/components/Journey/JourneyMap.test.tsx
T
Maurice 870bd6131f feat(journey): draw the trips' GPX tracks on the journey map (#1911)
* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
2026-08-12 15:14:50 +02:00

554 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-027
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(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
};
});
import React from 'react';
import { render, act, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } 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,
];
// The leaflet mock hands out one shared map/marker instance, so every render
// resolves to the same spy object.
const mockedMap = () => vi.mocked(L.map).mock.results[0].value;
const mockedMarker = (i = 0) => vi.mocked(L.marker).mock.results[i].value;
const divIconHtml = (i: number) => String(vi.mocked(L.divIcon).mock.calls[i][0].html);
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: buildSettings() });
vi.clearAllMocks();
});
describe('JourneyMap', () => {
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
const { container } = render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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<JourneyMapHandle>();
render(
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
);
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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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('<svg');
expect(firstCall.html).toContain('</svg>');
// 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(
<JourneyMap checkins={[]} entries={entriesWithMood} />
);
// 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(
<JourneyMap checkins={[]} entries={threeEntries} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
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(
<JourneyMap checkins={[]} entries={singleEntry} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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('−');
});
it('FE-COMP-JOURNEYMAP-013: skips entries without coordinates but keeps the rest', () => {
render(
<JourneyMap checkins={[]} entries={mixedEntries} />
);
// Only the two entries with real lat/lng become markers
expect(L.marker).toHaveBeenCalledTimes(2);
});
it('FE-COMP-JOURNEYMAP-014: highlightMarker scales the pin up and lifts its z-index', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
const iconsBefore = vi.mocked(L.divIcon).mock.calls.length;
act(() => { ref.current!.highlightMarker('e1'); });
expect(divIconHtml(iconsBefore)).toContain('scale(1.2)');
expect(mockedMarker().setZIndexOffset).toHaveBeenCalledWith(1000);
});
it('FE-COMP-JOURNEYMAP-015: highlighting a second marker resets the previous one', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
act(() => { ref.current!.highlightMarker('e1'); });
const iconsBefore = vi.mocked(L.divIcon).mock.calls.length;
act(() => { ref.current!.highlightMarker('e2'); });
// First the old pin is redrawn unhighlighted, then the new one highlighted
expect(divIconHtml(iconsBefore)).toContain('scale(1)');
expect(divIconHtml(iconsBefore + 1)).toContain('scale(1.2)');
expect(mockedMarker().setZIndexOffset).toHaveBeenCalledWith(0);
});
it('FE-COMP-JOURNEYMAP-016: highlightMarker ignores ids that have no marker', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
const iconsBefore = vi.mocked(L.divIcon).mock.calls.length;
act(() => { ref.current!.highlightMarker('does-not-exist'); });
expect(vi.mocked(L.divIcon).mock.calls.length).toBe(iconsBefore);
});
it('FE-COMP-JOURNEYMAP-017: focusMarker flies to the pin, never below zoom 12', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
act(() => { ref.current!.focusMarker('e2'); });
// getZoom() is stubbed at 10, so the floor of 12 wins
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
});
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
expect(mockedMap().flyTo).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-019: invalidateSize forwards to the leaflet map', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
const before = vi.mocked(mockedMap().invalidateSize).mock.calls.length;
act(() => { ref.current!.invalidateSize(); });
expect(vi.mocked(mockedMap().invalidateSize).mock.calls.length).toBe(before + 1);
});
it('FE-COMP-JOURNEYMAP-020: a trail draws its own dashed polyline', () => {
render(
<JourneyMap
checkins={[]}
entries={entriesWithCoords}
trail={[{ lat: 48.85, lng: 2.35 }, { lat: 50.0, lng: 8.0 }, { lat: 52.52, lng: 13.4 }]}
/>
);
// trail polyline first, route polyline second
const trailCall = vi.mocked(L.polyline).mock.calls[0];
expect(trailCall[0]).toEqual([[48.85, 2.35], [50.0, 8.0], [52.52, 13.4]]);
expect(trailCall[1]).toMatchObject({ dashArray: '6 4', color: '#6366f1' });
});
it('FE-COMP-JOURNEYMAP-021: a single-point trail is not drawn', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithoutCoords} trail={[{ lat: 1, lng: 2 }]} />
);
expect(L.polyline).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-022: fullScreen enables wheel zoom and drops the route polyline', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} fullScreen />
);
expect(vi.mocked(L.map).mock.calls[0][1]).toMatchObject({ scrollWheelZoom: true });
// The connecting route line is a sidebar-only decoration
expect(L.polyline).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-023: clicking a marker reports the entry id', () => {
const onMarkerClick = vi.fn();
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} onMarkerClick={onMarkerClick} />
);
const handler = vi.mocked(mockedMarker().on).mock.calls.find(c => c[0] === 'click')?.[1] as () => void;
act(() => { handler(); });
expect(onMarkerClick).toHaveBeenCalledWith('e1');
});
it('FE-COMP-JOURNEYMAP-024: without any coordinates the map falls back to the world view', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
render(<JourneyMap checkins={[]} entries={entriesWithoutCoords} />);
expect(mockedMap().setView).toHaveBeenCalledWith([30, 0], 2);
expect(mockedMap().fitBounds).not.toHaveBeenCalled();
} finally {
globalThis.requestAnimationFrame = origRAF;
}
});
it('FE-COMP-JOURNEYMAP-025: paddingBottom widens the bottom fit padding', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} />);
expect(mockedMap().fitBounds).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ paddingBottomRight: [50, 200] }),
);
} finally {
globalThis.requestAnimationFrame = origRAF;
}
});
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toContain('dark_all');
});
it('FE-COMP-JOURNEYMAP-027: a configured tile url overrides the default basemap', () => {
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png');
});
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop flies to that marker after the settle delay', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e2" />);
expect(mockedMap().flyTo).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(60); });
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
} finally {
vi.useRealTimers();
}
});
it('FE-COMP-JOURNEYMAP-029: activeMarkerId falls back to setView while the map has no view yet', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
act(() => { vi.advanceTimersByTime(60); });
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
expect(mockedMap().flyTo).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('FE-COMP-JOURNEYMAP-030: an activeMarkerId with no marker does not move the camera', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="ghost" />);
act(() => { vi.advanceTimersByTime(60); });
expect(mockedMap().flyTo).not.toHaveBeenCalled();
expect(mockedMap().setView).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('FE-COMP-JOURNEYMAP-031: the deferred resize fires 200ms after mount', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const before = vi.mocked(mockedMap().invalidateSize).mock.calls.length;
act(() => { vi.advanceTimersByTime(250); });
expect(vi.mocked(mockedMap().invalidateSize).mock.calls.length).toBeGreaterThan(before);
} finally {
vi.useRealTimers();
}
});
it('FE-COMP-JOURNEYMAP-032: the zoom buttons drive the leaflet map', () => {
const { container } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const buttons = container.querySelectorAll('button');
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
expect(mockedMap().zoomIn).toHaveBeenCalledTimes(1);
expect(mockedMap().zoomOut).toHaveBeenCalledTimes(1);
});
it('FE-COMP-JOURNEYMAP-033: height 9999 stretches the wrapper to the full container', () => {
const { container } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} height={9999} />);
expect((container.firstChild as HTMLElement).style.height).toBe('100%');
});
it('FE-COMP-JOURNEYMAP-034: per-day colours and labels reach the pin svg', () => {
render(
<JourneyMap
checkins={[]}
entries={[{ id: 'e9', lat: 40.4, lng: -3.7, title: 'Madrid', mood: null, entry_date: '2025-06-04', dayColor: '#ff0055', dayLabel: 4 }]}
/>
);
const html = divIconHtml(0);
expect(html).toContain('#ff0055');
expect(html).toContain('>4<');
});
it('FE-COMP-JOURNEYMAP-035: entries are ordered by date, not by array position', () => {
render(
<JourneyMap
checkins={[]}
entries={[
{ id: 'late', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-09' },
{ id: 'early', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
]}
/>
);
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
expect(tooltipTitles).toEqual(['Paris', 'Berlin']);
});
it('FE-COMP-JOURNEYMAP-036: an untitled entry still gets a tooltip label', () => {
render(
<JourneyMap
checkins={[]}
entries={[{ id: 'e0', lat: 1, lng: 2, title: null, mood: null, entry_date: '2025-06-01' }]}
/>
);
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith('Entry', expect.objectContaining({ direction: 'top' }));
});
it('FE-COMP-JOURNEYMAP-037: unmounting tears the leaflet map down', () => {
const { unmount } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const map = mockedMap();
const before = vi.mocked(map.remove).mock.calls.length;
unmount();
expect(vi.mocked(map.remove).mock.calls.length).toBe(before + 1);
});
// ── GPX tracks (#1260) ──────────────────────────────────────────────────────
const track = (over: Partial<{ place_id: number; trip_id: number; name: string; color: string | null; points: [number, number][] }> = {}) => ({
place_id: 1, trip_id: 1, name: 'Morning hike', color: '#ff0000',
points: [[47.1, 11.2], [47.2, 11.3]] as [number, number][], ...over,
});
it('FE-COMP-JOURNEYMAP-040: draws a track as a casing plus a coloured line', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track()]} />);
const calls = vi.mocked(L.polyline).mock.calls;
const casing = calls.find(c => (c[1] as any)?.color === '#ffffff');
const line = calls.find(c => (c[1] as any)?.color === '#ff0000');
expect(casing).toBeDefined();
expect(line).toBeDefined();
// Leaflet wants [lat, lng], which is the order the geometry is stored in.
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
// Solid, unlike the dashed line that merely connects entries in time order.
expect((line![1] as any).dashArray).toBeUndefined();
});
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: 'Morning hike' })]} />);
const line = vi.mocked(L.polyline).mock.results
.map(r => r.value as any)
.find(v => v.bindTooltip.mock.calls.length > 0);
expect(line).toBeDefined();
const [label, opts] = line.bindTooltip.mock.calls[0];
expect(label).toBe('Morning hike');
// The house tooltip, not Leaflet's default box: it reads the appearance tokens.
expect(opts.className).toBe('map-tooltip');
});
it('FE-COMP-JOURNEYMAP-044: an unnamed track gets no empty tooltip', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: '' })]} />);
const tooltipped = vi.mocked(L.polyline).mock.results
.map(r => r.value as any)
.filter(v => v.bindTooltip.mock.calls.length > 0);
expect(tooltipped).toHaveLength(0);
});
it('FE-COMP-JOURNEYMAP-041: a track without its own colour falls back rather than vanishing', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ color: null })]} />);
const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color);
expect(colors).toContain('#4f46e5');
});
it('FE-COMP-JOURNEYMAP-042: a track with fewer than two points draws nothing', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ points: [[47.1, 11.2]] })]} />);
const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color);
expect(colors).not.toContain('#ff0000');
expect(colors).not.toContain('#ffffff');
});
});