diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx index cff3ff23..8d5af14e 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx @@ -387,6 +387,125 @@ describe("OnlineBoardDetailsPage", () => { }); }); + describe("TZ §4.1.4 Table 7 breadcrumb leaf (rows 6–8)", () => { + /** + * Helper: return all elements inside the breadcrumbs nav whose + * text content matches the given string. + */ + function getCrumbLink(container: HTMLElement, text: string): HTMLAnchorElement | null { + const nav = container.querySelector("[data-testid='breadcrumbs']"); + if (!nav) return null; + const anchors = Array.from(nav.querySelectorAll("a")); + return (anchors.find((a) => a.textContent?.trim() === text) as HTMLAnchorElement) ?? null; + } + + it("4.1.4-R-Crumb-flight: flight-mode details shows 'BREADCRUMBS.FLIGHT-NUMBER' as last clickable crumb pointing to flight search URL", () => { + mockSearchParamsInstance = new URLSearchParams( + "request=onlineboard-flight-SU1234-20260515", + ); + const { container } = render( + , + ); + // t mock returns key; leaf label key is BREADCRUMBS.FLIGHT-NUMBER + const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER"); + expect(link).toBeTruthy(); + // back URL: /ru/onlineboard/flight/SU1234-20260515 + expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/"); + expect(link?.getAttribute("href")).toContain("20260515"); + }); + + it("4.1.4-R-Crumb-route: route-mode details shows 'BREADCRUMBS.ROUTE' as last clickable crumb pointing to route search URL", () => { + mockSearchParamsInstance = new URLSearchParams( + "request=onlineboard-route-MOW-LED-20260515", + ); + const { container } = render( + , + ); + const link = getCrumbLink(container, "BREADCRUMBS.ROUTE"); + expect(link).toBeTruthy(); + expect(link?.getAttribute("href")).toContain("/ru/onlineboard/route/MOW-LED-20260515"); + }); + + it("4.1.4-R-Crumb-departure: departure-mode details shows 'BREADCRUMBS.DEPARTURE' as last clickable crumb pointing to departure search URL", () => { + 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("4.1.4-R-Crumb-arrival: arrival-mode details shows 'BREADCRUMBS.ARRIVAL' as last clickable crumb pointing to arrival search URL", () => { + 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"); + }); + + it("4.1.4-R-Crumb-share-link: details opened without ?request= (share link) shows only [Home, Онлайн-Табло] with no leaf", () => { + mockSearchParamsInstance = new URLSearchParams(); + const { container } = render( + , + ); + const nav = container.querySelector("[data-testid='breadcrumbs']"); + expect(nav).toBeTruthy(); + const items = nav!.querySelectorAll(".breadcrumbs__item"); + // Home + BOARD.TITLE = 2 items, no leaf + expect(items.length).toBe(2); + // The leaf keys must NOT appear + 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("4.1.4-R-Crumb-timerange: details opened from route with time range → back crumb URL includes time range suffix", () => { + // onlineboard-route-MOW-LED-20260515-09001800 (timeFrom=0900 timeTo=1800) + mockSearchParamsInstance = new URLSearchParams( + "request=onlineboard-route-MOW-LED-20260515-09001800", + ); + const { container } = render( + , + ); + const link = getCrumbLink(container, "BREADCRUMBS.ROUTE"); + expect(link).toBeTruthy(); + // URL should contain the time range suffix 09001800 + expect(link?.getAttribute("href")).toContain("09001800"); + }); + }); + describe("parent-request codec (TZ §4.1.2 Table 5 row 6)", () => { it("4.1.2-R-Request-route: hydrates mini-list from route parent-request", () => { mockSearchParamsInstance = new URLSearchParams( diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index c6936693..ae9ee1b2 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -414,6 +414,75 @@ export const OnlineBoardDetailsPage: FC = ({ return raw ? parseDetailsRequestParam(raw) : null; }, [searchParams]); + // TZ §4.1.4 Table 7 rows 6–8: build mode-specific leaf breadcrumb. + // The leaf is clickable and navigates back to the source search page + // with filter state (including time range) preserved. + // + // CONFLICT (Task 16 arbitration pending): TZ row 6 says departure/arrival + // leaf should show "{depCity}-{arrCity}" but parentRequest only carries one + // city (the station). We show only that city. See AGENTS.md Conflicts register. + const detailsCrumbs = useMemo(() => { + const baseCrumbs = [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }]; + if (!parentRequest) return baseCrumbs; + + const backUrl = (() => { + switch (parentRequest.kind) { + case "flight": { + // flightNumber in the request param is carrier+number (e.g. "SU1234"). + // Split into carrier and number for buildOnlineBoardUrl. + 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": { + // Format flight number with hyphen per TZ Table 7: "SU-1234" + 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: parentRequest.station }); + case "arrival": + return t("BREADCRUMBS.ARRIVAL", { city: parentRequest.station }); + case "route": + return t("BREADCRUMBS.ROUTE", { + departureCity: parentRequest.departure, + arrivalCity: parentRequest.arrival, + }); + } + })(); + + return [...baseCrumbs, { label: leafLabel, url: backUrl }]; + }, [parentRequest, locale, t]); + const parentParams = useMemo(() => { if (!parentRequest) return null; const d = parentRequest.date; @@ -464,13 +533,9 @@ export const OnlineBoardDetailsPage: FC = ({ [flightId.carrier, flightId.flightNumber, flightId.suffix, locale, navigate], ); - const onlineboardHref = `/${locale}/onlineboard`; - // Angular uses the 'Онлайн-Табло' title (BOARD.TITLE) as the sole - // breadcrumb leaf — the flight number itself is NOT a breadcrumb entry. - const rootBreadcrumbLabel = t("BOARD.TITLE"); const commonLayoutProps = { headerLeft: , - breadcrumbs: [{ label: rootBreadcrumbLabel, url: onlineboardHref }], + breadcrumbs: detailsCrumbs, }; if (loading) { @@ -531,7 +596,7 @@ export const OnlineBoardDetailsPage: FC = ({ } title={

{pageTitle}

} - breadcrumbs={[{ label: rootBreadcrumbLabel, url: onlineboardHref }]} + breadcrumbs={detailsCrumbs} contentLeft={ = ({ items = [] }) => {
    {allItems.map((item, index) => { const isLast = index === allItems.length - 1; - // The Home crumb (index 0) is always a link, even on start pages - // where it happens to be the only/last item — matches Angular, - // which always renders 'Главная' with the aeroflot.ru href. - const showAsLink = Boolean(item.url) && (index === 0 || !isLast); + // All items with a url render as links. This includes the last item + // (leaf crumb) when it represents a navigatable back-link, per TZ + // Table 7 row 6 note: the last breadcrumb on details pages must + // navigate back to the search results page with filter state preserved. + // Previously this was `index === 0 || !isLast`, which suppressed links + // on the last crumb — that was incorrect for details pages. + const showAsLink = Boolean(item.url); return (