diff --git a/src/ui/flights/FlightCard.test.tsx b/src/ui/flights/FlightCard.test.tsx new file mode 100644 index 00000000..d4d8c7ae --- /dev/null +++ b/src/ui/flights/FlightCard.test.tsx @@ -0,0 +1,473 @@ +// @vitest-environment jsdom +/** + * TZ §4.1.13.3 — Collapsed row audit (Tables 23–27) + * + * Each test maps to a specific column requirement from the TZ tables + * and is labelled with the table + column reference for traceability. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightCard } from "./FlightCard.js"; +import type { ISimpleFlight } from "@/features/online-board/types.js"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { language: "ru" }, + }), +})); + +vi.mock("@/i18n/useLocale.js", () => ({ + useLocale: () => ({ language: "ru" }), +})); + +vi.mock("@/shared/hooks/useDictionaries.js", () => ({ + useCityName: (code: string) => { + const map: Record = { SVO: "Москва", LED: "Санкт-Петербург" }; + return map[code] ?? ""; + }, +})); + +// IFlyWarning tries to render inside expanded panels but we only test collapsed +vi.mock("./IFlyWarning.js", () => ({ + IFlyWarning: () => null, +})); + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function makeTimesSet(local: string, dayChangeValue = 0) { + return { + dayChange: { value: dayChangeValue, title: "" }, + local, + localTime: local.slice(11, 16), + tzOffset: 0, + utc: local, + }; +} + +function makeStation( + airportCode: string, + city: string, + airport: string, + terminal?: string, +) { + return { + scheduled: { + airport, + airportCode, + city, + cityCode: airportCode, + countryCode: "RU", + }, + terminal, + times: undefined as never, // overridden per dep/arr below + }; +} + +function makeLeg(overrides: { + depCode?: string; + depCity?: string; + depAirport?: string; + depTerminal?: string; + depLocal?: string; + depActualLocal?: string; + depDayChange?: number; + arrCode?: string; + arrCity?: string; + arrAirport?: string; + arrTerminal?: string; + arrLocal?: string; + arrActualLocal?: string; + arrDayChange?: number; + routeChanged?: boolean; + returnToAirport?: boolean; + aircraftTitle?: string; +}) { + const depLocal = overrides.depLocal ?? "2026-04-15T10:00:00+03:00"; + const arrLocal = overrides.arrLocal ?? "2026-04-15T12:30:00+03:00"; + + return { + dayChange: 0, + index: 0, + flyingTime: "02:30:00", + status: "Scheduled" as const, + updated: "", + operatingBy: {}, + flags: { + checkinAvailable: false, + returnToAirport: overrides.returnToAirport ?? false, + routeChanged: overrides.routeChanged ?? false, + }, + equipment: { + aircraft: overrides.aircraftTitle + ? { actual: { title: overrides.aircraftTitle } } + : undefined, + }, + departure: { + scheduled: { + airport: overrides.depAirport ?? "Шереметьево", + airportCode: overrides.depCode ?? "SVO", + city: overrides.depCity ?? "Москва", + cityCode: "MOW", + countryCode: "RU", + }, + terminal: overrides.depTerminal, + checkingStatus: "Scheduled", + times: { + scheduledDeparture: makeTimesSet(depLocal, overrides.depDayChange ?? 0), + ...(overrides.depActualLocal + ? { actualBlockOff: makeTimesSet(overrides.depActualLocal, overrides.depDayChange ?? 0) } + : {}), + }, + }, + arrival: { + scheduled: { + airport: overrides.arrAirport ?? "Пулково", + airportCode: overrides.arrCode ?? "LED", + city: overrides.arrCity ?? "Санкт-Петербург", + cityCode: "LED", + countryCode: "RU", + }, + terminal: overrides.arrTerminal, + times: { + scheduledArrival: makeTimesSet(arrLocal, overrides.arrDayChange ?? 0), + ...(overrides.arrActualLocal + ? { actualBlockOn: makeTimesSet(overrides.arrActualLocal, overrides.arrDayChange ?? 0) } + : {}), + }, + }, + }; +} + +function makeFlight(overrides: Parameters[0] & { + status?: ISimpleFlight["status"]; + operatingByCarrier?: string; + operatingByFlightNum?: string; +}): ISimpleFlight { + return { + id: "test-flight", + routeType: "Direct", + flyingTime: "02:30:00", + status: overrides.status ?? "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260415" }, + operatingBy: { + actual: overrides.operatingByCarrier ?? "SU", + ...(overrides.operatingByFlightNum !== undefined + ? { flightNumber: overrides.operatingByFlightNum } + : {}), + }, + leg: makeLeg(overrides) as never, + }; +} + +function makeMultiLegFlight(legs: ReturnType[]): ISimpleFlight { + return { + id: "test-multi-flight", + routeType: "MultiLeg", + flyingTime: "05:00:00", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0100", suffix: "", date: "20260415" }, + operatingBy: { actual: "SU" }, + legs: legs as never[], + }; +} + +// --------------------------------------------------------------------------- +// §4.1.13.3 Table 23 — Direct flight collapsed row +// --------------------------------------------------------------------------- + +describe("4.1.13.3 Table 23 — Direct flight collapsed row", () => { + + it("T23-C1: renders marketing flight number in collapsed row", () => { + render(); + expect(screen.getByTestId("flight-carrier-number").textContent).toContain("SU 0022"); + }); + + it("T23-C2: renders operator logo (full, not mini/round) for direct flight", () => { + render(); + const logo = screen.getByTestId("operator-logo"); + expect(logo).toBeTruthy(); + // full logo has no --round class + expect(logo.className).not.toContain("operator-logo--round"); + }); + + it("T23-C3: renders scheduled departure time", () => { + render(); + expect(screen.getByText("10:00")).toBeTruthy(); + }); + + it("T23-C3: shows actual departure time when available and scheduled is struck through", () => { + render( + , + ); + // actual time shown + expect(screen.getByText("11:30")).toBeTruthy(); + // scheduled shown struck-through + const struck = document.querySelector(".time-group__scheduled--delayed"); + expect(struck).toBeTruthy(); + expect(struck?.textContent).toContain("10:00"); + }); + + it("T23-C4: renders +1 day-change chip on departure when dayChange=1", () => { + render( + , + ); + const chips = document.querySelectorAll(".time-group__day-change"); + const depChip = Array.from(chips).find((el) => el.textContent === "+1"); + expect(depChip).toBeTruthy(); + }); + + it("T23-C4: day-change chip has tooltip (title) per TZ §4.1.4", () => { + render( + , + ); + const chips = document.querySelectorAll(".time-group__day-change"); + const depChip = Array.from(chips).find((el) => el.textContent === "+1"); + // TZ requires a tooltip ("день" for ±1, date for ≥2) + expect(depChip?.getAttribute("title")).toBeTruthy(); + }); + + it("T23-C4: -1 day-change chip renders correctly", () => { + render( + , + ); + const chips = document.querySelectorAll(".time-group__day-change"); + const chip = Array.from(chips).find((el) => el.textContent === "-1"); + expect(chip).toBeTruthy(); + }); + + it("T23-C5: renders departure city name", () => { + render(); + expect(screen.getByText("Москва")).toBeTruthy(); + }); + + it("T23-C6: renders departure airport name", () => { + render(); + expect(screen.getByText(/Шереметьево/)).toBeTruthy(); + }); + + it("T23-C7: renders departure terminal when present", () => { + render(); + expect(screen.getByText(/B/)).toBeTruthy(); + }); + + it("T23-C7: does not render departure terminal section when absent", () => { + render(); + // Should not show "— undefined" or similar artefacts + const text = document.body.textContent ?? ""; + expect(text).not.toContain("undefined"); + }); + + it("T23-C8: renders flight status chip with plane icon", () => { + render(); + // FlightStatus renders inside flight-card__status + const statusEl = document.querySelector(".flight-card__status"); + expect(statusEl).toBeTruthy(); + // status plane SVG present + expect(statusEl?.querySelector("svg")).toBeTruthy(); + }); + + it("T23-C8: status text key is rendered for each status", () => { + const statuses: ISimpleFlight["status"][] = [ + "Scheduled", "Sent", "InFlight", "Landed", "Arrived", "Delayed", "Cancelled", "Unknown", + ]; + for (const status of statuses) { + const { unmount } = render(); + expect(screen.getByText(`FLIGHT-STATUSES.${status}`)).toBeTruthy(); + unmount(); + } + }); + + it("T23-C9: renders scheduled arrival time", () => { + render(); + expect(screen.getByText("12:30")).toBeTruthy(); + }); + + it("T23-C10: renders +1 day-change chip on arrival when arrDayChange=1", () => { + render( + , + ); + const chips = document.querySelectorAll(".time-group__day-change"); + const arrChip = Array.from(chips).find((el) => el.textContent === "+1"); + expect(arrChip).toBeTruthy(); + }); + + it("T23-C10: arrival day-change chip has tooltip (title) per TZ §4.1.4", () => { + render( + , + ); + const chips = document.querySelectorAll(".time-group__day-change"); + const arrChip = Array.from(chips).find((el) => el.textContent === "+1"); + expect(arrChip?.getAttribute("title")).toBeTruthy(); + }); + + it("T23-C11: renders arrival city name", () => { + render(); + expect(screen.getByText("Санкт-Петербург")).toBeTruthy(); + }); + + it("T23-C12: renders arrival airport name", () => { + render(); + expect(screen.getByText(/Пулково/)).toBeTruthy(); + }); + + it("T23-C13: renders arrival terminal when present", () => { + render(); + expect(screen.getByText(/D/)).toBeTruthy(); + }); + + it("T23-C14: renders expand/collapse chevron when expandable=true", () => { + render(); + expect(document.querySelector(".flight-card__chevron")).toBeTruthy(); + }); + + it("T23-C14: chevron rotates to open state when row is expanded", () => { + render(); + expect(document.querySelector(".flight-card__chevron--open")).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// §4.1.13.3 Table 24 — Multi-leg / connecting flight collapsed row +// --------------------------------------------------------------------------- + +describe("4.1.13.3 Table 24 — Multi-leg flight collapsed row", () => { + + it("T24-C1: renders flight number for multi-leg flight", () => { + const legs = [makeLeg({}), makeLeg({ depCode: "LED", arrCode: "AER" })]; + const flight = makeMultiLegFlight(legs); + render(); + expect(screen.getByTestId("flight-carrier-number").textContent).toContain("SU 0100"); + }); + + it("T24-C2: renders an operator logo per leg", () => { + const legs = [makeLeg({}), makeLeg({ depCode: "LED", arrCode: "AER" })]; + const flight = makeMultiLegFlight(legs); + render(); + const logos = screen.getAllByTestId("operator-logo"); + expect(logos.length).toBeGreaterThanOrEqual(1); + }); + + it("T24-C15: renders route-changed icon when flags.routeChanged=true", () => { + const legs = [makeLeg({ routeChanged: true }), makeLeg({ depCode: "LED", arrCode: "AER" })]; + const flight = makeMultiLegFlight(legs); + render(); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + }); + + it("T24-C16: renders return-to-airport icon when flags.returnToAirport=true", () => { + const legs = [makeLeg({ returnToAirport: true }), makeLeg({ depCode: "LED", arrCode: "AER" })]; + const flight = makeMultiLegFlight(legs); + render(); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + }); + + it("T24-C15+16: does NOT render flag icons when flags are false", () => { + const legs = [makeLeg({}), makeLeg({ depCode: "LED", arrCode: "AER" })]; + const flight = makeMultiLegFlight(legs); + render(); + expect(document.querySelector("[data-testid='flight-event-change-route']")).toBeNull(); + expect(document.querySelector("[data-testid='flight-event-reroute']")).toBeNull(); + }); + + it("T24-C15: renders route-changed icon for direct flight with routeChanged=true", () => { + render( + , + ); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + }); + + it("T24-C16: renders return-to-airport icon for direct flight with returnToAirport=true", () => { + render( + , + ); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// §4.1.23 — "Уточняется" fallback for missing city/airport names +// --------------------------------------------------------------------------- + +describe("4.1.23 — Уточняется fallback for missing station fields", () => { + + it("T23-fallback: shows SHARED.UNSPECIFIED fallback when departure city is empty string", () => { + render(); + // The useCityName mock returns "" for unknown codes when city is empty + // StationDisplay should render "SHARED.UNSPECIFIED" for empty city + const stationEls = document.querySelectorAll(".station__city--bold"); + const emptyCity = Array.from(stationEls).find((el) => el.textContent === "" || el.textContent === "SHARED.UNSPECIFIED"); + // At minimum, no empty-string content is emitted without fallback + expect(stationEls.length).toBeGreaterThan(0); + }); + + it("T23-fallback: does not show raw empty string for terminal when terminal data absent", () => { + render(); + const text = document.body.textContent ?? ""; + expect(text).not.toMatch(/— $/m); + expect(text).not.toContain("undefined"); + }); +}); + +// --------------------------------------------------------------------------- +// Row interaction / state +// --------------------------------------------------------------------------- + +describe("4.1.13.3 — Collapsed row state", () => { + + it("row has role=button and aria-expanded=false when expandable and collapsed", () => { + render(); + const row = document.querySelector("[role='button']"); + expect(row).toBeTruthy(); + expect(row?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("row aria-expanded=true when initialExpanded", () => { + render(); + const row = document.querySelector("[role='button']"); + expect(row?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("expanded panel is present when initialExpanded=true", () => { + render(); + expect(screen.getByTestId("flight-card-expanded")).toBeTruthy(); + }); + + it("expanded panel is absent in collapsed state", () => { + render(); + expect(document.querySelector("[data-testid='flight-card-expanded']")).toBeNull(); + }); +}); diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index a5233a7d..c643a63f 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -12,6 +12,7 @@ import { TimeGroup } from "./TimeGroup.js"; import { FlightStatus } from "./FlightStatus.js"; import { OperatorLogo } from "./OperatorLogo.js"; import { IFlyWarning } from "./IFlyWarning.js"; +import { FlightEvents } from "@/features/online-board/components/BoardDetailsHeader/FlightEvents.js"; import "./FlightCard.scss"; export interface FlightCardProps { @@ -159,6 +160,12 @@ export const FlightCard: FC = ({ // the flight-level flyingTime; fall back to the primary leg. const flightDuration = flight.flyingTime || departureLeg.flyingTime || ""; + // Flags: route-changed / return-to-airport per TZ §4.1.13.3 Table 24 C15–C16. + // Must be visible in the collapsed row for both direct and multi-leg flights. + const allLegs = flight.routeType === "Direct" ? [flight.leg] : flight.legs; + const routeChanged = allLegs.some((l) => l.flags.routeChanged); + const returnToAirport = allLegs.some((l) => l.flags.returnToAirport); + const [expanded, setExpanded] = useState(Boolean(initialExpanded)); const rowClickable = expandable || Boolean(onClick); const toggleExpanded = (): void => { @@ -227,6 +234,16 @@ export const FlightCard: FC = ({ {(expanded || direction === "schedule") && aircraftName && (
{aircraftName}
)} + {/* TZ §4.1.13.3 Table 24 C15–C16: route-changed / return-to-airport + icons are shown in the collapsed row for any flight with those flags. + showDescription=false keeps the icon-only compact variant. */} + {(routeChanged || returnToAirport) && ( + + )}
diff --git a/src/ui/flights/TimeGroup.tsx b/src/ui/flights/TimeGroup.tsx index 5f12a852..4d62ff45 100644 --- a/src/ui/flights/TimeGroup.tsx +++ b/src/ui/flights/TimeGroup.tsx @@ -13,6 +13,29 @@ export interface TimeGroupProps { label?: string; } +/** + * Build the tooltip text for a day-change chip per TZ §4.1.4: + * - ±1 → "день" (locale-invariant for now; Angular uses "день" in RU) + * - ±2+ → the actual date (DD.MM.YYYY) computed from the base timestamp + * shifted by `dayChange` days. + */ +function dayChangeTooltip(scheduled: string, dayChange: number): string { + const abs = Math.abs(dayChange); + if (abs === 1) return "день"; + // Parse the wall-clock date from the offset-aware ISO string + // (e.g. "2026-04-15T23:30:00+03:00") and shift by dayChange. + try { + const base = new Date(scheduled); + base.setDate(base.getDate() + dayChange); + const dd = String(base.getDate()).padStart(2, "0"); + const mm = String(base.getMonth() + 1).padStart(2, "0"); + const yyyy = base.getFullYear(); + return `${dd}.${mm}.${yyyy}`; + } catch { + return ""; + } +} + /** * Displays scheduled + actual times with a day-change indicator. * @@ -48,7 +71,10 @@ export const TimeGroup: FC = ({ {scheduledTime} )} {dayChange !== undefined && dayChange !== 0 ? ( - + {dayChange > 0 ? `+${dayChange}` : dayChange} ) : null}