Fix flights map transfer fallback timing
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user