Fix flights map transfer fallback timing

This commit is contained in:
2026-05-22 10:04:33 +03:00
parent ca9978f003
commit 1158673fdf
4 changed files with 162 additions and 8 deletions
@@ -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<FlightsMapSearchParams | null> = [];
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(<FlightsMapStartPage />);
const { rerender } = render(<FlightsMapStartPage />);
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(<FlightsMapStartPage />);
});
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(<FlightsMapStartPage />);
const { rerender } = render(<FlightsMapStartPage />);
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(<FlightsMapStartPage />);
});
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(<FlightsMapStartPage />);
const { rerender } = render(<FlightsMapStartPage />);
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(<FlightsMapStartPage />);
});
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(<FlightsMapStartPage />);
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);
});
});
// ---------------------------------------------------------------------------
@@ -220,12 +220,21 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
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<FlightsMapStartPageProps> = ({
error,
effectiveConnections,
connectionsFallbackSuppressed,
completedParams,
filterState.departure,
filterState.arrival,
routes,
searchParams,
]);
// Reflect fallback in the UI toggle once.
@@ -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<IFlightRoute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [completedParams, setCompletedParams] =
useState<FlightsMapSearchParams | null>(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 };
}
+93
View File
@@ -49,6 +49,59 @@ async function routeFlightsMapTransferOnlyFixtures(
);
}
async function routeFlightsMapDirectRouteFixtures(
page: import("@playwright/test").Page,
): Promise<string[]> {
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([]);
});
});