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:
2026-04-23 13:11:39 +03:00
parent ed3dc1053b
commit c6055d94ba
15 changed files with 395 additions and 82 deletions
@@ -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.
+2 -2
View File
@@ -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}"
}
}
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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}"
}
}
+2 -2
View File
@@ -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}"
}
}
+2 -2
View File
@@ -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}"
}
}
+2 -2
View File
@@ -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}"
}
}
+2 -2
View File
@@ -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}"
}
}
+3 -3
View File
@@ -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": "Регистрация",
+2 -2
View File
@@ -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}"
}
}
+21
View File
@@ -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;
}
+85 -13
View File
@@ -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);
});
}
});