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");