diff --git a/src/features/flights-map/hooks/useGeolocationDefault.test.tsx b/src/features/flights-map/hooks/useGeolocationDefault.test.tsx new file mode 100644 index 00000000..6ae84e06 --- /dev/null +++ b/src/features/flights-map/hooks/useGeolocationDefault.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { StrictMode, type ReactNode } from "react"; +import { useGeolocationDefault } from "./useGeolocationDefault.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; +import type { IFlightsMapFilterState } from "../types.js"; + +const raw: IRawDictionaries = { + regions: [], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], +}; +const dictionaries: IDictionaries = transformDictionaries(raw, "ru"); + +function makeFilter( + overrides: Partial = {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +interface GeolocationMock { + getCurrentPosition: ReturnType; +} + +function installGeolocation(mock: GeolocationMock | undefined): void { + Object.defineProperty(navigator, "geolocation", { + value: mock, + writable: true, + configurable: true, + }); +} + +function successMock(lat: number, lon: number): GeolocationMock { + return { + getCurrentPosition: vi.fn((success: PositionCallback) => { + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 0, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + toJSON: () => ({}), + }, + timestamp: 0, + toJSON: () => ({}), + } as GeolocationPosition); + }), + }; +} + +function errorMock(): GeolocationMock { + return { + getCurrentPosition: vi.fn((_success, error?: PositionErrorCallback) => { + error?.({ + code: 1, + message: "permission denied", + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + } as GeolocationPositionError); + }), + }; +} + +describe("useGeolocationDefault", () => { + let lastState: IFlightsMapFilterState = makeFilter(); + let setFilterState: (updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState) => void; + + beforeEach(() => { + lastState = makeFilter(); + setFilterState = (updater) => { + lastState = updater(lastState); + }; + }); + + afterEach(() => { + installGeolocation(undefined); + }); + + it("sets departure to nearest city when filter is empty and geolocation succeeds", () => { + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBe("MOW"); + }); + + it("does not update when filter.departure is already set", () => { + lastState = makeFilter({ departure: "LED" }); + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBe("LED"); + }); + + it("does not update when filter.arrival is already set", () => { + lastState = makeFilter({ arrival: "MOW" }); + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when geolocation permission is denied", () => { + installGeolocation(errorMock()); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when navigator.geolocation is missing", () => { + installGeolocation(undefined); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when dictionaries is null at callback time", () => { + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(null, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("invokes getCurrentPosition exactly once under StrictMode", () => { + const mock = successMock(55.1, 37.1); + installGeolocation(mock); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook( + () => useGeolocationDefault(dictionaries, lastState, setFilterState), + { wrapper }, + ); + expect(mock.getCurrentPosition).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/flights-map/hooks/useGeolocationDefault.ts b/src/features/flights-map/hooks/useGeolocationDefault.ts new file mode 100644 index 00000000..700ab306 --- /dev/null +++ b/src/features/flights-map/hooks/useGeolocationDefault.ts @@ -0,0 +1,65 @@ +/** + * Sets the flights-map filter's departure to the nearest city based on + * browser geolocation, if the user has not already typed a departure or + * arrival. Fires once per mount. Silent on permission denial or missing + * geolocation API. Matches Angular `UserLocationService` + `DictionariesService.locate`. + */ + +import { useEffect, useRef } from "react"; +import { + findCityByCoord, + type IDictionaries, +} from "@/shared/dictionaries/index.js"; +import type { IFlightsMapFilterState } from "../types.js"; + +export function useGeolocationDefault( + dictionaries: IDictionaries | null, + filterState: IFlightsMapFilterState, + setFilterState: ( + updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState, + ) => void, +): void { + const appliedRef = useRef(false); + const dictRef = useRef(dictionaries); + dictRef.current = dictionaries; + const filterRef = useRef(filterState); + filterRef.current = filterState; + const setFilterRef = useRef(setFilterState); + setFilterRef.current = setFilterState; + + useEffect(() => { + if (appliedRef.current) return; + if (typeof navigator === "undefined" || !navigator.geolocation) { + appliedRef.current = true; + return; + } + + appliedRef.current = true; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const d = dictRef.current; + const f = filterRef.current; + if (!d) return; + if (f.departure || f.arrival) return; + + const city = findCityByCoord( + d, + pos.coords.latitude, + pos.coords.longitude, + ); + if (!city) return; + + setFilterRef.current((prev) => + prev.departure || prev.arrival + ? prev + : { ...prev, departure: city.code }, + ); + }, + () => { + // Silent: permission denied / timeout / unavailable. + }, + { enableHighAccuracy: false, timeout: 5000 }, + ); + }, []); +}