test(tours): cover the seamark overlay on GL maps and the tour type fields

This commit is contained in:
Maurice
2026-10-16 22:22:26 +02:00
committed by Maurice
parent 704621d0c2
commit d705bd9dd0
2 changed files with 179 additions and 0 deletions
@@ -0,0 +1,105 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
type Handler = (event: Record<string, unknown>) => void;
/** Enough of a GL map to watch what the overlay hook does with it. */
function fakeMap(styleLayers: Array<{ id: string }> = []) {
const layers = new Map<string, { visibility: string }>();
const sources = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const map = {
getLayer: vi.fn((id: string) => layers.get(id)),
getSource: vi.fn((id: string) => (sources.has(id) ? {} : undefined)),
addSource: vi.fn((id: string) => sources.add(id)),
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, { visibility: 'visible' })),
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, { visibility: value })),
getStyle: vi.fn(() => ({ layers: styleLayers })),
on: vi.fn((type: string, handler: Handler) => {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type)?.add(handler);
}),
off: vi.fn((type: string, handler: Handler) => handlers.get(type)?.delete(handler)),
};
const fire = (type: string, event: Record<string, unknown>) =>
handlers.get(type)?.forEach((handler) => handler(event));
return { map, layers, fire, gl: map as unknown as RasterOverlayGlMap };
}
function overlay(active: boolean): MapRasterOverlay {
return {
id: 'seamarks',
url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
attribution: 'OpenSeaMap',
minZoom: 6,
maxZoom: 18,
active,
onTileError: vi.fn(),
onTileLoad: vi.fn(),
};
}
describe('useRasterOverlaysGL', () => {
it('adds a switched-on overlay under the first own layer, so the route stays on top', () => {
const { map, gl } = fakeMap([{ id: 'background' }, { id: 'trek-route-line' }]);
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
expect(map.addSource).toHaveBeenCalledWith(
'trek-overlay-seamarks',
expect.objectContaining({ type: 'raster', minzoom: 6 })
);
expect(map.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trek-overlay-seamarks' }),
'trek-route-line'
);
});
it('adds nothing for an overlay that is off, and hides one already there when it is switched off', () => {
const { map, gl, layers } = fakeMap();
const { rerender } = renderHook(({ list }) => useRasterOverlaysGL(gl, list), {
initialProps: { list: [overlay(false)] },
});
expect(map.addLayer).not.toHaveBeenCalled();
rerender({ list: [overlay(true)] });
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('visible');
rerender({ list: [overlay(false)] });
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('none');
});
it('puts the overlay back after a basemap change rebuilt the style', () => {
const { map, gl, layers, fire } = fakeMap();
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
layers.clear();
fire('styledata', {});
expect(map.addLayer).toHaveBeenCalledTimes(2);
});
it("reports its own tiles' failures and arrivals, and leaves other sources' events alone", () => {
const { gl, fire } = fakeMap();
const seamarks = overlay(true);
renderHook(() => useRasterOverlaysGL(gl, [seamarks]));
fire('error', { sourceId: 'trek-overlay-seamarks', error: new Error('503') });
fire('error', { sourceId: 'basemap', error: new Error('503') });
fire('sourcedata', { sourceId: 'trek-overlay-seamarks', tile: {} });
fire('sourcedata', { sourceId: 'trek-overlay-seamarks' });
expect(seamarks.onTileError).toHaveBeenCalledTimes(1);
expect(seamarks.onTileLoad).toHaveBeenCalledTimes(1);
});
it('keeps the plain map when the style refuses the layer, and lets go of its listeners on unmount', () => {
const { map, gl } = fakeMap();
map.addLayer.mockImplementation(() => {
throw new Error('style not loaded');
});
const { unmount } = renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
unmount();
expect(map.off).toHaveBeenCalledTimes(3);
});
it('does nothing without a map or without overlays', () => {
const { map, gl } = fakeMap();
renderHook(() => useRasterOverlaysGL(null, [overlay(true)]));
renderHook(() => useRasterOverlaysGL(gl, []));
expect(map.on).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { TourTypeFields } from './TourTypeFields';
import type { TourPlannerController } from './useTourPlanner';
/** A planner as far as the type fields read it: a paddle tour unless told otherwise. */
function planner(overrides: Record<string, unknown> = {}, paddle: Record<string, unknown> = {}) {
const base = hikePlannerDefaults();
return {
...base,
tourType: 'kayak',
isPaddle: true,
paddle: { ...base.paddle, recommendedSpeedKmh: 4, effectiveSpeedKmh: 4, ...paddle },
...overrides,
} as unknown as TourPlannerController;
}
describe('TourTypeFields (PAD-01)', () => {
it('a hike shows the type switch only, and choosing paddle is handed to the planner', () => {
const hike = planner({ tourType: 'hike', isPaddle: false });
render(<TourTypeFields planner={hike} locked={false} />);
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Paddle tour/ }));
expect(hike.setTourType).toHaveBeenCalledWith('kayak');
});
it('a paddle tour asks for exactly one boat and its water, and says so until both are chosen', () => {
const paddleTour = planner({ paddleIncomplete: true });
render(<TourTypeFields planner={paddleTour} locked={false} />);
fireEvent.click(screen.getByRole('button', { name: 'Canoe' }));
fireEvent.click(screen.getByRole('button', { name: 'Lake' }));
expect(paddleTour.paddle.setBoatType).toHaveBeenCalledWith('canoe');
expect(paddleTour.paddle.setWaterContext).toHaveBeenCalledWith('lake');
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('reads a typed speed with either decimal separator, and an empty field as the suggestion', () => {
const paddleTour = planner();
render(<TourTypeFields planner={paddleTour} locked={false} />);
const input = screen.getByPlaceholderText('4');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '4,5' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(4.5);
fireEvent.change(input, { target: { value: 'fast' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(Number.NaN);
fireEvent.change(input, { target: { value: '' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(null);
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.blur(input);
});
it('offers the boat suggestion beside a speed of the paddler own, taken only on purpose', () => {
const paddleTour = planner({}, { boatType: 'kayak', speedKmh: 6 });
render(<TourTypeFields planner={paddleTour} locked={false} />);
fireEvent.click(screen.getByRole('button', { name: /4 km\/h/ }));
expect(paddleTour.paddle.adoptRecommendedSpeed).toHaveBeenCalled();
});
it('names a speed that does not hold, and asks for one where the boat has no suggestion', () => {
const { unmount } = render(
<TourTypeFields planner={planner({ paddleSpeedInvalid: true }, { speedKmh: Number.NaN })} locked={false} />
);
expect(screen.getByRole('alert')).toBeInTheDocument();
unmount();
render(
<TourTypeFields
planner={planner({}, { boatType: 'other', recommendedSpeedKmh: null, effectiveSpeedKmh: null })}
locked={false}
/>
);
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
});
});