Add shared useGeoCityDefault hook (generalized from flights-map)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user