Audit Online-Board collapsed row per TZ 4.1.13.3 Tables 23-27

This commit is contained in:
2026-04-21 22:55:49 +03:00
parent 8b0d559df9
commit 3b5ae9af85
3 changed files with 517 additions and 1 deletions
+473
View File
@@ -0,0 +1,473 @@
// @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();
});
});
+17
View File
@@ -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<FlightCardProps> = ({
// 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 C15C16.
// 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<FlightCardProps> = ({
{(expanded || direction === "schedule") && aircraftName && (
<div className="flight-card__aircraft">{aircraftName}</div>
)}
{/* TZ §4.1.13.3 Table 24 C15C16: 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) && (
<FlightEvents
changeRoute={routeChanged}
reroute={returnToAirport}
showDescription={false}
/>
)}
</div>
<div className="flight-card__operator">
+27 -1
View File
@@ -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<TimeGroupProps> = ({
<span className="time-group__scheduled">{scheduledTime}</span>
)}
{dayChange !== undefined && dayChange !== 0 ? (
<span className="time-group__day-change">
<span
className="time-group__day-change"
title={dayChangeTooltip(scheduled, dayChange)}
>
{dayChange > 0 ? `+${dayChange}` : dayChange}
</span>
) : null}