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.
This commit is contained in:
2026-04-22 00:39:23 +03:00
parent 7fcb844b82
commit c49a2a8525
3 changed files with 188 additions and 13 deletions
@@ -187,8 +187,14 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
{t("SHARED.BACK-SCHEDULE")}
</Link>
);
const title = flightIds[0]
? `${t("BOARD.FLIGHT-INFO")}: ${flightIds[0].carrier} ${flightIds[0].flightNumber}`
// TZ §4.1.16.6 Table 60 rules 1+5: connecting flights show BOTH flight numbers in the header.
const titleFlightNumbers = flightIds.length > 1
? flightIds.map((f) => `${f.carrier} ${f.flightNumber}${f.suffix ?? ""}`).join(", ")
: flightIds[0]
? `${flightIds[0].carrier} ${flightIds[0].flightNumber}${flightIds[0].suffix ?? ""}`
: null;
const title = titleFlightNumbers
? `${t("BOARD.FLIGHT-INFO")}: ${titleFlightNumbers}`
: t("SCHEDULE.TITLE");
if (loading) {
@@ -170,10 +170,15 @@ 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 */
function todayUtc(offsetHours = 3): string {
/** 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();
now.setHours(now.getHours() + offsetHours);
return now.toISOString();
// 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 ─────────────────────────────────────────────────────────────────────
@@ -245,7 +250,7 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
});
});
describe("Connecting flight structure", () => {
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();
@@ -256,6 +261,138 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
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");
});
});
// ────────────────────────────────────────────────────────────────────────────
@@ -313,7 +450,7 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
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(3))} onStatus={onStatus} />);
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
expect(screen.getByTestId("schedule-status-button")).toBeTruthy();
});
@@ -336,7 +473,7 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
it("calls onStatus handler when status button clicked", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc(3))} onStatus={onStatus} />);
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
fireEvent.click(screen.getByTestId("schedule-status-button"));
expect(onStatus).toHaveBeenCalledOnce();
});
@@ -266,9 +266,24 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
transferType === "connecting"
? "SHARED.FLIGHT-TRANSFER"
: "SHARED.INTERMEDIATE-LANDING-PLURAL-ONE";
// TZ §4.1.16.6 rule 12: show both stations if airport codes differ OR if the
// IATA city codes differ (e.g. SVO→Moscow vs VKO→Moscow is same city, but
// SVO→Moscow vs UFA→Ufa is different). Fall back to airport-code comparison
// when cityCode is absent.
const arrCityCode = leg.arrival.scheduled.cityCode;
const depCityCode = next?.departure.scheduled.cityCode;
const stationChange =
next && leg.arrival.scheduled.airportCode !==
next.departure.scheduled.airportCode;
next && (
leg.arrival.scheduled.airportCode !== next.departure.scheduled.airportCode ||
(arrCityCode && depCityCode && arrCityCode !== depCityCode)
);
// TZ §4.1.16.6 rule 14: if same airport but different terminals, show both.
const arrTerminal = leg.arrival.terminal;
const depTerminal = next?.departure.terminal;
const terminalChange =
next && !stationChange &&
arrTerminal && depTerminal &&
arrTerminal !== depTerminal;
return (
<div key={`leg-${idx}`}>
@@ -359,16 +374,33 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
<div className="schedule-flight-body__transfer-stations">
{stationChange ? (
<>
<span>{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}</span>
<span aria-hidden="true">{"\u2192"}</span>
<span>
{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}
{arrTerminal ? ` - ${arrTerminal}` : ""}
</span>
<span aria-hidden="true">{"→"}</span>
<span>
{next.departure.scheduled.city}, {next.departure.scheduled.airport}
{depTerminal ? ` - ${depTerminal}` : ""}
</span>
</>
) : terminalChange ? (
// TZ §4.1.16.6 rule 14: same airport, different terminals
<>
<span>
{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}
{arrTerminal ? ` - ${arrTerminal}` : ""}
</span>
<span aria-hidden="true">{"→"}</span>
<span>
{next.departure.scheduled.airport}
{depTerminal ? ` - ${depTerminal}` : ""}
</span>
</>
) : (
<span>
{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}
{leg.arrival.terminal ? ` - ${leg.arrival.terminal}` : ""}
{arrTerminal ? ` - ${arrTerminal}` : ""}
</span>
)}
</div>