Fix schedule aircraft link target

This commit is contained in:
2026-05-14 17:22:11 +03:00
parent b3d242e7e0
commit 6cf57596bf
4 changed files with 107 additions and 29 deletions
@@ -473,7 +473,10 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
<FlightCard flight={flight} direction="schedule" /> <FlightCard flight={flight} direction="schedule" />
<IFlyWarning flightNumber={flight.flightId.flightNumber} /> <IFlyWarning flightNumber={flight.flightId.flightNumber} />
{renderBody(flight)} {renderBody(flight)}
<ScheduleLegDetails flight={flight as unknown as ISimpleFlight} /> <ScheduleLegDetails
flight={flight as unknown as ISimpleFlight}
locale={locale}
/>
{/* Angular hides the weekly operating schedule on {/* Angular hides the weekly operating schedule on
multi-leg chains; keep it on direct flights. */} multi-leg chains; keep it on direct flights. */}
@@ -42,6 +42,30 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
} }
describe("ScheduleLegDetails Питание sub-icons", () => { describe("ScheduleLegDetails Питание sub-icons", () => {
it("links the aircraft title to Angular's Aeroflot plane park URL", () => {
render(<ScheduleLegDetails flight={makeFlight([])} locale="ru-ru" />);
const link = screen.getByRole("link", { name: "Sukhoi SuperJet 100" });
expect(link.getAttribute("href")).toBe(
"http://www.aeroflot.ru/cms/ru/flight/plane_park",
);
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
it("uses the language prefix from locale for the plane park URL", () => {
render(<ScheduleLegDetails flight={makeFlight([])} locale="en-us" />);
expect(
screen
.getByRole("link", { name: "Sukhoi SuperJet 100" })
.getAttribute("href"),
).toBe(
"http://www.aeroflot.ru/cms/en/flight/plane_park",
);
});
it("renders no meal-class sub-icons when equipment.meal is empty", () => { it("renders no meal-class sub-icons when equipment.meal is empty", () => {
render(<ScheduleLegDetails flight={makeFlight([])} />); render(<ScheduleLegDetails flight={makeFlight([])} />);
expect(screen.queryByText("FOOD.ECONOMY")).toBeNull(); expect(screen.queryByText("FOOD.ECONOMY")).toBeNull();
@@ -3,7 +3,7 @@
* each flight leg. Mirrors Angular's `flight-details-wrapper` accordion * each flight leg. Mirrors Angular's `flight-details-wrapper` accordion
* (schedule view): two rows when the data exists. * (schedule view): two rows when the data exists.
* *
* 1. Борт — aircraft type with a link to its Aeroflot info page. * 1. Борт — aircraft type with a link to Aeroflot's aircraft park.
* 2. Питание на борту — meal-class sub-icons (Эконом / Комфорт / * 2. Питание на борту — meal-class sub-icons (Эконом / Комфорт /
* Бизнес), each rendered ONLY when the API's * Бизнес), each rendered ONLY when the API's
* `equipment.meal[]` array contains the matching `type`. Matches * `equipment.meal[]` array contains the matching `type`. Matches
@@ -25,18 +25,18 @@ import "./ScheduleLegDetails.scss";
export interface ScheduleLegDetailsProps { export interface ScheduleLegDetailsProps {
flight: ISimpleFlight; flight: ISimpleFlight;
locale?: string;
} }
/** Slug used for the aircraft catalog deep-link on www.aeroflot.ru. */ function aircraftParkHref(locale: string | undefined): string {
function aircraftSlug(title: string | undefined | null): string | null { const language = locale?.split("-")[0] || "ru";
if (!title) return null; return `http://www.aeroflot.ru/cms/${language}/flight/plane_park`;
return title
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
} }
export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => { export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({
flight,
locale,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState<boolean>(true); const [expanded, setExpanded] = useState<boolean>(true);
@@ -47,13 +47,7 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
leg.equipment?.aircraft?.actual?.title ?? leg.equipment?.aircraft?.actual?.title ??
leg.equipment?.aircraft?.scheduled?.title ?? leg.equipment?.aircraft?.scheduled?.title ??
""; "";
const slug = aircraftSlug(aircraft); const planeUrl = aircraftParkHref(locale);
// External Aeroflot page that describes the aircraft model. Angular
// uses a curated slug map; the simple kebab-case works for common
// models (e.g. sukhoi-superjet-100).
const planeUrl = slug
? `https://www.aeroflot.ru/ru-ru/about/aircrafts/${slug}`
: null;
return ( return (
<div className="schedule-leg-details" data-testid="schedule-leg-details"> <div className="schedule-leg-details" data-testid="schedule-leg-details">
@@ -117,18 +111,14 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
<span className="schedule-leg-details__label"> <span className="schedule-leg-details__label">
{t("SHARED.PLANE")} {t("SHARED.PLANE")}
</span> </span>
{planeUrl ? ( <a
<a className="schedule-leg-details__link"
className="schedule-leg-details__link" href={planeUrl}
href={planeUrl} target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" >
> {aircraft}
{aircraft} </a>
</a>
) : (
<span className="schedule-leg-details__value">{aircraft}</span>
)}
</div> </div>
)} )}
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "./fixtures/console-gate";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Schedule details uses the same Angular aircraft-link behavior as
// online-board: the model text under "Борт" opens the generic plane park page.
const FIXTURE_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../fixtures/api",
);
const scheduleDetails = fs.readFileSync(
path.join(FIXTURE_DIR, "schedule-details.json"),
"utf8",
);
const URL =
"/ru-ru/schedule/VVO/SU5752-20260518/KJA/SU6837-20260519/MJZ?request=schedule-route-VVO-MJZ-20260518-20260524";
test("Schedule details aircraft title opens Aeroflot plane park in a new tab", async ({
page,
context,
consoleMessages,
}) => {
await page.route("**/api/flights/v1.1/ru/schedule/details?**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: scheduleDetails,
});
});
await context.route("http://www.aeroflot.ru/cms/ru/flight/plane_park", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><title>Plane park</title>",
});
});
await page.goto(URL);
const details = page.locator(".schedule-leg-details");
await expect(details).toHaveCount(1, { timeout: 15000 });
await expect(details.getByText("Борт", { exact: true })).toBeVisible();
const link = details.locator("a.schedule-leg-details__link");
await expect(link).toHaveText("Sukhoi SuperJet 100");
await expect(link).toHaveAttribute(
"href",
"http://www.aeroflot.ru/cms/ru/flight/plane_park",
);
await expect(link).toHaveAttribute("target", "_blank");
const popupPromise = page.waitForEvent("popup");
await link.click();
const popup = await popupPromise;
await expect(popup).toHaveURL("http://www.aeroflot.ru/cms/ru/flight/plane_park");
await popup.close();
});