mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(tours): cover the seamark overlay on GL maps and the tour type fields
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user