diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx index dedb007f..b52bd101 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx @@ -3,8 +3,18 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, act } from "@testing-library/react"; import { FlightsMapStartPage } from "./FlightsMapStartPage.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; +import type { FlightsMapSearchParams } from "../types.js"; + +function buildDictionaries(raw?: IRawDictionaries): IDictionaries { + return transformDictionaries( + raw ?? { regions: [], countries: [], cities: [], airports: [] }, + "ru", + ); +} vi.mock("@modern-js/runtime/router", () => ({ useParams: () => ({ lang: "ru" }), @@ -48,11 +58,12 @@ const searchState: { loading: false, error: null, }; +const searchCalls: Array = []; vi.mock("../hooks/useFlightsMapSearch.js", () => ({ - useFlightsMapSearch: () => ({ - ...searchState, - refresh: vi.fn(), - }), + useFlightsMapSearch: (params: FlightsMapSearchParams | null) => { + searchCalls.push(params); + return { ...searchState, refresh: vi.fn() }; + }, })); vi.mock("../hooks/useFlightsMapCalendar.js", () => ({ @@ -60,16 +71,7 @@ vi.mock("../hooks/useFlightsMapCalendar.js", () => ({ })); const dictState: { - dictionaries: - | null - | { - cities: Array<{ - code: string; - name: string; - country_code: string; - location: { lat: number; lon: number }; - }>; - }; + dictionaries: IDictionaries | null; loading: boolean; error: Error | null; } = { @@ -77,10 +79,15 @@ const dictState: { loading: true, error: null, }; -vi.mock("@/shared/dictionaries/index.js", () => ({ - useDictionaries: () => dictState, - getCityCodeByAirportCode: () => undefined, -})); +vi.mock("@/shared/dictionaries/index.js", async () => { + const actual = await vi.importActual( + "@/shared/dictionaries/index.js", + ); + return { + ...actual, + useDictionaries: () => dictState, + }; +}); describe("FlightsMapStartPage — dictionaries integration", () => { beforeEach(() => { @@ -104,7 +111,7 @@ describe("FlightsMapStartPage — dictionaries integration", () => { it("does not show the loader once dictionaries resolve", () => { dictState.loading = false; - dictState.dictionaries = { cities: [] }; + dictState.dictionaries = buildDictionaries(); render(); expect(screen.queryByTestId("map-loader")).toBeNull(); }); @@ -119,22 +126,42 @@ describe("FlightsMapStartPage — markers from dictionaries", () => { }); it("maps cities to IMapMarker[] with zoomLevel and countryType", () => { - dictState.dictionaries = { + dictState.dictionaries = buildDictionaries({ + regions: [], + countries: [], cities: [ { code: "MOW", - name: "Москва", + title: { ru: "Москва" }, country_code: "RU", + has_afl_flights: true, location: { lat: 55, lon: 37 }, }, { code: "PAR", - name: "Париж", + title: { ru: "Париж" }, country_code: "FR", + has_afl_flights: true, location: { lat: 48, lon: 2 }, }, ], - }; + airports: [ + { + code: "MOW", + city_code: "MOW", + title: { ru: "Москва" }, + has_afl_flights: true, + location: { lat: 55, lon: 37 }, + }, + { + code: "PAR", + city_code: "PAR", + title: { ru: "Париж" }, + has_afl_flights: true, + location: { lat: 48, lon: 2 }, + }, + ], + }); render(); @@ -152,12 +179,42 @@ describe("FlightsMapStartPage — markers from dictionaries", () => { }); it("drops cities whose location is missing or invalid", () => { - dictState.dictionaries = { + dictState.dictionaries = buildDictionaries({ + regions: [], + countries: [], cities: [ - { code: "MOW", name: "Москва", country_code: "RU", location: { lat: 55, lon: 37 } }, - { code: "BAD", name: "Bad", country_code: "RU", location: { lat: Number.NaN, lon: 0 } }, + { + code: "MOW", + title: { ru: "Москва" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: 55, lon: 37 }, + }, + { + code: "BAD", + title: { ru: "Плохой" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: Number.NaN, lon: 0 }, + }, ], - }; + airports: [ + { + code: "MOW", + city_code: "MOW", + title: { ru: "Москва" }, + has_afl_flights: true, + location: { lat: 55, lon: 37 }, + }, + { + code: "BAD", + city_code: "BAD", + title: { ru: "Плохой" }, + has_afl_flights: true, + location: { lat: Number.NaN, lon: 0 }, + }, + ], + }); render(); @@ -166,7 +223,7 @@ describe("FlightsMapStartPage — markers from dictionaries", () => { }); it("passes domestic/international toggles through to MapCanvas", () => { - dictState.dictionaries = { cities: [] }; + dictState.dictionaries = buildDictionaries(); render(); @@ -178,13 +235,56 @@ describe("FlightsMapStartPage — markers from dictionaries", () => { describe("FlightsMapStartPage — polylines from search results (C.3)", () => { beforeEach(() => { lastMapCanvasProps = null; - dictState.dictionaries = { + dictState.dictionaries = buildDictionaries({ + regions: [], + countries: [], cities: [ - { code: "A", name: "A", country_code: "RU", location: { lat: 55, lon: 37 } }, - { code: "B", name: "B", country_code: "RU", location: { lat: 60, lon: 40 } }, - { code: "X", name: "X", country_code: "RU", location: { lat: 58, lon: 38 } }, + { + code: "A", + title: { ru: "Город A" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: 55, lon: 37 }, + }, + { + code: "B", + title: { ru: "Город B" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: 60, lon: 40 }, + }, + { + code: "X", + title: { ru: "Город X" }, + country_code: "RU", + has_afl_flights: true, + location: { lat: 58, lon: 38 }, + }, ], - }; + airports: [ + { + code: "A", + city_code: "A", + title: { ru: "Аэропорт A" }, + has_afl_flights: true, + location: { lat: 55, lon: 37 }, + }, + { + code: "B", + city_code: "B", + title: { ru: "Аэропорт B" }, + has_afl_flights: true, + location: { lat: 60, lon: 40 }, + }, + { + code: "X", + city_code: "X", + title: { ru: "Аэропорт X" }, + has_afl_flights: true, + location: { lat: 58, lon: 38 }, + }, + ], + }); dictState.loading = false; dictState.error = null; searchState.routes = []; @@ -230,3 +330,138 @@ describe("FlightsMapStartPage — polylines from search results (C.3)", () => { expect(polylines[1]!.style).toBe("connecting"); }); }); + +describe("FlightsMapStartPage — C.4 integration", () => { + beforeEach(() => { + lastMapCanvasProps = null; + searchCalls.length = 0; + dictState.dictionaries = buildDictionaries({ + regions: [], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "US", title: { ru: "США" }, world_region_id: 1 }, + ], + 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 }, + }, + { + code: "NYC", + title: { ru: "Нью-Йорк" }, + country_code: "US", + has_afl_flights: true, + location: { lat: 40, lon: -74 }, + }, + ], + 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 }, + }, + { + code: "JFK", + city_code: "NYC", + title: { ru: "Джон Кеннеди" }, + has_afl_flights: true, + location: { lat: 40, lon: -74 }, + }, + ], + }); + dictState.loading = false; + dictState.error = null; + searchState.routes = []; + searchState.loading = false; + searchState.error = null; + }); + + it("renders all routes (no filter toggles) in initial filter state", () => { + searchState.routes = [ + { route: ["MOW", "LED"], isDirect: true }, + { route: ["MOW", "NYC"], isDirect: true }, + ]; + render(); + const polylines = lastMapCanvasProps!["polylines"] as Array<{ cityIds: string[] }>; + expect(polylines).toHaveLength(2); + }); + + it("renders a departure + arrival popup in route mode with a buy-ticket URL", () => { + searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }]; + + render(); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); + }); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); + }); + + const popups = lastMapCanvasProps!["popups"] as Array<{ content: string; lat: number; lng: number }>; + expect(popups).toHaveLength(2); + expect(popups[0]!.content).toContain("Москва"); + expect(popups[1]!.content).toContain("Санкт-Петербург"); + expect(popups[1]!.content).toContain("routes=MOW."); + expect(popups[1]!.content).toContain(".LED"); + expect(popups[1]!.content).toContain("https://www.aeroflot.ru/sb/app/ru-ru"); + }); + + it("does not render popups in spider mode (departure only)", () => { + searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }]; + render(); + const onClick = lastMapCanvasProps!["onMarkerClick"] as (id: string) => void; + act(() => { + onClick("MOW"); + }); + + const popups = lastMapCanvasProps!["popups"] as unknown[]; + expect(popups).toEqual([]); + }); + + it("auto-fallback: re-issues the search with connections=1 when direct routes come back empty", () => { + searchState.routes = []; + render(); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); + }); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); + }); + + const withOne = searchCalls.filter((p) => p?.connections === 1); + expect(withOne.length).toBeGreaterThanOrEqual(1); + }); + + it("mirror: last search call uses connections=1 after auto-fallback", () => { + searchState.routes = []; + render(); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); + }); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); + }); + + const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival); + expect(last?.connections).toBe(1); + }); +});