diff --git a/src/features/schedule/components/ScheduleLegDetails.test.tsx b/src/features/schedule/components/ScheduleLegDetails.test.tsx new file mode 100644 index 00000000..2e989d46 --- /dev/null +++ b/src/features/schedule/components/ScheduleLegDetails.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ScheduleLegDetails } from "./ScheduleLegDetails.js"; +import type { ISimpleFlight, MealType } from "@/features/online-board/types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(meal: MealType[] | undefined): ISimpleFlight { + return { + routeType: "Direct", + id: "x", + flightId: { carrier: "SU", flightNumber: "6188", date: "20260426" } as never, + operatingBy: { scheduled: "SU" } as never, + flyingTime: "01:30:00", + status: "Scheduled" as never, + leg: { + index: 0, + dayChange: 0, + flyingTime: "01:30:00", + updated: "", + operatingBy: { scheduled: "SU" } as never, + status: "Scheduled" as never, + flags: {} as never, + departure: { + scheduled: { airport: "VKO", airportCode: "VKO", city: "Москва", cityCode: "MOW", countryCode: "RU" }, + times: { scheduledDeparture: { local: "2026-04-26T00:30:00+03:00", localTime: "00:30", tzOffset: 0, utc: "2026-04-25T21:30:00", dayChange: { value: 0 } as never } }, + } as never, + arrival: { + scheduled: { airport: "LED", airportCode: "LED", city: "Санкт-Петербург", cityCode: "LED", countryCode: "RU" }, + times: { scheduledArrival: { local: "2026-04-26T02:00:00+03:00", localTime: "02:00", tzOffset: 0, utc: "2026-04-25T23:00:00", dayChange: { value: 0 } as never } }, + } as never, + equipment: { + name: "Sukhoi SuperJet 100", + aircraft: { scheduled: { title: "Sukhoi SuperJet 100" } }, + ...(meal !== undefined ? { meal: meal.map((type) => ({ type })) } : {}), + } as never, + } as never, + } as unknown as ISimpleFlight; +} + +describe("ScheduleLegDetails Питание sub-icons", () => { + it("renders no meal-class sub-icons when equipment.meal is empty", () => { + render(); + expect(screen.queryByText("FOOD.ECONOMY")).toBeNull(); + expect(screen.queryByText("FOOD.COMFORT")).toBeNull(); + expect(screen.queryByText("FOOD.BUSINESS")).toBeNull(); + // The cutlery-row label still renders. + expect(screen.getByText("SHARED.FOOD")).toBeTruthy(); + }); + + it("renders only the meal classes the flight actually serves", () => { + render(); + expect(screen.getByText("FOOD.ECONOMY")).toBeTruthy(); + expect(screen.queryByText("FOOD.COMFORT")).toBeNull(); + expect(screen.getByText("FOOD.BUSINESS")).toBeTruthy(); + }); + + it("renders all three when the flight serves all three", () => { + render(); + expect(screen.getByText("FOOD.ECONOMY")).toBeTruthy(); + expect(screen.getByText("FOOD.COMFORT")).toBeTruthy(); + expect(screen.getByText("FOOD.BUSINESS")).toBeTruthy(); + }); +}); diff --git a/src/features/schedule/components/ScheduleLegDetails.tsx b/src/features/schedule/components/ScheduleLegDetails.tsx new file mode 100644 index 00000000..59105f46 --- /dev/null +++ b/src/features/schedule/components/ScheduleLegDetails.tsx @@ -0,0 +1,181 @@ +/** + * "Детали рейса" section shown inside the schedule details page for + * each flight leg. Mirrors Angular's `flight-details-wrapper` accordion + * (schedule view): two rows when the data exists. + * + * 1. Борт — aircraft type with a link to its Aeroflot info page. + * 2. Питание на борту — meal-class sub-icons (Эконом / Комфорт / + * Бизнес), each rendered ONLY when the API's + * `equipment.meal[]` array contains the matching `type`. Matches + * Angular's `*ngIf="hasEconomyMeal"` etc. — flights with no meal + * data show just the cutlery icon + caption with no sub-icons. + * + * Expanded by default, toggled via the header. + * + * @module + */ + +import { useState, type FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "@/features/online-board/types.js"; +import economIcon from "@/features/online-board/components/details-panels/icons/econom.svg"; +import comfortIcon from "@/features/online-board/components/details-panels/icons/comfort.svg"; +import businessIcon from "@/features/online-board/components/details-panels/icons/business.svg"; +import "./ScheduleLegDetails.scss"; + +export interface ScheduleLegDetailsProps { + flight: ISimpleFlight; +} + +/** Slug used for the aircraft catalog deep-link on www.aeroflot.ru. */ +function aircraftSlug(title: string | undefined | null): string | null { + if (!title) return null; + return title + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); +} + +export const ScheduleLegDetails: FC = ({ flight }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(true); + + const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + if (!leg) return null; + + const aircraft = + leg.equipment?.aircraft?.actual?.title ?? + leg.equipment?.aircraft?.scheduled?.title ?? + ""; + const slug = aircraftSlug(aircraft); + // 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 ( +
+ + + {expanded && ( +
+ {aircraft && ( +
+ + + {t("SHARED.PLANE")} + + {planeUrl ? ( + + {aircraft} + + ) : ( + {aircraft} + )} +
+ )} + + {/* Питание на борту — meal-class sub-icons gated by what the + API reports in `equipment.meal[]` (matches Angular's + *ngIf="hasEconomyMeal" etc). Schedule view also shows the + row when meal data is missing — just no sub-icons. */} + {(() => { + const meals = leg.equipment?.meal ?? []; + const types = new Set(meals.map((m) => m.type)); + return ( +
+ + + {t("SHARED.FOOD")} + +
+ {types.has("Economy") && ( + + )} + {types.has("Comfort") && ( + + )} + {types.has("Business") && ( + + )} +
+
+ ); + })()} +
+ )} +
+ ); +}; + +interface MealPillProps { + icon: "econom" | "comfort" | "business"; + labelKey: string; + t: (k: string) => string; +} + +const MEAL_ICONS: Record = { + econom: economIcon, + comfort: comfortIcon, + business: businessIcon, +}; + +const MealPill: FC = ({ icon, labelKey, t }) => ( + + + {t(labelKey)} + +); diff --git a/tests/e2e/schedule-details-meal-sub-icons.spec.ts b/tests/e2e/schedule-details-meal-sub-icons.spec.ts new file mode 100644 index 00000000..f8b88374 --- /dev/null +++ b/tests/e2e/schedule-details-meal-sub-icons.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; + +// Schedule Details "Питание на борту" must render meal-class sub-icons +// (Эконом класс / Комфорт класс / Бизнес класс) ONLY when the API +// returns the matching `equipment.meal[].type` for that flight. Empty +// meal[] → just the cutlery icon + caption with no sub-icons. Mirrors +// Angular's `*ngIf="hasEconomyMeal"` etc. in +// flight-details-meal.component.html. +// +// Reference URL covers a connecting itinerary where +// • SU 6188 returns meal=[] → no sub-icons +// • SU 6341 returns meal=[Comfort, Economy, Business] → all three + +const URL = + "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; + +test("Питание sub-icons appear only for legs whose API meal[] contains them", async ({ + page, +}) => { + await page.goto(URL); + + // Wait until both leg-details panels are mounted. + await expect(page.locator(".schedule-leg-details")).toHaveCount(2, { + timeout: 15000, + }); + + // ── Leg 1 (SU 6188, meal=[]) ───────────────────────────────────── + const leg1 = page.locator(".schedule-leg-details").nth(0); + await expect(leg1.getByText(/Sukhoi SuperJet/i)).toBeVisible(); + // Cutlery icon row + caption present. + await expect(leg1.getByText("Питание на борту")).toBeVisible(); + // No sub-icons. + await expect(leg1.getByText("Эконом класс")).toHaveCount(0); + await expect(leg1.getByText("Комфорт класс")).toHaveCount(0); + await expect(leg1.getByText("Бизнес класс")).toHaveCount(0); + + // ── Leg 2 (SU 6341, meal=[Comfort, Economy, Business]) ─────────── + const leg2 = page.locator(".schedule-leg-details").nth(1); + await expect(leg2.getByText("Эконом класс")).toBeVisible(); + await expect(leg2.getByText("Комфорт класс")).toBeVisible(); + await expect(leg2.getByText("Бизнес класс")).toBeVisible(); +});