474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
// @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<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();
|
||
});
|
||
});
|