diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx index c9f13c41..01efe574 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx @@ -73,10 +73,12 @@ const searchState: { routes: Array<{ route: string[]; isDirect: boolean }>; loading: boolean; error: Error | null; + completedParams: FlightsMapSearchParams | null; } = { routes: [], loading: false, error: null, + completedParams: null, }; const searchCalls: Array = []; vi.mock("../hooks/useFlightsMapSearch.js", () => ({ @@ -317,6 +319,7 @@ describe("FlightsMapStartPage — polylines from search results (C.3)", () => { searchState.routes = []; searchState.loading = false; searchState.error = null; + searchState.completedParams = null; }); it("passes an empty polylines array when no routes", () => { @@ -510,9 +513,9 @@ describe("FlightsMapStartPage — C.4 integration", () => { expect(popups).toEqual([]); }); - it("auto-fallback: re-issues the search with connections=1 when direct routes come back empty", () => { + it("auto-fallback: re-issues the search with connections=1 when a completed direct search comes back empty", () => { searchState.routes = []; - render(); + const { rerender } = render(); act(() => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); }); @@ -520,13 +523,21 @@ describe("FlightsMapStartPage — C.4 integration", () => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); }); + const direct = [...searchCalls] + .reverse() + .find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0); + searchState.completedParams = direct ?? null; + act(() => { + rerender(); + }); + const withOne = searchCalls.filter((p) => p?.connections === 1); expect(withOne.length).toBeGreaterThanOrEqual(1); }); - it("mirror: last search call uses connections=1 after auto-fallback", () => { + it("mirror: last search call uses connections=1 after completed auto-fallback", () => { searchState.routes = []; - render(); + const { rerender } = render(); act(() => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); }); @@ -534,13 +545,21 @@ describe("FlightsMapStartPage — C.4 integration", () => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); }); + const direct = [...searchCalls] + .reverse() + .find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0); + searchState.completedParams = direct ?? null; + act(() => { + rerender(); + }); + 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(); + const { rerender } = render(); act(() => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); }); @@ -548,6 +567,14 @@ describe("FlightsMapStartPage — C.4 integration", () => { (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); }); + const direct = [...searchCalls] + .reverse() + .find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0); + searchState.completedParams = direct ?? null; + act(() => { + rerender(); + }); + const autoFallbackValue = lastMapFilterProps!["value"] as IFlightsMapFilterState; expect(autoFallbackValue.connections).toBe(true); @@ -564,6 +591,22 @@ describe("FlightsMapStartPage — C.4 integration", () => { const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival); expect(last?.connections).toBe(0); }); + + it("does not auto-fallback before the current direct search has completed", () => { + searchState.routes = []; + searchState.completedParams = null; + render(); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW"); + }); + act(() => { + (lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED"); + }); + + const value = lastMapFilterProps!["value"] as IFlightsMapFilterState; + expect(value.connections).toBe(false); + expect(searchCalls.some((p) => p?.connections === 1)).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index 25f6073a..76bbd061 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -220,12 +220,21 @@ export const FlightsMapStartPage: FC = ({ searchDateFromYmd, ]); - const { routes, loading, error } = useFlightsMapSearch(searchParams); + const { routes, loading, error, completedParams } = + useFlightsMapSearch(searchParams); const { availableDays } = useFlightsMapCalendar(calendarParams); // Auto-fallback: empty result, route mode, connections=0 → retry with 1. useEffect(() => { if (loading || error) return; + if (!searchParams || searchParams.connections !== 0) return; + const directSearchCompleted = + completedParams?.departure === searchParams.departure && + completedParams?.arrival === searchParams.arrival && + completedParams?.dateFrom === searchParams.dateFrom && + completedParams?.dateTo === searchParams.dateTo && + completedParams?.connections === 0; + if (!directSearchCompleted) return; if (effectiveConnections !== 0) return; if (connectionsFallbackSuppressed) return; if (!filterState.departure || !filterState.arrival) return; @@ -236,9 +245,11 @@ export const FlightsMapStartPage: FC = ({ error, effectiveConnections, connectionsFallbackSuppressed, + completedParams, filterState.departure, filterState.arrival, routes, + searchParams, ]); // Reflect fallback in the UI toggle once. diff --git a/src/features/flights-map/hooks/useFlightsMapSearch.ts b/src/features/flights-map/hooks/useFlightsMapSearch.ts index 83caced4..73023dad 100644 --- a/src/features/flights-map/hooks/useFlightsMapSearch.ts +++ b/src/features/flights-map/hooks/useFlightsMapSearch.ts @@ -17,6 +17,7 @@ export interface UseFlightsMapSearchResult { routes: IFlightRoute[]; loading: boolean; error: ApiError | null; + completedParams: FlightsMapSearchParams | null; refresh: () => void; } @@ -31,6 +32,8 @@ export function useFlightsMapSearch( const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [completedParams, setCompletedParams] = + useState(null); const [refreshKey, setRefreshKey] = useState(0); const paramsRef = useRef(params); @@ -44,18 +47,21 @@ export function useFlightsMapSearch( if (!paramsRef.current) { setRoutes([]); setLoading(false); + setCompletedParams(null); return; } + const requestParams = { ...paramsRef.current }; let cancelled = false; setLoading(true); setError(null); setRoutes([]); - searchDestinations(client, paramsRef.current) + searchDestinations(client, requestParams) .then((response: IDestinationsResponse) => { if (!cancelled) { setRoutes(response.data.routes); + setCompletedParams(requestParams); setLoading(false); } }) @@ -63,6 +69,7 @@ export function useFlightsMapSearch( if (!cancelled) { setError(err); setRoutes([]); + setCompletedParams(requestParams); setLoading(false); } }); @@ -80,5 +87,5 @@ export function useFlightsMapSearch( refreshKey, ]); - return { routes, loading, error, refresh }; + return { routes, loading, error, completedParams, refresh }; } diff --git a/tests/e2e/flights-map.spec.ts b/tests/e2e/flights-map.spec.ts index f854a8b7..cbeb7c9b 100644 --- a/tests/e2e/flights-map.spec.ts +++ b/tests/e2e/flights-map.spec.ts @@ -49,6 +49,59 @@ async function routeFlightsMapTransferOnlyFixtures( ); } +async function routeFlightsMapDirectRouteFixtures( + page: import("@playwright/test").Page, +): Promise { + await routeAppSettingsFixture(page); + await routeDictionaryFixtures(page); + + const destinationRequests: string[] = []; + + await page.route("**/api/flights/1/*/destinations?**", async (route) => { + const url = new URL(route.request().url()); + destinationRequests.push(url.toString()); + const departure = url.searchParams.get("departure"); + const arrival = url.searchParams.get("arrival"); + const connections = url.searchParams.get("connections"); + + const isSelectedRoute = departure === "LED" && arrival === "MLE"; + const routes = + isSelectedRoute && connections === "0" + ? [{ route: ["LED", "MLE"], isDirect: true }] + : isSelectedRoute && connections === "1" + ? [{ route: ["LED", "SVO", "MLE"], isDirect: false }] + : []; + + if (isSelectedRoute && connections === "0") { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { routes } }), + }); + }); + + 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"), + }), + ); + + return destinationRequests; +} + async function selectCity( page: import("@playwright/test").Page, inputTestId: string, @@ -103,4 +156,44 @@ test.describe("Flights Map", () => { expect(consoleMessages).toEqual([]); }); + + test("transfer-only checkbox stays off while a direct route search is still loading", async ({ + page, + consoleMessages, + }) => { + const destinationRequests = await routeFlightsMapDirectRouteFixtures(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"); + await expect(transferToggle).toBeEnabled(); + await expect(transferToggle).not.toBeChecked(); + + await expect + .poll(() => + destinationRequests.some( + (url) => + url.includes("departure=LED") && + url.includes("arrival=MLE") && + url.includes("connections=0"), + ), + ) + .toBe(true); + await expect(transferToggle).not.toBeChecked(); + await page.waitForTimeout(500); + expect( + destinationRequests.some( + (url) => + url.includes("departure=LED") && + url.includes("arrival=MLE") && + url.includes("connections=1"), + ), + ).toBe(false); + + expect(consoleMessages).toEqual([]); + }); });