From c6055d94badff2c4b78cdaf8e0bfe56241e45e1c Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 23 Apr 2026 13:11:39 +0300 Subject: [PATCH] Add details-page breadcrumb leaf with Angular-correct labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../OnlineBoardDetailsPage.test.tsx | 132 +++++++++++++----- .../components/OnlineBoardDetailsPage.tsx | 89 +++++++++++- .../components/ScheduleDetailsPage.test.tsx | 19 ++- .../components/ScheduleDetailsPage.tsx | 80 ++++++++++- src/i18n/locales/de/common.json | 4 +- src/i18n/locales/en/common.json | 4 +- src/i18n/locales/es/common.json | 4 +- src/i18n/locales/fr/common.json | 4 +- src/i18n/locales/it/common.json | 4 +- src/i18n/locales/ja/common.json | 4 +- src/i18n/locales/ko/common.json | 4 +- src/i18n/locales/ru/common.json | 6 +- src/i18n/locales/zh/common.json | 4 +- src/shared/hooks/useDictionaries.ts | 21 +++ tests/e2e/breadcrumbs-parity.spec.ts | 98 +++++++++++-- 15 files changed, 395 insertions(+), 82 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx index f8b20634..a95f1052 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx @@ -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( - , - ); - assertTwoCrumbsNoLeaf(container); - }); - } + it("share-link (no ?request=) → only [Home, Онлайн-Табло], no leaf", () => { + mockSearchParamsInstance = new URLSearchParams(); + const { container } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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", () => { diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index a77ca2ff..7ee6c50f 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -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 = ({ // 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; diff --git a/src/features/schedule/components/ScheduleDetailsPage.test.tsx b/src/features/schedule/components/ScheduleDetailsPage.test.tsx index 139f0347..7645f1a8 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.test.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.test.tsx @@ -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", () => ({ }) => {children}, })); +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) => { @@ -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", () => { diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index 293e25d5..9dbb5e8a 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -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 = ({ 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. diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0b14254f..a5396ec5 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -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}" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 7e4768b0..08fc79c4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 3cb1e111..751c31a9 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -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}" } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 9b056f6c..f0d3d831 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -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}" } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index c23e0bac..f61cce22 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -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}" } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 9e61c3e7..5b6d90fe 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -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}" } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index d152d551..3cacab8b 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -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}" } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 1bc0be63..1841f56b 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -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": "Регистрация", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 2523ceed..98b39154 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -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}" } } diff --git a/src/shared/hooks/useDictionaries.ts b/src/shared/hooks/useDictionaries.ts index 6901f4f6..f80e39e7 100644 --- a/src/shared/hooks/useDictionaries.ts +++ b/src/shared/hooks/useDictionaries.ts @@ -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; +} diff --git a/tests/e2e/breadcrumbs-parity.spec.ts b/tests/e2e/breadcrumbs-parity.spec.ts index cd7fcf5b..9c06c0f6 100644 --- a/tests/e2e/breadcrumbs-parity.spec.ts +++ b/tests/e2e/breadcrumbs-parity.spec.ts @@ -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///?request=… -// → [Главная, Расписание рейсов] (no leaf even with ?request=) -// /onlineboard → [Главная] -// /onlineboard/route/... → [Главная, Онлайн-Табло] -// /onlineboard/-?request=… -// → [Главная, Онлайн-Табло] (no leaf even with ?request=) +// /schedule → [Главная] +// /schedule/route/... → [Главная, Расписание рейсов] +// /schedule/// → [Главная, Расписание рейсов] +// /schedule///?request=schedule-route-… +// → [Главная, Расписание рейсов, "{depCity} - {arrCity}"] +// (no "Маршрут:" prefix; leaf links back to /schedule/route/...) +// /onlineboard → [Главная] +// /onlineboard/route/... → [Главная, Онлайн-Табло] +// /onlineboard/- → [Главная, Онлайн-Табло] +// /onlineboard/-?request=onlineboard-flight-… +// → […, "Рейс: SU 6188"] (carrier and number space-separated) +// /onlineboard/-?request=onlineboard-route-… +// → […, "Маршрут: {depCity} - {arrCity}"] +// /onlineboard/-?request=onlineboard-departure--… +// → […, "Вылет: {airportName}"] (airport name, e.g. SVO → "Шереметьево") +// /onlineboard/-?request=onlineboard-arrival--… +// → […, "Прилет: {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); }); } });