mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
47a4276e84
logout() unregistered every sync trigger, and the only place that registers them is App's mount effect - which never runs again, because logout navigates through the router rather than reloading. Log out, log back in, go offline, edit something: it queues and nothing ever flushes it. No online listener, no visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth success instead; registerSyncTriggers is already idempotent. The background sync loops only checked the auth gate on entry. Logout swaps the Dexie handle to the shared anonymous database, so a trip loop or a blob download still in flight afterwards wrote the logged-out account's itinerary and documents into it - the database that survives logout and greets the next person on a shared device. Check the gate per iteration. And the remote place:updated applier replaced the whole embedded place, while the local reducer right next to it preserves the assignment's own times. Same event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the itinerary when someone else renames the place. One merge helper now, used by both, so they cannot drift again. The test that should have caught it seeded null times and asserted only the name. Also: moving a day note deleted before it created, and restored the note only locally when the create failed; t() interpolation was corrupted by a '$' in a parameter value; a 429 upload was treated as permanently failed; and the geolocation watch was started inside a state updater.
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
// FE-HOOK-GEO-001 to FE-HOOK-GEO-023
|
|
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(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-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);
|
|
});
|
|
});
|