Files
TREK/client/src/hooks/useGeolocation.test.ts
T
Maurice 088eef6c6b fix(mobile,map): long toasts wrap and http selfhosts get the right geolocation message (#2095)
The phone toast was a single truncated pill, which cut the denied message exactly before the settings hint it exists for; long messages wrap up to four lines now. An insecure context is reported as its own error instead of masquerading as a blocked device permission.
2026-09-02 21:06:51 +02:00

438 lines
17 KiB
TypeScript

// FE-HOOK-GEO-001 to FE-HOOK-GEO-031
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation';
type WatchSuccess = (pos: GeolocationPosition) => void;
type WatchFailure = (err: GeolocationPositionError) => void;
let successCb: WatchSuccess | null = null;
let errorCb: WatchFailure | null = null;
let watchPosition: ReturnType<typeof vi.fn>;
let clearWatch: ReturnType<typeof vi.fn>;
function fix(over: Partial<GeolocationCoordinates> = {}, timestamp = 1_700_000_000_000): GeolocationPosition {
return {
coords: { latitude: 48.2, longitude: 16.37, accuracy: 12, heading: null, speed: null, altitude: null, altitudeAccuracy: null, ...over },
timestamp,
} as GeolocationPosition;
}
function geoError(code: number, message = 'Location unavailable'): GeolocationPositionError {
return { code, message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 } as GeolocationPositionError;
}
/** Dispatches an orientation event the hook's listener will pick up. */
function orient(props: Record<string, unknown>): void {
const ev = new Event('deviceorientationabsolute');
Object.assign(ev, props);
window.dispatchEvent(ev);
}
beforeEach(() => {
successCb = null;
errorCb = null;
watchPosition = vi.fn((ok: WatchSuccess, fail: WatchFailure) => {
successCb = ok;
errorCb = fail;
return 7;
});
clearWatch = vi.fn();
Object.defineProperty(navigator, 'geolocation', {
value: { watchPosition, clearWatch },
configurable: true,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('useGeolocation', () => {
it('FE-HOOK-GEO-001: starts off with nothing tracked', () => {
const { result } = renderHook(() => useGeolocation());
expect(result.current.mode).toBe('off');
expect(result.current.position).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.errorCode).toBeNull();
expect(watchPosition).not.toHaveBeenCalled();
});
it('FE-HOOK-GEO-002: cycles off → show → follow → off', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('show');
expect(watchPosition).toHaveBeenCalledTimes(1);
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('follow');
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('off');
expect(clearWatch).toHaveBeenCalledWith(7);
});
it('FE-HOOK-GEO-003: requests a high-accuracy watch', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(watchPosition.mock.calls[0][2]).toEqual({
enableHighAccuracy: true,
maximumAge: 2000,
timeout: 15000,
});
});
it('FE-HOOK-GEO-004: maps a fix onto the position shape', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix({ heading: 90, speed: 3.5 })));
expect(result.current.position).toEqual({
lat: 48.2, lng: 16.37, accuracy: 12, heading: 90, speed: 3.5, timestamp: 1_700_000_000_000,
});
});
it('FE-HOOK-GEO-005: reports an error but stays subscribed when the fix merely fails', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(2, 'Position unavailable')));
expect(result.current.error).toBe('Position unavailable');
expect(result.current.mode).toBe('show');
expect(clearWatch).not.toHaveBeenCalled();
});
it('FE-HOOK-GEO-006: falls back to a generic message when the error carries none', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(3, '')));
expect(result.current.error).toBe('Location unavailable');
});
it('FE-HOOK-GEO-007: a permission denial stops tracking entirely', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(1, 'User denied Geolocation')));
expect(result.current.mode).toBe('off');
expect(clearWatch).toHaveBeenCalledWith(7);
});
it('FE-HOOK-GEO-008: refuses to start when the browser has no geolocation', async () => {
// The hook guards with `'geolocation' in navigator`, so the key has to go
// away entirely — a defined-but-undefined property would slip past it.
delete (navigator as { geolocation?: Geolocation }).geolocation;
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('off');
expect(result.current.error).toBe('Geolocation is not supported in this browser');
});
it('FE-HOOK-GEO-009: prefers the webkit compass heading', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ webkitCompassHeading: 120, alpha: 10, absolute: true }));
expect(result.current.position?.heading).toBe(120);
});
it('FE-HOOK-GEO-010: converts a counter-clockwise alpha into a clockwise heading', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ alpha: 90, absolute: true }));
expect(result.current.position?.heading).toBe(270);
});
it('FE-HOOK-GEO-011: smooths successive headings instead of snapping', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ alpha: 0, absolute: true }));
expect(result.current.position?.heading).toBe(0);
// 0 → 40 with alpha 0.25 lands a quarter of the way, not at 40.
act(() => orient({ alpha: 320, absolute: true }));
expect(result.current.position?.heading).toBeCloseTo(10, 5);
});
it('FE-HOOK-GEO-012: takes the short way around the 360° seam', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ alpha: 10, absolute: true })); // heading 350
act(() => orient({ alpha: 350, absolute: true })); // heading 10 — +20 across the seam
expect(result.current.position?.heading).toBeCloseTo(355, 5);
});
it('FE-HOOK-GEO-013a: still uses a non-absolute alpha as a fallback heading', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ alpha: 90 }));
expect(result.current.position?.heading).toBe(270);
});
it('FE-HOOK-GEO-013: ignores orientation events without a usable angle', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix({ heading: 45 })));
act(() => orient({ alpha: null, absolute: true }));
expect(result.current.position?.heading).toBe(45);
});
it('FE-HOOK-GEO-014: keeps the compass heading when the GPS fix has none', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => successCb!(fix()));
act(() => orient({ alpha: 180, absolute: true }));
act(() => successCb!(fix({ heading: null }, 1_700_000_001_000)));
expect(result.current.position?.heading).toBe(180);
});
it('FE-HOOK-GEO-015: setMode accepts a value and a derived updater', async () => {
const { result } = renderHook(() => useGeolocation());
act(() => result.current.setMode('show'));
expect(result.current.mode).toBe('show');
expect(watchPosition).toHaveBeenCalledTimes(1);
act(() => result.current.setMode(prev => (prev === 'show' ? 'follow' : prev)));
expect(result.current.mode).toBe('follow');
// Already watching — no second subscription.
expect(watchPosition).toHaveBeenCalledTimes(1);
act(() => result.current.setMode('off'));
expect(result.current.mode).toBe('off');
expect(result.current.position).toBeNull();
expect(clearWatch).toHaveBeenCalledWith(7);
});
it('FE-HOOK-GEO-015b: the updater form sees the freshest mode and starts one watch', async () => {
// The side effects run outside the state updater (React may invoke an
// updater more than once per commit, and startWatch awaits the iOS
// orientation prompt before it records the watch id), so the previous mode
// comes from a ref that setMode keeps in step within a batch.
const requestPermission = vi.fn().mockResolvedValue('granted');
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation(), { wrapper: StrictMode });
await act(async () => {
result.current.setMode('show');
result.current.setMode(prev => (prev === 'show' ? 'follow' : 'off'));
await Promise.resolve();
});
expect(result.current.mode).toBe('follow');
expect(watchPosition).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-GEO-016: asks iOS for orientation permission and proceeds either way', async () => {
const requestPermission = vi.fn().mockResolvedValue('denied');
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(requestPermission).toHaveBeenCalled();
expect(result.current.mode).toBe('show');
});
it('FE-HOOK-GEO-017: survives an older webkit that throws from requestPermission', async () => {
const requestPermission = vi.fn().mockRejectedValue(new Error('not allowed'));
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('show');
expect(watchPosition).toHaveBeenCalled();
});
it('FE-HOOK-GEO-018: tolerates clearWatch throwing while tearing down', async () => {
clearWatch.mockImplementation(() => { throw new Error('gone'); });
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(() => act(() => result.current.setMode('off'))).not.toThrow();
});
it('FE-HOOK-GEO-024: stopping while the iOS prompt is open leaves no watch behind', async () => {
let grant!: () => void;
const requestPermission = vi.fn(() => new Promise<string>(resolve => { grant = () => resolve('granted'); }));
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
act(() => result.current.setMode('show'));
act(() => result.current.setMode('off'));
await act(async () => { grant(); await new Promise(r => setTimeout(r, 0)); });
expect(watchPosition).not.toHaveBeenCalled();
});
it('FE-HOOK-GEO-025: stop and start during the iOS prompt still ends with one watch', async () => {
const grants: ((v: string) => void)[] = [];
const requestPermission = vi.fn(() => new Promise<string>(resolve => { grants.push(resolve); }));
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
act(() => result.current.setMode('show'));
act(() => result.current.setMode('off'));
act(() => result.current.setMode('show'));
await act(async () => { grants.forEach(g => g('granted')); await new Promise(r => setTimeout(r, 0)); });
// Both runs got past the prompt; only the newer one may subscribe, or the older
// watch keeps running with its id overwritten.
expect(requestPermission).toHaveBeenCalledTimes(2);
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(result.current.mode).toBe('show');
});
it.each([
[1, 'permission-denied'],
[2, 'unavailable'],
[3, 'timeout'],
])('FE-HOOK-GEO-026: maps watch error code %i to errorCode "%s"', async (code, expected) => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(code)));
expect(result.current.errorCode).toBe(expected);
expect(result.current.error).toBe('Location unavailable');
});
it('FE-HOOK-GEO-027: a denial still stops the watch and keeps the code for the UI', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(1, 'User denied Geolocation')));
expect(result.current.mode).toBe('off');
expect(clearWatch).toHaveBeenCalledWith(7);
expect(result.current.errorCode).toBe('permission-denied');
});
it('FE-HOOK-GEO-028: a missing geolocation API reports the unsupported code', async () => {
delete (navigator as { geolocation?: Geolocation }).geolocation;
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(result.current.errorCode).toBe('unsupported');
});
it('FE-HOOK-GEO-029: a successful fix clears both error and errorCode', async () => {
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
act(() => errorCb!(geoError(3, 'Timeout expired')));
expect(result.current.error).toBe('Timeout expired');
expect(result.current.errorCode).toBe('timeout');
act(() => successCb!(fix()));
expect(result.current.error).toBeNull();
expect(result.current.errorCode).toBeNull();
expect(result.current.position).not.toBeNull();
});
it('FE-HOOK-GEO-030: an insecure origin is named as such, not as a blocked permission', async () => {
// Plain-HTTP self-hosting: the browser would answer PERMISSION_DENIED and the
// user would be sent into device settings that are already correct.
vi.stubGlobal('isSecureContext', false);
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(watchPosition).not.toHaveBeenCalled();
expect(result.current.mode).toBe('off');
expect(result.current.errorCode).toBe('insecure-context');
});
it('FE-HOOK-GEO-031: a secure origin is not mistaken for an insecure one', async () => {
vi.stubGlobal('isSecureContext', true);
const { result } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
expect(result.current.mode).toBe('show');
expect(result.current.errorCode).toBeNull();
expect(watchPosition).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => {
const { result, unmount } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
unmount();
expect(clearWatch).toHaveBeenCalledWith(7);
});
});
describe('getCurrentPositionOnce', () => {
/** Swaps the watch-based stub from beforeEach for a one-shot one. */
function stubOnce(getCurrentPosition: (ok: PositionCallback, fail?: PositionErrorCallback) => void): void {
Object.defineProperty(navigator, 'geolocation', {
value: { getCurrentPosition: vi.fn(getCurrentPosition) },
configurable: true,
});
}
it('FE-HOOK-GEO-020: resolves a mapped position from a single fix', async () => {
stubOnce(ok => ok(fix({ latitude: 41.9, longitude: 12.5, accuracy: 10 })));
await expect(getCurrentPositionOnce()).resolves.toEqual({
lat: 41.9, lng: 12.5, accuracy: 10, heading: null, speed: null, timestamp: 1_700_000_000_000,
});
});
it('FE-HOOK-GEO-021: rejects as unsupported when the browser has no geolocation', async () => {
// Same guard as FE-HOOK-GEO-008: the key has to go away entirely.
delete (navigator as { geolocation?: Geolocation }).geolocation;
await expect(getCurrentPositionOnce()).rejects.toMatchObject({
name: 'GeoOnceError',
code: 'unsupported',
});
});
it('FE-HOOK-GEO-022: rejects outside a secure context without asking the browser', async () => {
const getCurrentPosition = vi.fn();
stubOnce(getCurrentPosition);
vi.stubGlobal('isSecureContext', false);
await expect(getCurrentPositionOnce()).rejects.toMatchObject({ code: 'insecure-context' });
expect(getCurrentPosition).not.toHaveBeenCalled();
});
it.each([
[1, 'permission-denied'],
[2, 'unavailable'],
[3, 'timeout'],
])('FE-HOOK-GEO-023: maps error code %i to "%s"', async (code, expected) => {
stubOnce((_ok, fail) => fail?.(geoError(code)));
const rejection = await getCurrentPositionOnce().catch((e: unknown) => e);
expect(rejection).toBeInstanceOf(GeoOnceError);
expect((rejection as GeoOnceError).code).toBe(expected);
});
});