diff --git a/src/shared/dictionaries/useDictionaries.test.tsx b/src/shared/dictionaries/useDictionaries.test.tsx new file mode 100644 index 00000000..f70057d0 --- /dev/null +++ b/src/shared/dictionaries/useDictionaries.test.tsx @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { useDictionaries } from "./useDictionaries.js"; +import { ApiClientProvider } from "@/shared/api/provider.js"; +import type { ApiClient } from "@/shared/api/client.js"; +import type { IRawDictionaries } from "./types.js"; + +const raw: IRawDictionaries = { + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + 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 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], +}; + +function makeClient( + impl: (path: string) => Promise, +): ApiClient { + return { get: vi.fn(impl) } as unknown as ApiClient; +} + +function wrapper(client: ApiClient) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useDictionaries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts in a loading state with no data and no error", () => { + const client = makeClient(async () => []); + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + expect(result.current.loading).toBe(true); + expect(result.current.dictionaries).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("resolves to a populated IDictionaries after fetch", async () => { + const client = makeClient(async (path: string) => { + if (path.includes("world_regions")) return raw.regions; + if (path.includes("countries")) return raw.countries; + if (path.includes("cities")) return raw.cities; + if (path.includes("airports")) return raw.airports; + throw new Error("unexpected"); + }); + + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.dictionaries).not.toBeNull(); + expect(result.current.dictionaries!.cities.map((c) => c.code)).toEqual(["MOW"]); + expect(result.current.error).toBeNull(); + }); + + it("sets error and keeps dictionaries null on fetch rejection", async () => { + const client = makeClient(async () => { + throw new Error("boom"); + }); + + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.dictionaries).toBeNull(); + expect(result.current.error?.message).toBe("boom"); + }); + + it("does not call setState after unmount during an in-flight fetch", async () => { + let resolveFetch: (v: unknown) => void = () => {}; + const deferred = new Promise((r) => { + resolveFetch = r; + }); + const client = makeClient(async () => deferred); + + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); + const { unmount } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + unmount(); + resolveFetch([]); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining("unmounted"), + ); + warn.mockRestore(); + }); +}); diff --git a/src/shared/dictionaries/useDictionaries.ts b/src/shared/dictionaries/useDictionaries.ts new file mode 100644 index 00000000..e3019312 --- /dev/null +++ b/src/shared/dictionaries/useDictionaries.ts @@ -0,0 +1,41 @@ +/** + * Loads the four dictionary endpoints and returns the transformed, ready-to-use + * IDictionaries object. Mounts once per session (the map is client-only and + * only mounts once). Not intended for multiple concurrent consumers. + */ + +import { useEffect, useState } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { fetchDictionaries } from "./api.js"; +import { transformDictionaries } from "./transform.js"; +import type { IDictionariesState } from "./types.js"; + +export function useDictionaries(lang: string): IDictionariesState { + const client = useApiClient(); + const [state, setState] = useState({ + dictionaries: null, + loading: true, + error: null, + }); + + useEffect(() => { + let cancelled = false; + + fetchDictionaries(client) + .then((raw) => { + if (cancelled) return; + const dictionaries = transformDictionaries(raw, lang); + setState({ dictionaries, loading: false, error: null }); + }) + .catch((err: Error) => { + if (cancelled) return; + setState({ dictionaries: null, loading: false, error: err }); + }); + + return () => { + cancelled = true; + }; + }, [client, lang]); + + return state; +}