diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx
index b61bae02..012be519 100644
--- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx
+++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx
@@ -10,6 +10,7 @@ import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
import type {
FlightsMapCalendarParams,
+ IFlightsMapFilterState,
FlightsMapSearchParams,
} from "../types.js";
import {
@@ -383,6 +384,7 @@ describe("TZ §4.1.4 Table 7 breadcrumbs — Flight-Map pages (rows 1-3)", () =>
describe("FlightsMapStartPage — C.4 integration", () => {
beforeEach(() => {
lastMapCanvasProps = null;
+ lastMapFilterProps = null;
searchCalls.length = 0;
dictState.dictionaries = buildDictionaries({
regions: [],
@@ -513,6 +515,33 @@ describe("FlightsMapStartPage — C.4 integration", () => {
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
expect(last?.connections).toBe(1);
});
+
+ it("keeps transfer-only toggle off after the user disables auto-fallback", () => {
+ searchState.routes = [];
+ render();
+ act(() => {
+ (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
+ });
+ act(() => {
+ (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
+ });
+
+ const autoFallbackValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
+ expect(autoFallbackValue.connections).toBe(true);
+
+ act(() => {
+ (lastMapFilterProps!["onChange"] as (state: IFlightsMapFilterState) => void)({
+ ...autoFallbackValue,
+ connections: false,
+ });
+ });
+
+ const userValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
+ expect(userValue.connections).toBe(false);
+
+ const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
+ expect(last?.connections).toBe(0);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx
index 0d063571..25f6073a 100644
--- a/src/features/flights-map/components/FlightsMapStartPage.tsx
+++ b/src/features/flights-map/components/FlightsMapStartPage.tsx
@@ -161,6 +161,8 @@ export const FlightsMapStartPage: FC = ({
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
filterState.connections ? 1 : 0,
);
+ const [connectionsFallbackSuppressed, setConnectionsFallbackSuppressed] =
+ useState(false);
const persistFilterState = useCallback((newState: IFlightsMapFilterState) => {
setFilterState(newState);
@@ -225,6 +227,7 @@ export const FlightsMapStartPage: FC = ({
useEffect(() => {
if (loading || error) return;
if (effectiveConnections !== 0) return;
+ if (connectionsFallbackSuppressed) return;
if (!filterState.departure || !filterState.arrival) return;
if (routes.length > 0) return;
setEffectiveConnections(1);
@@ -232,6 +235,7 @@ export const FlightsMapStartPage: FC = ({
loading,
error,
effectiveConnections,
+ connectionsFallbackSuppressed,
filterState.departure,
filterState.arrival,
routes,
@@ -239,14 +243,43 @@ export const FlightsMapStartPage: FC = ({
// Reflect fallback in the UI toggle once.
useEffect(() => {
- if (filterState.arrival && effectiveConnections === 1 && !filterState.connections) {
+ if (
+ filterState.arrival &&
+ effectiveConnections === 1 &&
+ !filterState.connections &&
+ !connectionsFallbackSuppressed
+ ) {
setFilterState((prev) => ({ ...prev, connections: true }));
}
- }, [effectiveConnections, filterState.arrival, filterState.connections]);
+ }, [
+ effectiveConnections,
+ filterState.arrival,
+ filterState.connections,
+ connectionsFallbackSuppressed,
+ ]);
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
+ const sameRoute =
+ newState.departure === filterState.departure &&
+ newState.arrival === filterState.arrival;
+ const turnedConnectionsOff =
+ sameRoute && filterState.connections && !newState.connections;
+
+ setConnectionsFallbackSuppressed(
+ turnedConnectionsOff
+ ? true
+ : sameRoute && !newState.connections
+ ? connectionsFallbackSuppressed
+ : false,
+ );
persistFilterState(newState);
- }, [persistFilterState]);
+ }, [
+ connectionsFallbackSuppressed,
+ filterState.arrival,
+ filterState.connections,
+ filterState.departure,
+ persistFilterState,
+ ]);
const handleMarkerClick = useCallback(
(markerId: string) => {
diff --git a/tests/e2e/flights-map.spec.ts b/tests/e2e/flights-map.spec.ts
index 675f9c63..f854a8b7 100644
--- a/tests/e2e/flights-map.spec.ts
+++ b/tests/e2e/flights-map.spec.ts
@@ -1,11 +1,73 @@
import { test, expect } from "./fixtures/console-gate";
+import {
+ routeAppSettingsFixture,
+ routeDictionaryFixtures,
+} from "./helpers/api-fixtures";
+
+async function routeFlightsMapTransferOnlyFixtures(
+ page: import("@playwright/test").Page,
+): Promise {
+ await routeAppSettingsFixture(page);
+ await routeDictionaryFixtures(page);
+
+ await page.route("**/api/flights/1/*/destinations?**", async (route) => {
+ const url = new URL(route.request().url());
+ const departure = url.searchParams.get("departure");
+ const arrival = url.searchParams.get("arrival");
+ const connections = url.searchParams.get("connections");
+
+ const hasTransferRoute =
+ departure === "LED" && arrival === "MLE" && connections === "1";
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ routes: hasTransferRoute
+ ? [{ route: ["LED", "SVO", "MLE"], isDirect: false }]
+ : [],
+ },
+ }),
+ });
+ });
+
+ await page.route("**/api/flights/v1/*/days/**/flights-map/", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ days: "1".repeat(200) }),
+ }),
+ );
+
+ await page.route("**/map/api/tile/**", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "image/gif",
+ body: Buffer.from("R0lGODlhAQABAAAAACw=", "base64"),
+ }),
+ );
+}
+
+async function selectCity(
+ page: import("@playwright/test").Page,
+ inputTestId: string,
+ query: string,
+ optionTestId: string,
+): Promise {
+ const input = page.getByTestId(inputTestId).locator("input");
+ await input.click();
+ await input.fill(query);
+ await expect(page.getByTestId(optionTestId)).toBeVisible({ timeout: 10000 });
+ await page.getByTestId(optionTestId).click();
+}
test.describe("Flights Map", () => {
test("/ru/flights-map renders or shows feature-flag disabled message", async ({
page,
consoleMessages,
}) => {
- await page.goto("/ru/flights-map");
+ await page.goto("/ru-ru/flights-map");
await page.waitForLoadState("domcontentloaded");
// Either the map page renders or the feature-flag-disabled fallback shows
@@ -14,4 +76,31 @@ test.describe("Flights Map", () => {
await expect(mapStart.or(mapDisabled)).toBeVisible({ timeout: 10000 });
});
+
+ test("transfer-only checkbox can be switched off after auto-fallback", async ({
+ page,
+ consoleMessages,
+ }) => {
+ await routeFlightsMapTransferOnlyFixtures(page);
+
+ await page.goto("/ru-ru/flights-map");
+ await expect(page.getByTestId("flights-map-start")).toBeVisible();
+
+ await selectCity(page, "fm-departure-input", "Санкт", "city-suggestion-LED");
+ await selectCity(page, "fm-arrival-input", "Мале", "city-suggestion-MLE");
+
+ const transferToggle = page.getByTestId("fm-connections-toggle");
+ const transferLabel = page.getByText("Показать только рейсы с пересадкой", {
+ exact: true,
+ });
+ await expect(transferToggle).toBeEnabled();
+ await expect(transferToggle).toBeChecked({ timeout: 10000 });
+
+ await transferLabel.click();
+ await expect(transferToggle).not.toBeChecked();
+ await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
+ await expect(transferToggle).not.toBeChecked();
+
+ expect(consoleMessages).toEqual([]);
+ });
});
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
index 35870bed..1f90f314 100644
--- a/tests/e2e/smoke.spec.ts
+++ b/tests/e2e/smoke.spec.ts
@@ -1,7 +1,14 @@
import { test, expect } from "./fixtures/console-gate";
+import {
+ routeDictionaryFixtures,
+ routeOnlineboardRouteFixtures,
+} from "./helpers/api-fixtures";
test.describe("Smoke tests", () => {
test("root / redirects to /ru/onlineboard", async ({ page, consoleMessages }) => {
+ await routeDictionaryFixtures(page);
+ await routeOnlineboardRouteFixtures(page);
+
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
@@ -9,8 +16,8 @@ test.describe("Smoke tests", () => {
// Depending on SSR/CSR mode, this may be a server 302 or a client-side
// navigation. Wait up to 15s for either outcome.
try {
- await page.waitForURL("**/ru/onlineboard", { timeout: 15000 });
- expect(page.url()).toContain("/ru/onlineboard");
+ await page.waitForURL(/\/ru(?:-ru)?\/onlineboard$/, { timeout: 15000 });
+ expect(page.url()).toMatch(/\/ru(?:-ru)?\/onlineboard$/);
} catch {
// If the redirect doesn't fire (e.g. loader not invoked in dev SSR mode),
// verify the page at least rendered the online board content.
@@ -54,6 +61,9 @@ test.describe("Smoke tests", () => {
page,
consoleMessages,
}) => {
+ await routeDictionaryFixtures(page);
+ await routeOnlineboardRouteFixtures(page);
+
await page.goto("/ru-en/onlineboard?_preferredLanguage=en&_preferredLocale=ruh");
await page.waitForLoadState("domcontentloaded");