Add shared useGeoCityDefault hook (generalized from flights-map)

This commit is contained in:
2026-04-21 18:58:23 +03:00
parent 2aa831e198
commit b31204c543
2 changed files with 194 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGeoCityDefault } from "./useGeoCityDefault.js";
import type { IDictionaries } from "@/shared/dictionaries/index.js";
const mockDictionaries: IDictionaries = {
cities: [
{
code: "MOW",
name: "Москва",
location: { lat: 55.7558, lon: 37.6173 },
country_code: "RU",
countryName: "Russia",
has_afl_flights: true,
airports: [],
},
{
code: "LED",
name: "Санкт-Петербург",
location: { lat: 59.9311, lon: 30.3609 },
country_code: "RU",
countryName: "Russia",
has_afl_flights: true,
airports: [],
},
],
airports: [],
regions: [],
countries: [],
cityByCode: new Map([
[
"MOW",
{
code: "MOW",
name: "Москва",
location: { lat: 55.7558, lon: 37.6173 },
country_code: "RU",
countryName: "Russia",
has_afl_flights: true,
airports: [],
},
],
[
"LED",
{
code: "LED",
name: "Санкт-Петербург",
location: { lat: 59.9311, lon: 30.3609 },
country_code: "RU",
countryName: "Russia",
has_afl_flights: true,
airports: [],
},
],
]),
airportByCode: new Map(),
ruCityCodes: new Set(["MOW", "LED"]),
otherCityCodes: new Set(),
} as unknown as IDictionaries;
describe("useGeoCityDefault", () => {
let getCurrentPositionMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
getCurrentPositionMock = vi.fn();
Object.defineProperty(navigator, "geolocation", {
value: { getCurrentPosition: getCurrentPositionMock },
configurable: true,
});
});
afterEach(() => { vi.restoreAllMocks(); });
it("calls onCity with nearest city code when geolocation succeeds and shouldApply returns true", () => {
getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
});
const onCity = vi.fn();
renderHook(() =>
useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }),
);
expect(onCity).toHaveBeenCalledWith("MOW");
});
it("does not call onCity when shouldApply returns false", () => {
getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
});
const onCity = vi.fn();
renderHook(() =>
useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => false, onCity }),
);
expect(onCity).not.toHaveBeenCalled();
});
it("is silent when geolocation permission is denied", () => {
getCurrentPositionMock.mockImplementation((_s: unknown, onErr: (err: GeolocationPositionError) => void) => {
onErr({ code: 1, message: "denied" } as GeolocationPositionError);
});
const onCity = vi.fn();
renderHook(() =>
useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }),
);
expect(onCity).not.toHaveBeenCalled();
});
it("is silent when navigator.geolocation is undefined", () => {
Object.defineProperty(navigator, "geolocation", { value: undefined, configurable: true });
const onCity = vi.fn();
renderHook(() =>
useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }),
);
expect(onCity).not.toHaveBeenCalled();
});
it("fires only once per mount even if dictionaries change", () => {
getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
});
const onCity = vi.fn();
const { rerender } = renderHook(
({ dict }: { dict: IDictionaries }) =>
useGeoCityDefault({ dictionaries: dict, shouldApply: () => true, onCity }),
{ initialProps: { dict: mockDictionaries } },
);
rerender({ dict: { ...mockDictionaries } as IDictionaries });
expect(onCity).toHaveBeenCalledTimes(1);
});
it("waits for dictionaries to be non-null before computing city", () => {
getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
});
const onCity = vi.fn();
renderHook(() =>
useGeoCityDefault({ dictionaries: null, shouldApply: () => true, onCity }),
);
expect(onCity).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* One-shot geolocation → city-code hook. Matches Angular's
* `UserLocationService.location` semantics used by Online-Board,
* Schedule, and Flight-Map. Silent on permission denial / missing API.
*
* Fires at most once per mount. The `shouldApply` predicate is
* re-evaluated at callback time (after the geo permission resolves),
* letting the caller opt out if the user has already entered a value.
*/
import { useEffect, useRef } from "react";
import {
findCityByCoord,
type IDictionaries,
} from "@/shared/dictionaries/index.js";
export interface UseGeoCityDefaultOptions {
dictionaries: IDictionaries | null;
shouldApply: () => boolean;
onCity: (cityCode: string) => void;
}
export function useGeoCityDefault(opts: UseGeoCityDefaultOptions): void {
const appliedRef = useRef(false);
const optsRef = useRef(opts);
optsRef.current = opts;
useEffect(() => {
if (appliedRef.current) return;
if (typeof navigator === "undefined" || !navigator.geolocation) {
appliedRef.current = true;
return;
}
appliedRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
const { dictionaries, shouldApply, onCity } = optsRef.current;
if (!dictionaries) return;
if (!shouldApply()) return;
const city = findCityByCoord(
dictionaries,
pos.coords.latitude,
pos.coords.longitude,
);
if (!city) return;
onCity(city.code);
},
() => { /* silent */ },
{ enableHighAccuracy: false, timeout: 5000 },
);
}, []);
}