Files
flights_web/src/ui/flights/FlightCard.test.tsx
T

474 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* TZ §4.1.13.3 — Collapsed row audit (Tables 2327)
*
* 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<string, string> = { 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<typeof makeLeg>[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<typeof makeLeg>[]): 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(<FlightCard flight={makeFlight({})} expandable />);
expect(screen.getByTestId("flight-carrier-number").textContent).toContain("SU 0022");
});
it("T23-C2: renders operator logo (full, not mini/round) for direct flight", () => {
render(<FlightCard flight={makeFlight({})} expandable />);
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(<FlightCard flight={makeFlight({ depLocal: "2026-04-15T10:00:00+03:00" })} expandable />);
expect(screen.getByText("10:00")).toBeTruthy();
});
it("T23-C3: shows actual departure time when available and scheduled is struck through", () => {
render(
<FlightCard
flight={makeFlight({
depLocal: "2026-04-15T10:00:00+03:00",
depActualLocal: "2026-04-15T11:30:00+03:00",
})}
expandable
/>,
);
// 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(
<FlightCard
flight={makeFlight({ depDayChange: 1 })}
expandable
/>,
);
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(
<FlightCard
flight={makeFlight({ depDayChange: 1 })}
expandable
/>,
);
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(
<FlightCard
flight={makeFlight({ depDayChange: -1 })}
expandable
/>,
);
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(<FlightCard flight={makeFlight({ depCity: "Москва" })} expandable />);
expect(screen.getByText("Москва")).toBeTruthy();
});
it("T23-C6: renders departure airport name", () => {
render(<FlightCard flight={makeFlight({ depAirport: "Шереметьево" })} expandable />);
expect(screen.getByText(/Шереметьево/)).toBeTruthy();
});
it("T23-C7: renders departure terminal when present", () => {
render(<FlightCard flight={makeFlight({ depTerminal: "B" })} expandable />);
expect(screen.getByText(/B/)).toBeTruthy();
});
it("T23-C7: does not render departure terminal section when absent", () => {
render(<FlightCard flight={makeFlight({})} expandable />);
// 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(<FlightCard flight={makeFlight({ status: "Delayed" })} expandable />);
// 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(<FlightCard flight={makeFlight({ status })} expandable />);
expect(screen.getByText(`FLIGHT-STATUSES.${status}`)).toBeTruthy();
unmount();
}
});
it("T23-C9: renders scheduled arrival time", () => {
render(<FlightCard flight={makeFlight({ arrLocal: "2026-04-15T12:30:00+03:00" })} expandable />);
expect(screen.getByText("12:30")).toBeTruthy();
});
it("T23-C10: renders +1 day-change chip on arrival when arrDayChange=1", () => {
render(
<FlightCard
flight={makeFlight({ arrDayChange: 1 })}
expandable
/>,
);
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(
<FlightCard
flight={makeFlight({ arrDayChange: 1 })}
expandable
/>,
);
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(<FlightCard flight={makeFlight({ arrCity: "Санкт-Петербург" })} expandable />);
expect(screen.getByText("Санкт-Петербург")).toBeTruthy();
});
it("T23-C12: renders arrival airport name", () => {
render(<FlightCard flight={makeFlight({ arrAirport: "Пулково" })} expandable />);
expect(screen.getByText(/Пулково/)).toBeTruthy();
});
it("T23-C13: renders arrival terminal when present", () => {
render(<FlightCard flight={makeFlight({ arrTerminal: "D" })} expandable />);
expect(screen.getByText(/D/)).toBeTruthy();
});
it("T23-C14: renders expand/collapse chevron when expandable=true", () => {
render(<FlightCard flight={makeFlight({})} expandable />);
expect(document.querySelector(".flight-card__chevron")).toBeTruthy();
});
it("T23-C14: chevron rotates to open state when row is expanded", () => {
render(<FlightCard flight={makeFlight({})} expandable initialExpanded />);
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(<FlightCard flight={flight} expandable />);
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(<FlightCard flight={flight} expandable />);
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(<FlightCard flight={flight} expandable />);
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(<FlightCard flight={flight} expandable />);
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(<FlightCard flight={flight} expandable />);
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(
<FlightCard
flight={makeFlight({ routeChanged: true })}
expandable
/>,
);
expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
});
it("T24-C16: renders return-to-airport icon for direct flight with returnToAirport=true", () => {
render(
<FlightCard
flight={makeFlight({ returnToAirport: true })}
expandable
/>,
);
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(<FlightCard flight={makeFlight({ depCity: "" })} expandable />);
// 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(<FlightCard flight={makeFlight({})} expandable />);
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(<FlightCard flight={makeFlight({})} expandable />);
const row = document.querySelector("[role='button']");
expect(row).toBeTruthy();
expect(row?.getAttribute("aria-expanded")).toBe("false");
});
it("row aria-expanded=true when initialExpanded", () => {
render(<FlightCard flight={makeFlight({})} expandable initialExpanded />);
const row = document.querySelector("[role='button']");
expect(row?.getAttribute("aria-expanded")).toBe("true");
});
it("expanded panel is present when initialExpanded=true", () => {
render(<FlightCard flight={makeFlight({})} expandable initialExpanded />);
expect(screen.getByTestId("flight-card-expanded")).toBeTruthy();
});
it("expanded panel is absent in collapsed state", () => {
render(<FlightCard flight={makeFlight({})} expandable />);
expect(document.querySelector("[data-testid='flight-card-expanded']")).toBeNull();
});
});