From 2ba4c152e84f558814d0618b88d9fe68e8bcd7c5 Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 23 Apr 2026 15:33:17 +0300 Subject: [PATCH] =?UTF-8?q?Schedule=20details:=20gate=20=D0=9F=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20sub-icons=20on=20equipment.meal[]=20?= =?UTF-8?q?(Angular=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angular's flight-details-meal.component.html renders each Эконом / Комфорт / Бизнес sub-icon under *ngIf=hasEconomyMeal etc — flights with no meal data show just the cutlery icon and caption with no class pills. React was hardcoding all three regardless of data, so SU 6188 (whose API returns meal=[]) showed three meaningless icons; SU 6341 (meal=[Comfort, Economy, Business]) showed the right ones by accident. Read leg.equipment.meal, build a Set, render each pill only when its type is in the set. Add a unit test covering empty, partial, and full meal data and an e2e regression on the live MOW→LED→MMK itinerary (test asserts SU 6188 has none, SU 6341 has all three). The e2e depends on backend data and can flake when the dev proxy WAF cookie has expired. --- .../components/ScheduleLegDetails.test.tsx | 67 +++++++ .../components/ScheduleLegDetails.tsx | 181 ++++++++++++++++++ .../schedule-details-meal-sub-icons.spec.ts | 42 ++++ 3 files changed, 290 insertions(+) create mode 100644 src/features/schedule/components/ScheduleLegDetails.test.tsx create mode 100644 src/features/schedule/components/ScheduleLegDetails.tsx create mode 100644 tests/e2e/schedule-details-meal-sub-icons.spec.ts 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(); +});