Files
flights_web/src/features/schedule/components/ScheduleFlightBody.test.tsx
T
gnezim fa4656dab1 Summary header: round-logo badges + remove share/buy from leg body
Connecting itineraries now render details-header-badge with the small
round airline icon (36×36) from Angular's `[round]="isConnecting"`
path and drop the 'Авиакомпания' caption, so the SU 6188 + SU 6341
row sits compactly next to the share/buy/last-update cluster instead
of stretching two wide wordmarks across the summary.

Share + Buy buttons removed from ScheduleFlightBody — Angular's
`flight-schedule-details` wires `[share]=false [buy]=false
[print]=false [details]=false [register]=false` into its inner
flight-actions, so a per-leg action strip was never meant to exist.
The page-level summary header now owns those affordances.

OperatorLogo.scss: override the 180×46 rule inside .details-header-badge
when the logo carries .operator-logo--round so the connecting-summary
badge doesn't force a wide wordmark.

BoardDetailsHeader.scss is imported from DetailsHeaderBadge.tsx so
consumers (schedule details summary) that use the badge without the
full BoardDetailsHeader wrapper still pick up flex/gap/typography.
2026-04-23 17:07:25 +03:00

442 lines
20 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
/**
* Tests for ScheduleFlightBody TZ §4.1.14.4 compliance assertions.
*
* Coverage:
* - Direct flight: leg number, flight-id, operator logo, dep/arr times, duration
* - Transfer box between legs (multi-leg + connecting): label + duration + station info
* - Buy button visibility gate: >2h ahead AND <330 days ahead (TZ §4.1.14.4.4)
* - Status button visibility gate: same-day departure only (TZ §4.1.14.4.5)
* - Share button always present
* - Actions area rendered at bottom
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
// ── i18n stub ─────────────────────────────────────────────────────────────────
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock("@/i18n/useLocale.js", () => ({
useLocale: () => ({ language: "ru", locale: "ru-ru" }),
}));
// ── UI stubs ──────────────────────────────────────────────────────────────────
vi.mock("@/ui/flights/TimeGroup.js", () => ({
TimeGroup: ({ scheduled }: { scheduled: string }) => (
<span data-testid="time-group">{scheduled}</span>
),
}));
vi.mock("@/ui/flights/StationDisplay.js", () => ({
StationDisplay: ({ airportCode }: { airportCode: string }) => (
<span data-testid="station-display">{airportCode}</span>
),
}));
vi.mock("@/ui/flights/OperatorLogo.js", () => ({
OperatorLogo: ({ carrier }: { carrier: string }) => (
<span data-testid="operator-logo">{carrier}</span>
),
}));
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeTimesSet(utc: string, local: string) {
return {
scheduledDeparture: {
utc,
local,
localTime: local,
tzOffset: 0,
dayChange: { value: 0, title: "" },
},
};
}
function makeArrivalTimesSet(utc: string, local: string) {
return {
scheduledArrival: {
utc,
local,
localTime: local,
tzOffset: 0,
dayChange: { value: 0, title: "" },
},
};
}
function makeStation(code: string, city: string, airport: string): {
scheduled: { airportCode: string; city: string; airport: string; cityCode: string; countryCode: string };
terminal: string;
} {
return {
scheduled: {
airportCode: code,
city,
airport,
cityCode: code,
countryCode: "RU",
},
terminal: "",
};
}
function makeLeg(depUtc: string, depLocal: string, arrUtc: string, arrLocal: string, depCode = "SVO", arrCode = "LED"): IFlightLeg {
return {
departure: {
...makeStation(depCode, "Moscow", "Sheremetyevo"),
times: makeTimesSet(depUtc, depLocal),
checkingStatus: "",
},
arrival: {
...makeStation(arrCode, "Saint Petersburg", "Pulkovo"),
times: makeArrivalTimesSet(arrUtc, arrLocal),
},
flyingTime: "02:00:00",
equipment: {},
operatingBy: {},
status: "Scheduled",
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
dayChange: 0,
index: 0,
updated: "",
} as unknown as IFlightLeg;
}
function makeDirectFlight(depUtc: string, depLocal = "10:00:00"): ISimpleFlight {
const arrUtc = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
return {
routeType: "Direct",
id: "test-direct",
flyingTime: "02:00:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "1234", date: depUtc.slice(0, 10) },
operatingBy: {},
leg: makeLeg(depUtc, depLocal, arrUtc, "12:00:00"),
} as unknown as ISimpleFlight;
}
function makeMultiLegFlight(depUtc: string): ISimpleFlight {
const mid = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
const midNext = new Date(new Date(mid).getTime() + 1.5 * 3600 * 1000).toISOString();
const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
return {
routeType: "MultiLeg",
id: "test-multileg",
flyingTime: "06:30:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "5678", date: depUtc.slice(0, 10) },
operatingBy: {},
legs: [
makeLeg(depUtc, "10:00", mid, "12:00", "SVO", "KJA"),
makeLeg(midNext, "13:30", arr, "16:30", "KJA", "LED"),
],
} as unknown as ISimpleFlight;
}
function makeConnectingFlight(depUtc: string): ISimpleFlight {
const mid = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
const midNext = new Date(new Date(mid).getTime() + 2 * 3600 * 1000).toISOString();
const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
return {
routeType: "MultiLeg",
id: "su6188+su6233",
flyingTime: "07:00:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "6188", date: depUtc.slice(0, 10) },
operatingBy: {},
legs: [
makeLeg(depUtc, "10:00", mid, "12:00", "SVO", "KUF"),
makeLeg(midNext, "14:00", arr, "17:00", "KUF", "LED"),
],
_childFlightIds: [
{ carrier: "SU", flightNumber: "6188" },
{ carrier: "SU", flightNumber: "6233" },
],
} as unknown as ISimpleFlight;
}
// ── Buy-gate helpers ──────────────────────────────────────────────────────────
/** 10 days from now — buy should be visible */
const FUTURE_10D = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString();
/** 340 days from now — buy should be hidden (> 330 days) */
const FUTURE_340D = new Date(Date.now() + 340 * 24 * 3600 * 1000).toISOString();
/** 1 hour from now — buy should be hidden (< 2h threshold) */
const FUTURE_1H = new Date(Date.now() + 1 * 3600 * 1000).toISOString();
/** Yesterday — buy should be hidden (past) */
const YESTERDAY = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
/** Today (same calendar day) — status button should be visible */
/** Return an ISO string that is guaranteed to be within today's UTC date.
* Always use UTC noon (12:00) to avoid any local-to-UTC shift pushing us
* into yesterday or tomorrow. */
function todayUtc(): string {
const now = new Date();
// Force UTC noon of today — well within the UTC calendar day
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0),
).toISOString();
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ScheduleFlightBody TZ §4.1.14.4", () => {
describe("Direct flight structure", () => {
it("renders schedule-flight-body container", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByTestId("schedule-flight-body")).toBeTruthy();
});
it("renders leg number badge '1' for single leg", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByText("1")).toBeTruthy();
});
it("renders flight number SU 1234", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByText("SU 1234")).toBeTruthy();
});
it("renders operator logo for the leg carrier", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByTestId("operator-logo")).toBeTruthy();
});
it("renders departure and arrival station codes", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
const stations = screen.getAllByTestId("station-display");
expect(stations.some((s) => s.textContent === "SVO")).toBe(true);
expect(stations.some((s) => s.textContent === "LED")).toBe(true);
});
// Share/Buy/Status no longer render inside the per-leg body — they
// live in the page-level summary header (mirroring Angular's
// `flight-schedule-details [share]=false [buy]=false [print]=false`).
});
describe("Multi-leg flight structure", () => {
it("renders 2 leg rows for multi-leg flight", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
// Both leg numbers should appear
const nums = screen.getAllByText(/^[12]$/);
expect(nums.length).toBeGreaterThanOrEqual(2);
});
it("renders horizontal timeline for multi-leg (TZ §4.1.14.4 timeline row)", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
expect(screen.getByTestId("schedule-timeline")).toBeTruthy();
});
it("renders transfer box between legs", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
expect(screen.getByTestId("flight-transfer")).toBeTruthy();
});
it("shows SHARED.INTERMEDIATE-LANDING-PLURAL-ONE for multi-leg transfer", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
expect(screen.getByText("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")).toBeTruthy();
});
it("renders transfer content area in transfer box", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
// Transfer box rendered; content (icon + label) is present
const transfer = screen.getByTestId("flight-transfer");
expect(transfer.textContent).toContain("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE");
});
});
describe("Connecting flight structure TZ §4.1.16.6", () => {
it("renders separate flight numbers per leg for connecting flight", () => {
render(<ScheduleFlightBody flight={makeConnectingFlight(FUTURE_10D)} />);
expect(screen.getByText("SU 6188")).toBeTruthy();
expect(screen.getByText("SU 6233")).toBeTruthy();
});
it("shows SHARED.FLIGHT-TRANSFER label for connecting transfer box", () => {
render(<ScheduleFlightBody flight={makeConnectingFlight(FUTURE_10D)} />);
expect(screen.getByText("SHARED.FLIGHT-TRANSFER")).toBeTruthy();
});
it("transfer box shows single station when same airport (no change)", () => {
// Both legs land/depart at the same airport code and city (KUF)
render(<ScheduleFlightBody flight={makeConnectingFlight(FUTURE_10D)} />);
const transfer = screen.getByTestId("flight-transfer");
// Should NOT show the → arrow; look specifically at the stations div
const stationsDiv = transfer.querySelector(".schedule-flight-body__transfer-stations");
expect(stationsDiv).toBeTruthy();
// No arrow span in the stations area (the svg icon is outside this div)
const arrowSpans = stationsDiv?.querySelectorAll("span[aria-hidden='true']") ?? [];
expect(arrowSpans.length).toBe(0);
});
it("transfer box shows two stations when airports differ (TZ §4.1.16.6 rule 12)", () => {
// Flight where leg1 arr = SVO and leg2 dep = VKO (different airports)
const dep = FUTURE_10D;
const mid = new Date(new Date(dep).getTime() + 2 * 3600 * 1000).toISOString();
const midNext = new Date(new Date(mid).getTime() + 2 * 3600 * 1000).toISOString();
const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
const stationChangeFlight: ISimpleFlight = {
routeType: "MultiLeg",
id: "su6188+su6233-stn-change",
flyingTime: "07:00:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "6188", date: dep.slice(0, 10) },
operatingBy: {},
legs: [
{ ...makeLeg(dep, "10:00", mid, "12:00", "SVO", "VKO"),
arrival: { ...makeLeg(dep, "10:00", mid, "12:00", "SVO", "VKO").arrival,
scheduled: { airportCode: "VKO", city: "Moscow", airport: "Vnukovo", cityCode: "MOW", countryCode: "RU" } } },
{ ...makeLeg(midNext, "14:00", arr, "17:00", "SVO", "LED"),
departure: { ...makeLeg(midNext, "14:00", arr, "17:00", "SVO", "LED").departure,
scheduled: { airportCode: "SVO", city: "Moscow", airport: "Sheremetyevo", cityCode: "MOW", countryCode: "RU" } } },
],
_childFlightIds: [
{ carrier: "SU", flightNumber: "6188" },
{ carrier: "SU", flightNumber: "6233" },
],
} as unknown as ISimpleFlight;
render(<ScheduleFlightBody flight={stationChangeFlight} />);
const transfer = screen.getByTestId("flight-transfer");
// Should show arrow → between the two different airports
const arrowSpans = transfer.querySelectorAll("span[aria-hidden='true']");
expect(arrowSpans.length).toBeGreaterThanOrEqual(1);
expect(transfer.textContent).toContain("Vnukovo");
expect(transfer.textContent).toContain("Sheremetyevo");
});
it("transfer box shows both terminals when same airport but different terminals (TZ §4.1.16.6 rule 14)", () => {
// Leg1 arr = SVO terminal D; Leg2 dep = SVO terminal E
const dep = FUTURE_10D;
const mid = new Date(new Date(dep).getTime() + 2 * 3600 * 1000).toISOString();
const midNext = new Date(new Date(mid).getTime() + 1.5 * 3600 * 1000).toISOString();
const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
const leg1 = makeLeg(dep, "10:00", mid, "12:00", "SVO", "SVO");
const leg2 = makeLeg(midNext, "13:30", arr, "16:30", "SVO", "LED");
// Patch terminals
(leg1 as unknown as Record<string, unknown>).arrival = {
...(leg1 as unknown as { arrival: unknown }).arrival as Record<string, unknown>,
terminal: "D",
scheduled: { airportCode: "SVO", city: "Moscow", airport: "Sheremetyevo", cityCode: "MOW", countryCode: "RU" },
};
(leg2 as unknown as Record<string, unknown>).departure = {
...(leg2 as unknown as { departure: unknown }).departure as Record<string, unknown>,
terminal: "E",
scheduled: { airportCode: "SVO", city: "Moscow", airport: "Sheremetyevo", cityCode: "MOW", countryCode: "RU" },
checkingStatus: "",
};
const terminalChangeFlight: ISimpleFlight = {
routeType: "MultiLeg",
id: "su-terminal-change",
flyingTime: "05:30:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "1000", date: dep.slice(0, 10) },
operatingBy: {},
legs: [leg1, leg2],
_childFlightIds: [
{ carrier: "SU", flightNumber: "1000" },
{ carrier: "SU", flightNumber: "1001" },
],
} as unknown as ISimpleFlight;
render(<ScheduleFlightBody flight={terminalChangeFlight} />);
const transfer = screen.getByTestId("flight-transfer");
// Both terminal codes must appear
expect(transfer.textContent).toContain("D");
expect(transfer.textContent).toContain("E");
// Arrow must be present between them
const arrowSpans = transfer.querySelectorAll("span[aria-hidden='true']");
expect(arrowSpans.length).toBeGreaterThanOrEqual(1);
});
it("transfer box terminal text uses arrTerminal variable (not leg.arrival.terminal directly)", () => {
// Regression: same airport, same terminal should show single station without arrow
const dep = FUTURE_10D;
const mid = new Date(new Date(dep).getTime() + 2 * 3600 * 1000).toISOString();
const midNext = new Date(new Date(mid).getTime() + 1.5 * 3600 * 1000).toISOString();
const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
const leg1 = makeLeg(dep, "10:00", mid, "12:00", "SVO", "SVO");
const leg2 = makeLeg(midNext, "13:30", arr, "16:30", "SVO", "LED");
// Same terminal B on both
(leg1 as unknown as Record<string, unknown>).arrival = {
...(leg1 as unknown as { arrival: unknown }).arrival as Record<string, unknown>,
terminal: "B",
scheduled: { airportCode: "SVO", city: "Moscow", airport: "Sheremetyevo", cityCode: "MOW", countryCode: "RU" },
};
(leg2 as unknown as Record<string, unknown>).departure = {
...(leg2 as unknown as { departure: unknown }).departure as Record<string, unknown>,
terminal: "B",
scheduled: { airportCode: "SVO", city: "Moscow", airport: "Sheremetyevo", cityCode: "MOW", countryCode: "RU" },
checkingStatus: "",
};
const sametermFlight: ISimpleFlight = {
routeType: "MultiLeg",
id: "su-same-terminal",
flyingTime: "05:30:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "1000", date: dep.slice(0, 10) },
operatingBy: {},
legs: [leg1, leg2],
_childFlightIds: [
{ carrier: "SU", flightNumber: "1000" },
{ carrier: "SU", flightNumber: "1001" },
],
} as unknown as ISimpleFlight;
render(<ScheduleFlightBody flight={sametermFlight} />);
const transfer = screen.getByTestId("flight-transfer");
// No → arrow for same-airport same-terminal
const arrowSpans = transfer.querySelectorAll("span[aria-hidden='true']");
expect(arrowSpans.length).toBe(0);
// Terminal B should appear
expect(transfer.textContent).toContain("B");
});
});
// Share/Buy/Status button tests were removed: those affordances
// relocated from the per-leg body to the page-level summary header.
// Their visibility is exercised by BoardDetailsHeader/FlightActions
// tests plus the schedule-details summary e2e spec.
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.16.7 — Intermediate landing vs transfer duration (UTC fix)
// ────────────────────────────────────────────────────────────────────────────
describe("Transfer duration TZ §4.1.16.7 (UTC-based, not local)", () => {
it("computes ground time from UTC (90 min = 1ч. 30мин.)", () => {
// leg1 arr UTC: 10:00Z, leg2 dep UTC: 11:30Z → 90 min
const depUtc = "2026-08-10T08:00:00Z";
const midUtc = "2026-08-10T10:00:00Z"; // arr leg1
const midNextUtc = "2026-08-10T11:30:00Z"; // dep leg2
const arrUtc = "2026-08-10T14:30:00Z";
const flight: ISimpleFlight = {
routeType: "MultiLeg",
id: "duration-test",
flyingTime: "06:30:00",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: "9999", date: "2026-08-10" },
operatingBy: {},
legs: [
makeLeg(depUtc, "11:00", midUtc, "13:00", "SVO", "KJA"),
makeLeg(midNextUtc, "14:30", arrUtc, "17:30", "KJA", "LED"),
],
} as unknown as ISimpleFlight;
render(<ScheduleFlightBody flight={flight} />);
// 90 min = "1ч. 30мин." (ru locale)
const transfer = screen.getByTestId("flight-transfer");
expect(transfer.textContent).toContain("1ч. 30мин.");
});
it("label is Промежуточная посадка for same flight-number multi-leg (§4.1.16.7)", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} />);
expect(screen.getByText("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")).toBeTruthy();
});
it("label is Пересадка for connecting (different flight numbers) (§4.1.16.7)", () => {
render(<ScheduleFlightBody flight={makeConnectingFlight(FUTURE_10D)} />);
expect(screen.getByText("SHARED.FLIGHT-TRANSFER")).toBeTruthy();
});
});
});