Add details-page breadcrumb leaf with Angular-correct labels
Live audit shows Angular DOES add a third crumb on /onlineboard and /schedule details pages when the user reached them through ?request=: - onlineboard-flight → 'Рейс: SU 6188' (carrier+number space-separated) - onlineboard-route → 'Маршрут: Москва - Санкт-Петербург' - onlineboard-departure → 'Вылет: Шереметьево' (airport name when IATA is airport-only) - onlineboard-arrival → 'Прилет: Санкт-Петербург' (city name when IATA is also a city) - schedule-route → 'Москва - Санкт-Петербург' (no 'Маршрут:' prefix) Restore the leaf-emit logic, fix RU FLIGHT-NUMBER label to 'Рейс:', add spaces around the dash in ROUTE/SCHEDULE-ROUTE across all 9 locales, and add useStationDisplayName (city dict first, airport dict fallback — no parent-city escalation, matches Angular's getCityOrAirport).
This commit is contained in:
@@ -127,6 +127,11 @@ vi.mock("@modern-js/runtime/router", () => ({
|
||||
useSearchParams: () => [mockSearchParamsInstance],
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
|
||||
useCityName: (code: string) => code,
|
||||
useStationDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
const mockUseOnlineBoard = vi.fn((_params: SearchFlightsParams) => ({
|
||||
flights: [],
|
||||
loading: false,
|
||||
@@ -392,41 +397,104 @@ describe("OnlineBoardDetailsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Breadcrumb trail (Angular parity, never appends a leaf)", () => {
|
||||
function assertTwoCrumbsNoLeaf(container: HTMLElement) {
|
||||
describe("Breadcrumb trail (Angular parity)", () => {
|
||||
function getCrumbLink(container: HTMLElement, key: string): HTMLAnchorElement | null {
|
||||
const nav = container.querySelector("[data-testid='breadcrumbs']");
|
||||
expect(nav).toBeTruthy();
|
||||
const items = nav!.querySelectorAll(".breadcrumbs__item");
|
||||
// Home + BOARD.TITLE only — Angular's trail stops here regardless
|
||||
// of how the user reached the details page.
|
||||
expect(items.length).toBe(2);
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.ROUTE");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.DEPARTURE");
|
||||
expect(nav!.textContent).not.toContain("BREADCRUMBS.ARRIVAL");
|
||||
if (!nav) return null;
|
||||
const anchors = Array.from(nav.querySelectorAll("a"));
|
||||
return (anchors.find((a) => a.textContent?.includes(key)) as HTMLAnchorElement) ?? null;
|
||||
}
|
||||
function countItems(container: HTMLElement) {
|
||||
return container.querySelectorAll("[data-testid='breadcrumbs'] .breadcrumbs__item").length;
|
||||
}
|
||||
|
||||
const requestSamples = [
|
||||
"request=onlineboard-flight-SU1234-20260515",
|
||||
"request=onlineboard-route-MOW-LED-20260515",
|
||||
"request=onlineboard-route-MOW-LED-20260515-09001800",
|
||||
"request=onlineboard-departure-SVO-20260515",
|
||||
"request=onlineboard-arrival-SVO-20260515",
|
||||
"",
|
||||
];
|
||||
for (const qs of requestSamples) {
|
||||
it(`renders only [Home, Онлайн-Табло] when search params are: ${qs || "(empty)"}`, () => {
|
||||
mockSearchParamsInstance = new URLSearchParams(qs);
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
assertTwoCrumbsNoLeaf(container);
|
||||
});
|
||||
}
|
||||
it("share-link (no ?request=) → only [Home, Онлайн-Табло], no leaf", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams();
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
expect(countItems(container)).toBe(2);
|
||||
const nav = container.querySelector("[data-testid='breadcrumbs']")!;
|
||||
expect(nav.textContent).not.toContain("BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(nav.textContent).not.toContain("BREADCRUMBS.ROUTE");
|
||||
expect(nav.textContent).not.toContain("BREADCRUMBS.DEPARTURE");
|
||||
expect(nav.textContent).not.toContain("BREADCRUMBS.ARRIVAL");
|
||||
});
|
||||
|
||||
it("flight context → leaf 'Рейс: …' linking back to /flight/", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-flight-SU1234-20260515");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
expect(countItems(container)).toBe(3);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU1234-20260515");
|
||||
});
|
||||
|
||||
it("route context → leaf 'Маршрут: …' linking back to /route/", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
expect(countItems(container)).toBe(3);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/route/MOW-LED-20260515");
|
||||
});
|
||||
|
||||
it("route context with time range → back URL keeps time range suffix", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515-09001800");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
|
||||
expect(link?.getAttribute("href")).toContain("09001800");
|
||||
});
|
||||
|
||||
it("departure context → leaf 'Вылет: …' linking back to /departure/", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-departure-SVO-20260515");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.DEPARTURE");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/departure/SVO-20260515");
|
||||
});
|
||||
|
||||
it("arrival context → leaf 'Прилет: …' linking back to /arrival/", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-arrival-SVO-20260515");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.ARRIVAL");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/arrival/SVO-20260515");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TZ §4.1.15.8 timeline status center-class per status", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||
import { buildFlightJsonLd } from "../json-ld.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import { useCityName, useStationDisplayName } from "@/shared/hooks/useDictionaries.js";
|
||||
import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js";
|
||||
import { FlightsMiniList } from "./FlightsMiniList/index.js";
|
||||
import { DayTabs } from "./DayTabs/index.js";
|
||||
@@ -417,13 +418,87 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
// The leaf is clickable and navigates back to the source search page
|
||||
// with filter state (including time range) preserved.
|
||||
//
|
||||
// Angular stops the trail at 'Онлайн-Табло' (links to /onlineboard) —
|
||||
// never adds a leaf crumb for the flight or station. The page heading
|
||||
// already shows the flight number / city.
|
||||
const detailsCrumbs = useMemo(
|
||||
() => [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }],
|
||||
[locale, t],
|
||||
);
|
||||
// Resolve the IATA codes carried in `?request=` to display names.
|
||||
// Angular's onlineboard departure/arrival leaf shows the airport name
|
||||
// when the URL station is an airport IATA (SVO → "Шереметьево") and
|
||||
// falls back to city otherwise. Route leaf shows the city name.
|
||||
// Hooks must be called unconditionally; safe defaults when no request.
|
||||
const requestStation =
|
||||
parentRequest && parentRequest.area === "onlineboard" &&
|
||||
(parentRequest.kind === "departure" || parentRequest.kind === "arrival")
|
||||
? parentRequest.station
|
||||
: "";
|
||||
const requestRouteDep =
|
||||
parentRequest && parentRequest.area === "onlineboard" && parentRequest.kind === "route"
|
||||
? parentRequest.departure
|
||||
: "";
|
||||
const requestRouteArr =
|
||||
parentRequest && parentRequest.area === "onlineboard" && parentRequest.kind === "route"
|
||||
? parentRequest.arrival
|
||||
: "";
|
||||
const stationDisplay = useStationDisplayName(requestStation);
|
||||
const routeDepCity = useCityName(requestRouteDep);
|
||||
const routeArrCity = useCityName(requestRouteArr);
|
||||
|
||||
const detailsCrumbs = useMemo(() => {
|
||||
const baseCrumbs = [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }];
|
||||
if (!parentRequest || parentRequest.area !== "onlineboard") return baseCrumbs;
|
||||
|
||||
const backUrl = (() => {
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
if (!m || !m[1] || !m[2]) return `/${locale}/onlineboard`;
|
||||
return `/${locale}/${buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: m[1],
|
||||
flightNumber: m[2],
|
||||
date: parentRequest.date,
|
||||
})}`;
|
||||
}
|
||||
case "departure":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "departure", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "departure", station: parentRequest.station, date: parentRequest.date },
|
||||
)}`;
|
||||
case "arrival":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "arrival", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "arrival", station: parentRequest.station, date: parentRequest.date },
|
||||
)}`;
|
||||
case "route":
|
||||
return `/${locale}/${buildOnlineBoardUrl(
|
||||
parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date },
|
||||
)}`;
|
||||
}
|
||||
})();
|
||||
|
||||
const leafLabel = (() => {
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
// Angular renders "Рейс: SU 6188" — carrier and number space-separated
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
const formatted = m?.[1] && m?.[2] ? `${m[1]} ${m[2]}` : parentRequest.flightNumber;
|
||||
return t("BREADCRUMBS.FLIGHT-NUMBER", { flightNumber: formatted });
|
||||
}
|
||||
case "departure":
|
||||
return t("BREADCRUMBS.DEPARTURE", { city: stationDisplay });
|
||||
case "arrival":
|
||||
return t("BREADCRUMBS.ARRIVAL", { city: stationDisplay });
|
||||
case "route":
|
||||
return t("BREADCRUMBS.ROUTE", {
|
||||
departureCity: routeDepCity,
|
||||
arrivalCity: routeArrCity,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return [...baseCrumbs, { label: leafLabel, url: backUrl }];
|
||||
}, [parentRequest, locale, t, stationDisplay, routeDepCity, routeArrCity]);
|
||||
|
||||
const parentParams = useMemo(() => {
|
||||
if (!parentRequest || parentRequest.area !== "onlineboard") return null;
|
||||
|
||||
@@ -22,6 +22,7 @@ let mockSearchParamsGet: (key: string) => string | null = () => null;
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useSearchParams: () => [{ get: (k: string) => mockSearchParamsGet(k) }],
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ lang: "ru-ru" }),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
@@ -33,6 +34,11 @@ vi.mock("@modern-js/runtime/router", () => ({
|
||||
}) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
|
||||
useCityName: (code: string) => code,
|
||||
useStationDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) => {
|
||||
@@ -196,7 +202,7 @@ describe("ScheduleDetailsPage breadcrumbs", () => {
|
||||
expect(screen.queryByTestId("crumb-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never appends a leaf crumb even when ?request= carries schedule area (Angular parity)", () => {
|
||||
it("appends a route leaf crumb when ?request= carries schedule area (Angular parity)", () => {
|
||||
mockSearchParamsGet = (k) =>
|
||||
k === "request"
|
||||
? "schedule-route-NBC-KHV-20220307-20220313"
|
||||
@@ -211,10 +217,15 @@ describe("ScheduleDetailsPage breadcrumbs", () => {
|
||||
);
|
||||
|
||||
const crumb0 = screen.getByTestId("crumb-0");
|
||||
const crumb1 = screen.getByTestId("crumb-1");
|
||||
expect(crumb0.textContent).toContain("SCHEDULE.TITLE");
|
||||
// Angular's trail stops at 'Расписание рейсов' — no third crumb,
|
||||
// even when a request context is available.
|
||||
expect(screen.queryByTestId("crumb-1")).toBeNull();
|
||||
// Leaf label key is BREADCRUMBS.SCHEDULE-ROUTE — Angular shows
|
||||
// "{depCity} - {arrCity}" (no "Маршрут:" prefix on schedule details).
|
||||
expect(crumb1.textContent).toContain("BREADCRUMBS.SCHEDULE-ROUTE");
|
||||
const backUrl = crumb1.getAttribute("data-url") ?? "";
|
||||
expect(backUrl).toContain("/schedule/");
|
||||
expect(backUrl).toContain("NBC");
|
||||
expect(backUrl).toContain("KHV");
|
||||
});
|
||||
|
||||
it("ignores ?request= that carries onlineboard area", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
|
||||
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
||||
import { extractSimpleFlights } from "../extractSimpleFlights.js";
|
||||
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
|
||||
import { useCityName } from "@/shared/hooks/useDictionaries.js";
|
||||
import { buildScheduleDetailsSeo } from "../seo.js";
|
||||
import { buildScheduleFlightJsonLd } from "../json-ld.js";
|
||||
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
|
||||
@@ -164,13 +165,78 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
flights,
|
||||
]);
|
||||
|
||||
// Angular stops the trail at 'Расписание рейсов' (links to /schedule)
|
||||
// — never adds the route name as a third crumb. The page heading
|
||||
// already shows "{depCity} - {arrCity}".
|
||||
const breadcrumbs = useMemo(
|
||||
() => [{ label: t("SCHEDULE.TITLE"), url: scheduleHref }],
|
||||
[scheduleHref, t],
|
||||
);
|
||||
// Angular's schedule details page adds a third crumb only when the
|
||||
// user reached the page from a search context (?request=schedule-…).
|
||||
// The leaf reads "{depCity} - {arrCity}" (no "Маршрут:" prefix —
|
||||
// unlike the onlineboard variant) and links back to the route search
|
||||
// URL with the original date range / filter state preserved.
|
||||
const requestDep =
|
||||
parentRequest && parentRequest.area === "schedule" ? parentRequest.departure : "";
|
||||
const requestArr =
|
||||
parentRequest && parentRequest.area === "schedule" ? parentRequest.arrival : "";
|
||||
const requestDepCity = useCityName(requestDep);
|
||||
const requestArrCity = useCityName(requestArr);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
const baseCrumbs = [{ label: t("SCHEDULE.TITLE"), url: scheduleHref }];
|
||||
if (!parentRequest || parentRequest.area !== "schedule") return baseCrumbs;
|
||||
|
||||
const backUrl = `/${locale}/${buildScheduleUrl(
|
||||
parentRequest.returnTrip
|
||||
? {
|
||||
type: "roundtrip",
|
||||
outbound: {
|
||||
departure: parentRequest.departure,
|
||||
arrival: parentRequest.arrival,
|
||||
dateFrom: parentRequest.dateFrom,
|
||||
dateTo: parentRequest.dateTo,
|
||||
...(parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: {}),
|
||||
...(parentRequest.connections !== undefined
|
||||
? { connections: parentRequest.connections }
|
||||
: {}),
|
||||
},
|
||||
inbound: {
|
||||
departure: parentRequest.returnTrip.departure,
|
||||
arrival: parentRequest.returnTrip.arrival,
|
||||
dateFrom: parentRequest.returnTrip.dateFrom,
|
||||
dateTo: parentRequest.returnTrip.dateTo,
|
||||
...(parentRequest.returnTrip.timeFrom && parentRequest.returnTrip.timeTo
|
||||
? {
|
||||
timeFrom: parentRequest.returnTrip.timeFrom,
|
||||
timeTo: parentRequest.returnTrip.timeTo,
|
||||
}
|
||||
: {}),
|
||||
...(parentRequest.returnTrip.connections !== undefined
|
||||
? { connections: parentRequest.returnTrip.connections }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: "route",
|
||||
outbound: {
|
||||
departure: parentRequest.departure,
|
||||
arrival: parentRequest.arrival,
|
||||
dateFrom: parentRequest.dateFrom,
|
||||
dateTo: parentRequest.dateTo,
|
||||
...(parentRequest.timeFrom && parentRequest.timeTo
|
||||
? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
|
||||
: {}),
|
||||
...(parentRequest.connections !== undefined
|
||||
? { connections: parentRequest.connections }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
)}`;
|
||||
|
||||
const leafLabel = t("BREADCRUMBS.SCHEDULE-ROUTE", {
|
||||
departureCity: requestDepCity,
|
||||
arrivalCity: requestArrCity,
|
||||
});
|
||||
|
||||
return [...baseCrumbs, { label: leafLabel, url: backUrl }];
|
||||
}, [parentRequest, locale, scheduleHref, t, requestDepCity, requestArrCity]);
|
||||
|
||||
/**
|
||||
* TZ §4.1.16.3 R22-R28: navigate to a new day for the same flight.
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "Flug: {flightNumber}",
|
||||
"DEPARTURE": "Abflug: {city}",
|
||||
"ARRIVAL": "Ankunft: {city}",
|
||||
"ROUTE": "Strecke: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Strecke: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
"FLIGHT-NUMBER": "Flight: {flightNumber}",
|
||||
"DEPARTURE": "Departure: {city}",
|
||||
"ARRIVAL": "Arrival: {city}",
|
||||
"ROUTE": "Route: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Route: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
},
|
||||
"DETAILS": {
|
||||
"REGISTRATION": "Check-in",
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "Vuelo: {flightNumber}",
|
||||
"DEPARTURE": "Salida: {city}",
|
||||
"ARRIVAL": "Llegada: {city}",
|
||||
"ROUTE": "Ruta: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Ruta: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "Vol: {flightNumber}",
|
||||
"DEPARTURE": "Départ: {city}",
|
||||
"ARRIVAL": "Arrivée: {city}",
|
||||
"ROUTE": "Itinéraire: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Itinéraire: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "Volo: {flightNumber}",
|
||||
"DEPARTURE": "Partenza: {city}",
|
||||
"ARRIVAL": "Arrivo: {city}",
|
||||
"ROUTE": "Rotta: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Rotta: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "便: {flightNumber}",
|
||||
"DEPARTURE": "出発: {city}",
|
||||
"ARRIVAL": "到着: {city}",
|
||||
"ROUTE": "ルート: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "ルート: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "편: {flightNumber}",
|
||||
"DEPARTURE": "출발: {city}",
|
||||
"ARRIVAL": "도착: {city}",
|
||||
"ROUTE": "노선: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "노선: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
"ONLINEBOARD": "Онлайн-табло",
|
||||
"FLIGHT-NUMBER": "Номер рейса: {flightNumber}",
|
||||
"FLIGHT-NUMBER": "Рейс: {flightNumber}",
|
||||
"DEPARTURE": "Вылет: {city}",
|
||||
"ARRIVAL": "Прилет: {city}",
|
||||
"ROUTE": "Маршрут: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "Маршрут: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
},
|
||||
"DETAILS": {
|
||||
"REGISTRATION": "Регистрация",
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"FLIGHT-NUMBER": "航班: {flightNumber}",
|
||||
"DEPARTURE": "出发: {city}",
|
||||
"ARRIVAL": "到达: {city}",
|
||||
"ROUTE": "航线: {departureCity}-{arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
|
||||
"ROUTE": "航线: {departureCity} - {arrivalCity}",
|
||||
"SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,3 +38,24 @@ export function useCityName(code: string): string {
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a station IATA to its display name following Angular's
|
||||
* `getCityOrAirport` rule: city dictionary first (so true city codes
|
||||
* like LED → "Санкт-Петербург", MOW → "Москва" win), and only when
|
||||
* the code isn't a city does it fall through to the airport dictionary
|
||||
* (SVO → "Шереметьево"). Crucially this does NOT escalate from an
|
||||
* airport up to its parent city — used by the Onlineboard breadcrumb
|
||||
* leaf for departure/arrival URLs.
|
||||
*/
|
||||
export function useStationDisplayName(code: string): string {
|
||||
const { language } = useLocale();
|
||||
const { dictionaries } = useDictionariesState(language);
|
||||
if (!code || !dictionaries) return code;
|
||||
const upper = code.toUpperCase();
|
||||
const city = dictionaries.cityByCode.get(upper);
|
||||
if (city) return city.name;
|
||||
const airport = dictionaries.airportByCode.get(upper);
|
||||
if (airport) return airport.name;
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// Angular's breadcrumb trail (audited live on flights.test.aeroflot.ru):
|
||||
// /schedule → [Главная]
|
||||
// /schedule/route/... → [Главная, Расписание рейсов]
|
||||
// /schedule/<dep>/<flt>/<arr>?request=…
|
||||
// → [Главная, Расписание рейсов] (no leaf even with ?request=)
|
||||
// /onlineboard → [Главная]
|
||||
// /onlineboard/route/... → [Главная, Онлайн-Табло]
|
||||
// /onlineboard/<flt>-<date>?request=…
|
||||
// → [Главная, Онлайн-Табло] (no leaf even with ?request=)
|
||||
// /schedule → [Главная]
|
||||
// /schedule/route/... → [Главная, Расписание рейсов]
|
||||
// /schedule/<dep>/<flt>/<arr> → [Главная, Расписание рейсов]
|
||||
// /schedule/<dep>/<flt>/<arr>?request=schedule-route-…
|
||||
// → [Главная, Расписание рейсов, "{depCity} - {arrCity}"]
|
||||
// (no "Маршрут:" prefix; leaf links back to /schedule/route/...)
|
||||
// /onlineboard → [Главная]
|
||||
// /onlineboard/route/... → [Главная, Онлайн-Табло]
|
||||
// /onlineboard/<flt>-<date> → [Главная, Онлайн-Табло]
|
||||
// /onlineboard/<flt>-<date>?request=onlineboard-flight-…
|
||||
// → […, "Рейс: SU 6188"] (carrier and number space-separated)
|
||||
// /onlineboard/<flt>-<date>?request=onlineboard-route-…
|
||||
// → […, "Маршрут: {depCity} - {arrCity}"]
|
||||
// /onlineboard/<flt>-<date>?request=onlineboard-departure-<airportIATA>-…
|
||||
// → […, "Вылет: {airportName}"] (airport name, e.g. SVO → "Шереметьево")
|
||||
// /onlineboard/<flt>-<date>?request=onlineboard-arrival-<cityIATA>-…
|
||||
// → […, "Прилет: {cityName}"]
|
||||
|
||||
async function readCrumbs(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
@@ -40,11 +49,23 @@ const cases: { name: string; url: string; expected: { text: string; href: string
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Schedule details page (with ?request= context)",
|
||||
name: "Schedule details page (share-link, no ?request=)",
|
||||
url: "/ru-ru/schedule/SVO/SU6951-20260427/LED",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Расписание рейсов", href: "/ru-ru/schedule" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Schedule details page (with ?request=schedule-route)",
|
||||
url: "/ru-ru/schedule/SVO/SU6951-20260427/LED?request=schedule-route-MOW-LED-20260427-20260503",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Расписание рейсов", href: "/ru-ru/schedule" },
|
||||
{
|
||||
text: "Москва - Санкт-Петербург",
|
||||
href: "/ru-ru/schedule/route/MOW-LED-20260427-20260503",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -63,13 +84,61 @@ const cases: { name: string; url: string; expected: { text: string; href: string
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Onlineboard details page (with ?request= context)",
|
||||
url: "/ru-ru/onlineboard/SU0006-20260423?request=onlineboard-flight-SU0006-20260423",
|
||||
name: "Onlineboard details page (share-link, no ?request=)",
|
||||
url: "/ru-ru/onlineboard/SU0006-20260423",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Onlineboard details page (with ?request=onlineboard-flight)",
|
||||
url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-flight-SU6188-20260423",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
|
||||
{
|
||||
text: "Рейс: SU 6188",
|
||||
href: "/ru-ru/onlineboard/flight/SU6188-20260423",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Onlineboard details page (with ?request=onlineboard-route)",
|
||||
url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-route-MOW-LED-20260423",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
|
||||
{
|
||||
text: "Маршрут: Москва - Санкт-Петербург",
|
||||
href: "/ru-ru/onlineboard/route/MOW-LED-20260423",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Onlineboard details page (with ?request=onlineboard-departure, airport IATA)",
|
||||
url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-departure-SVO-20260423",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
|
||||
{
|
||||
text: "Вылет: Шереметьево",
|
||||
href: "/ru-ru/onlineboard/departure/SVO-20260423",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Onlineboard details page (with ?request=onlineboard-arrival, city IATA)",
|
||||
url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-arrival-LED-20260423",
|
||||
expected: [
|
||||
{ text: "Главная", href: "https://www.aeroflot.ru" },
|
||||
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
|
||||
{
|
||||
text: "Прилет: Санкт-Петербург",
|
||||
href: "/ru-ru/onlineboard/arrival/LED-20260423",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Breadcrumb parity with Angular", () => {
|
||||
@@ -77,8 +146,11 @@ test.describe("Breadcrumb parity with Angular", () => {
|
||||
test(c.name, async ({ page }) => {
|
||||
await page.goto(c.url);
|
||||
await expect(page.getByTestId("breadcrumbs")).toBeVisible({ timeout: 15000 });
|
||||
const items = await readCrumbs(page);
|
||||
expect(items).toEqual(c.expected);
|
||||
// Poll on the full items array — the leaf depends on dictionaries
|
||||
// fetched asynchronously, so labels arrive after the initial paint.
|
||||
await expect
|
||||
.poll(async () => readCrumbs(page), { timeout: 15000 })
|
||||
.toEqual(c.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user