Files
flights_web/src/features/schedule/components/ScheduleFlightBody.test.tsx
T
gnezim c49a2a8525 Audit connecting flight details per TZ §4.1.16.6
Three fixes:
- Transfer box: use IATA cityCode (not display text) for city-level
  station change detection (TZ §4.1.16.6 rule 12), catching cases where
  city codes differ even if airport codes are the same.
- Transfer box: add terminal-change case — same airport but different
  arrival/departure terminals now renders both codes separated by →
  (TZ §4.1.16.6 rule 14).
- ScheduleDetailsPage title: show all connecting flight numbers in the
  page <h1> and title string (TZ §4.1.16.6 Table 60 header rule 1+5).

Also fixes a pre-existing flaky test in ScheduleFlightBody: todayUtc()
now always returns UTC noon of today to avoid day-boundary races.
2026-04-22 00:39:23 +03:00

482 lines
22 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);
});
it("renders share button always (TZ §4.1.14.4.3)", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByTestId("schedule-share-button")).toBeTruthy();
});
});
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");
});
});
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.14.4.4 — Buy button visibility gate
// ────────────────────────────────────────────────────────────────────────────
describe("Buy button TZ §4.1.14.4.4", () => {
it("shows buy button when departure is 10 days ahead", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onBuy={onBuy} />);
expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
});
it("hides buy button when departure is > 330 days ahead", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_340D)} onBuy={onBuy} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy button when departure is less than 2h away", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_1H)} onBuy={onBuy} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy button when departure is in the past", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(YESTERDAY)} onBuy={onBuy} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy button when onBuy prop not provided", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("calls onBuy handler when buy button clicked", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onBuy={onBuy} />);
fireEvent.click(screen.getByTestId("schedule-buy-button"));
expect(onBuy).toHaveBeenCalledOnce();
});
it("uses first-leg departure UTC for multi-leg buy gate (TZ §4.1.14.4.4)", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} onBuy={onBuy} />);
expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
});
});
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.14.4.5 — Status button (today-only)
// ────────────────────────────────────────────────────────────────────────────
describe("Status button TZ §4.1.14.4.5", () => {
it("shows status button when flight departs today", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
expect(screen.getByTestId("schedule-status-button")).toBeTruthy();
});
it("hides status button when flight departs 10 days from now", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onStatus={onStatus} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("hides status button when flight departed yesterday", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(YESTERDAY)} onStatus={onStatus} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("hides status button when onStatus prop not provided", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("calls onStatus handler when status button clicked", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
fireEvent.click(screen.getByTestId("schedule-status-button"));
expect(onStatus).toHaveBeenCalledOnce();
});
});
});