ce2ca4a689
- URL surface now matches Angular: `/ru-ru/`, `/en-us/`, `/zh-cn/`, …
(BCP-47). Bare short codes still work — the [lang]/layout auto-
promotes them with a replace navigation. Internally everything that
needs the short language (i18n file lookup, API path segment,
Accept-Language header, dictionary `title[lang]` key, Intl
formatters) reads it through the new `useLocale()` hook, which
returns both `locale` (BCP-47) and `language` (short).
- ApiClient.locale is now mutable and is updated from the [lang]
layout whenever the URL locale changes — was hard-coded to "ru" in
the root layout before, so backend responses for /en/... still came
back in Russian. Cities / airports / flight statuses now arrive in
the active language.
- All 21 empty EN translation keys filled in (AIRPLANE.*, BOARD.
PREVIOUS-FLIGHT, SCHEDULE.FILE-NAME, SEO.SCHEDULE.*, SEO.FLIGHTS-
MAP.*, SHARED.FLIGHT-TRANSFER-PLURAL-*, SHARED.WEEK_FORMAT-WRONG)
so /en-us renders without falling back to raw keys.
- Added BOARD.LOAD-FAILED-TITLE / -MESSAGE keys (RU + EN) and removed
the three hardcoded Russian error strings from the search-page
error card.
- FlightStatus now reads `FLIGHT-STATUSES.{Status}` from i18n instead
of hardcoding the Russian labels.
- FlightCard's OperatorLogo now picks the en/ru carrier-logo variant
from `useLocale().language` instead of always passing "ru" — the
Aeroflot/Rossiya logos display in the active language where
variants exist.
- registerPrimeLocales(): all 9 supported languages get a PrimeReact
`addLocale` entry at module load (RU + EN hand-curated, others built
from Intl). Calendar/AutoComplete widgets switch with the URL.
- ErrorBoundary catches outside the i18n provider, so it now ships
its own minimal localised string table keyed off the URL locale —
no more "Something went wrong" leaking on the Russian site.
- Hreflang URLs now emit BCP-47 (`/en-us/...`) while `hreflang="en"`
stays the short Google-friendly form.
- Datetime helpers accept either short or BCP-47 locale (`isRussianLocale`)
so callers can pass through whatever the route hands them.
468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
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-ru" }),
|
|
Link: ({ children, ...props }: { children: React.ReactNode }) => <a {...props}>{children}</a>,
|
|
}));
|
|
|
|
vi.mock("@/i18n/provider.js", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "ru" } }),
|
|
}));
|
|
|
|
vi.mock("@/ui/layout/PageTabs.js", () => ({
|
|
PageTabs: () => <div data-testid="page-tabs" />,
|
|
}));
|
|
|
|
vi.mock("./ClientOnly.js", () => ({
|
|
ClientOnly: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
let lastMapCanvasProps: Record<string, unknown> | null = null;
|
|
vi.mock("./MapCanvas.js", () => ({
|
|
MapCanvas: (props: Record<string, unknown>) => {
|
|
lastMapCanvasProps = props;
|
|
return <div data-testid="map-canvas" />;
|
|
},
|
|
}));
|
|
|
|
vi.mock("./FlightsMapFilter.js", () => ({
|
|
FlightsMapFilter: () => <div data-testid="flights-map-filter" />,
|
|
}));
|
|
|
|
vi.mock("@/env/index.js", () => ({
|
|
getEnv: () => ({ API_BASE_URL: "https://api.test" }),
|
|
}));
|
|
|
|
const searchState: {
|
|
routes: Array<{ route: string[]; isDirect: boolean }>;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
} = {
|
|
routes: [],
|
|
loading: false,
|
|
error: null,
|
|
};
|
|
const searchCalls: Array<FlightsMapSearchParams | null> = [];
|
|
vi.mock("../hooks/useFlightsMapSearch.js", () => ({
|
|
useFlightsMapSearch: (params: FlightsMapSearchParams | null) => {
|
|
searchCalls.push(params);
|
|
return { ...searchState, refresh: vi.fn() };
|
|
},
|
|
}));
|
|
|
|
vi.mock("../hooks/useFlightsMapCalendar.js", () => ({
|
|
useFlightsMapCalendar: () => ({ availableDays: [] }),
|
|
}));
|
|
|
|
const dictState: {
|
|
dictionaries: IDictionaries | null;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
} = {
|
|
dictionaries: null,
|
|
loading: true,
|
|
error: null,
|
|
};
|
|
vi.mock("@/shared/dictionaries/index.js", async () => {
|
|
const actual = await vi.importActual<typeof import("@/shared/dictionaries/index.js")>(
|
|
"@/shared/dictionaries/index.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
useDictionaries: () => dictState,
|
|
};
|
|
});
|
|
|
|
describe("FlightsMapStartPage — dictionaries integration", () => {
|
|
beforeEach(() => {
|
|
dictState.dictionaries = null;
|
|
dictState.loading = true;
|
|
dictState.error = null;
|
|
});
|
|
|
|
it("shows the loader while dictionaries are loading", () => {
|
|
dictState.loading = true;
|
|
render(<FlightsMapStartPage />);
|
|
expect(screen.getByTestId("map-loader")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the error banner when dictionaries failed", () => {
|
|
dictState.loading = false;
|
|
dictState.error = new Error("dict boom");
|
|
render(<FlightsMapStartPage />);
|
|
expect(screen.getByTestId("map-error")).toBeTruthy();
|
|
});
|
|
|
|
it("does not show the loader once dictionaries resolve", () => {
|
|
dictState.loading = false;
|
|
dictState.dictionaries = buildDictionaries();
|
|
render(<FlightsMapStartPage />);
|
|
expect(screen.queryByTestId("map-loader")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("FlightsMapStartPage — markers from dictionaries", () => {
|
|
beforeEach(() => {
|
|
lastMapCanvasProps = null;
|
|
dictState.dictionaries = null;
|
|
dictState.loading = false;
|
|
dictState.error = null;
|
|
});
|
|
|
|
it("maps cities to IMapMarker[] with zoomLevel and countryType", () => {
|
|
dictState.dictionaries = buildDictionaries({
|
|
regions: [],
|
|
countries: [],
|
|
cities: [
|
|
{
|
|
code: "MOW",
|
|
title: { ru: "Москва" },
|
|
country_code: "RU",
|
|
has_afl_flights: true,
|
|
location: { lat: 55, lon: 37 },
|
|
},
|
|
{
|
|
code: "PAR",
|
|
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(<FlightsMapStartPage />);
|
|
|
|
const markers = lastMapCanvasProps!["markers"] as Array<Record<string, unknown>>;
|
|
expect(markers).toHaveLength(2);
|
|
|
|
const mow = markers.find((m) => m["id"] === "MOW")!;
|
|
expect(mow["countryType"]).toBe("ru");
|
|
expect(mow["zoomLevel"]).toBe(2);
|
|
expect(mow["label"]).toBe("Москва");
|
|
|
|
const par = markers.find((m) => m["id"] === "PAR")!;
|
|
expect(par["countryType"]).toBe("other");
|
|
expect(par["zoomLevel"]).toBe(6);
|
|
});
|
|
|
|
it("drops cities whose location is missing or invalid", () => {
|
|
dictState.dictionaries = buildDictionaries({
|
|
regions: [],
|
|
countries: [],
|
|
cities: [
|
|
{
|
|
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(<FlightsMapStartPage />);
|
|
|
|
const markers = lastMapCanvasProps!["markers"] as Array<Record<string, unknown>>;
|
|
expect(markers.map((m) => m["id"])).toEqual(["MOW"]);
|
|
});
|
|
|
|
it("passes domestic/international toggles through to MapCanvas", () => {
|
|
dictState.dictionaries = buildDictionaries();
|
|
|
|
render(<FlightsMapStartPage />);
|
|
|
|
expect(lastMapCanvasProps!["domestic"]).toBe(false);
|
|
expect(lastMapCanvasProps!["international"]).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("FlightsMapStartPage — polylines from search results (C.3)", () => {
|
|
beforeEach(() => {
|
|
lastMapCanvasProps = null;
|
|
dictState.dictionaries = buildDictionaries({
|
|
regions: [],
|
|
countries: [],
|
|
cities: [
|
|
{
|
|
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 = [];
|
|
searchState.loading = false;
|
|
searchState.error = null;
|
|
});
|
|
|
|
it("passes an empty polylines array when no routes", () => {
|
|
render(<FlightsMapStartPage />);
|
|
const polylines = lastMapCanvasProps!["polylines"] as unknown[];
|
|
expect(polylines).toEqual([]);
|
|
});
|
|
|
|
it("passes an empty intermediateIds when no routes", () => {
|
|
render(<FlightsMapStartPage />);
|
|
const ids = lastMapCanvasProps!["intermediateIds"] as string[];
|
|
expect(ids).toEqual([]);
|
|
});
|
|
|
|
it("flows intermediateIds from a multi-hop route", () => {
|
|
searchState.routes = [{ route: ["A", "X", "B"], isDirect: false }];
|
|
|
|
render(<FlightsMapStartPage />);
|
|
|
|
const ids = lastMapCanvasProps!["intermediateIds"] as string[];
|
|
expect(ids).toEqual(["X"]);
|
|
});
|
|
|
|
it("flows route-mode polylines with correct style flags when search returned routes", () => {
|
|
searchState.routes = [
|
|
{ route: ["A", "B"], isDirect: true },
|
|
{ route: ["A", "X", "B"], isDirect: false },
|
|
];
|
|
|
|
render(<FlightsMapStartPage />);
|
|
|
|
const polylines = lastMapCanvasProps!["polylines"] as Array<{
|
|
style: string;
|
|
cityIds: string[];
|
|
}>;
|
|
expect(polylines).toHaveLength(2);
|
|
expect(polylines[0]!.style).toBe("direct");
|
|
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(<FlightsMapStartPage />);
|
|
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(<FlightsMapStartPage />);
|
|
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(<FlightsMapStartPage />);
|
|
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(<FlightsMapStartPage />);
|
|
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(<FlightsMapStartPage />);
|
|
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);
|
|
});
|
|
});
|