mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
a782e0e471
A trip that models one building as several stops — drop the bags, check in, the museum inside the hotel — drew every pin on the same pixel, and only the top one could be clicked, hovered or seen. Clustering is the mechanism for exactly that: the plugin keeps children no zoom level can separate in one cluster and fans them apart on click. disableClusteringAtZoom pinned the group's own maximum zoom to 8, which both removed every cluster above it and moved the only fan trigger into a zoom band a trip planner never operates in, so the spiderfyOnMaxZoom set beside it could never fire. The cutoff is gone. maxClusterRadius is a function of zoom instead: the same generous radius as before in the overview, a pin's width above it, so stops that are genuinely apart keep their own pins and a pile becomes a counted bubble that fans open. Selecting a buried stop from the places rail now opens the bubble it is hiding in rather than doing nothing visible, and it does that without moving the camera, which stays the selection's own concern. The GL renderer has no fan to open, so folding a pin there removes the only thing that was left rather than providing a way in. It folds at a far tighter radius — genuine coincidence, where nothing was reachable to begin with — and draws the selected member of a pile, so the rail reaches it there too. A map object without a projector now skips folding instead of dropping every marker. The public share page spreads the same options and has no rail to fall back on, which made it the worse case; it is fixed by the same change. Closes #2344
149 lines
5.7 KiB
TypeScript
149 lines
5.7 KiB
TypeScript
/**
|
|
* The share page clusters its markers like the planner (#2343). The data hook and
|
|
* Leaflet are stubbed: under test is the page's wiring, not Leaflet's rendering.
|
|
*/
|
|
import React from 'react';
|
|
import L from 'leaflet';
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { render, screen, within } from '../../helpers/render';
|
|
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, createClusterIcon } from '../../../src/components/Map/markerCluster';
|
|
import { STACK_RADIUS_PX } from '../../../src/components/Map/coincidentPlaces';
|
|
import { MAP_MAX_ZOOM } from '../../../src/constants/mapDefaults';
|
|
import SharedTripPage from '../../../src/pages/SharedTripPage';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
hook: {} as Record<string, unknown>,
|
|
clusterProps: [] as Array<Record<string, unknown>>,
|
|
}));
|
|
|
|
vi.mock('../../../src/pages/sharedTrip/useSharedTrip', () => ({
|
|
useSharedTrip: () => mocks.hook,
|
|
}));
|
|
|
|
vi.mock('react-leaflet', () => ({
|
|
MapContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="map">{children}</div>,
|
|
TileLayer: () => null,
|
|
Polyline: () => null,
|
|
Tooltip: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
|
Marker: ({ children }: { children?: React.ReactNode }) => <div data-testid="marker">{children}</div>,
|
|
useMap: () => ({ fitBounds: vi.fn(), on: vi.fn(), off: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock('react-leaflet-cluster', () => ({
|
|
default: (props: { children?: React.ReactNode }) => {
|
|
mocks.clusterProps.push(props as unknown as Record<string, unknown>);
|
|
return <div data-testid="cluster-group">{props.children}</div>;
|
|
},
|
|
}));
|
|
|
|
// Its effect dynamically imports maplibre-gl, which has no business in jsdom.
|
|
vi.mock('../../../src/components/Map/VectorBasemap', () => ({ default: () => null }));
|
|
|
|
const place = (id: number, name: string, lat: number, lng: number) => ({
|
|
id,
|
|
name,
|
|
lat,
|
|
lng,
|
|
category_color: '#6366f1',
|
|
category_icon: 'map-pin',
|
|
});
|
|
|
|
function payload(places: Array<Record<string, unknown>>) {
|
|
return {
|
|
trip: { id: 1, title: 'Autumn', start_date: '2026-09-25', end_date: '2026-09-27', currency: 'EUR' },
|
|
days: [{ id: 5, day_number: 1, date: '2026-09-25', title: 'Day one' }],
|
|
assignments: {},
|
|
dayNotes: {},
|
|
places,
|
|
reservations: [],
|
|
accommodations: [],
|
|
packing: [],
|
|
budget: [],
|
|
categories: [],
|
|
permissions: { share_map: true, share_bookings: true, share_packing: true, share_budget: true },
|
|
collab: null,
|
|
cartoApiKey: null,
|
|
};
|
|
}
|
|
|
|
function hookState(data: unknown) {
|
|
return {
|
|
data,
|
|
error: false,
|
|
base: 'EUR',
|
|
convert: (n: number) => n,
|
|
selectedDay: null,
|
|
setSelectedDay: vi.fn(),
|
|
activeTab: 'plan',
|
|
setActiveTab: vi.fn(),
|
|
showLangPicker: false,
|
|
setShowLangPicker: vi.fn(),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mocks.clusterProps.length = 0;
|
|
});
|
|
|
|
describe('the map on a public share page', () => {
|
|
it('FE-PAGE-SHARED-MAP-001: every stop is handed to a cluster group, not the raw map', () => {
|
|
mocks.hook = hookState(
|
|
payload([place(11, 'The Bund', 31.24, 121.49), place(12, 'Peace Hotel', 31.2397, 121.4903)]),
|
|
);
|
|
render(<SharedTripPage />);
|
|
|
|
const cluster = screen.getByTestId('cluster-group');
|
|
expect(within(cluster).getAllByTestId('marker')).toHaveLength(2);
|
|
// And nothing escaped it: the map holds no bare markers of its own.
|
|
expect(within(screen.getByTestId('map')).getAllByTestId('marker')).toHaveLength(2);
|
|
});
|
|
|
|
it('FE-PAGE-SHARED-MAP-002: the group is built from the shared options, so both maps cluster alike', () => {
|
|
mocks.hook = hookState(payload([place(11, 'The Bund', 31.24, 121.49)]));
|
|
render(<SharedTripPage />);
|
|
|
|
expect(mocks.clusterProps).toHaveLength(1);
|
|
const props = mocks.clusterProps[0];
|
|
for (const [key, value] of Object.entries(CLUSTER_OPTIONS)) {
|
|
expect(props[key], key + ' must reach the cluster group').toBe(value);
|
|
}
|
|
expect(typeof props.iconCreateFunction).toBe('function');
|
|
});
|
|
|
|
it('FE-PAGE-SHARED-MAP-003: the bubble shows the count, in the size bucket that count earns', () => {
|
|
const small = createClusterIcon({ getChildCount: () => 7 });
|
|
const medium = createClusterIcon({ getChildCount: () => 30 });
|
|
const large = createClusterIcon({ getChildCount: () => 60 });
|
|
|
|
expect(small.options.className).toBe('marker-cluster-wrapper');
|
|
expect(small.options.html).toContain('<span>7</span>');
|
|
expect([small, medium, large].map((icon) => icon.options.iconSize)).toEqual([L.point(36, 36), L.point(42, 42), L.point(48, 48)]);
|
|
expect(small.options.html).toContain('width:36px;height:36px');
|
|
});
|
|
|
|
it('FE-PAGE-SHARED-MAP-005: two stops on one coordinate stay in a bubble a guest can open (#2344)', () => {
|
|
mocks.hook = hookState(
|
|
payload([place(11, 'Drop the bags', 31.24, 121.49), place(12, 'Check in', 31.24, 121.49)]),
|
|
);
|
|
render(<SharedTripPage />);
|
|
|
|
const props = mocks.clusterProps[0];
|
|
// A guest has no places rail to fall back on, so the bubble is the only way in and
|
|
// nothing may switch clustering off above the zoom the trip is read at.
|
|
expect(props).not.toHaveProperty('disableClusteringAtZoom');
|
|
expect(props.spiderfyOnMaxZoom).toBe(true);
|
|
const radius = props.maxClusterRadius as (zoom: number) => number;
|
|
expect(radius(MAP_MAX_ZOOM)).toBe(STACK_RADIUS_PX);
|
|
expect(radius(CLUSTER_UNTIL_ZOOM - 1)).toBe(CLUSTER_RADIUS_PX);
|
|
});
|
|
|
|
it('FE-PAGE-SHARED-MAP-004: a place without coordinates is left out of the map, as before', () => {
|
|
mocks.hook = hookState(
|
|
payload([place(11, 'The Bund', 31.24, 121.49), { id: 12, name: 'Geocoding failed' } as never]),
|
|
);
|
|
render(<SharedTripPage />);
|
|
|
|
expect(within(screen.getByTestId('cluster-group')).getAllByTestId('marker')).toHaveLength(1);
|
|
});
|
|
});
|