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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user