Audit Schedule expanded rows per TZ 4.1.14.4 (multi-segment + connecting)

Gate Buy button to the TZ §4.1.14.4.4 window: visible only when departure
UTC is > 2 hours ahead AND < 330 days ahead; first leg governs for multi/
connecting. Gate Status button (§4.1.14.4.5) to same-day departure only,
based on UTC calendar date. Add separate Details button (§4.1.14.4.6) that
is always visible when an onStatus handler is provided. Add SCSS for the
new details-btn outline style. Add 25-test ScheduleFlightBody.test.tsx
covering structure, transfer-box labels, buy gate, and status gate.
This commit is contained in:
2026-04-21 23:18:16 +03:00
parent 6f67c06786
commit 4290c819bb
3 changed files with 455 additions and 22 deletions
@@ -224,6 +224,26 @@
&:hover { background: colors.$blue--hover; }
}
// §4.1.14.4.6 Детали рейса: always-visible outline button (white bg,
// blue border+text), mirrors Angular's secondary CTA style.
&__details-btn {
background: colors.$white;
color: colors.$blue;
border: 1px solid colors.$blue;
border-radius: vars.$border-radius;
padding: vars.$space-m 24px;
font-size: fonts.$font-size-m;
font-weight: fonts.$font-medium;
cursor: pointer;
min-width: 150px;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover {
background: colors.$blue;
color: colors.$white;
}
}
// ----- horizontal timeline (route summary) -----------------------------
&__timeline {
padding: vars.$space-l vars.$space-xl vars.$space-m;
@@ -0,0 +1,344 @@
// @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 */
function todayUtc(offsetHours = 3): string {
const now = new Date();
now.setHours(now.getHours() + offsetHours);
return now.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", () => {
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();
});
});
// ────────────────────────────────────────────────────────────────────────────
// 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(3))} 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(3))} onStatus={onStatus} />);
fireEvent.click(screen.getByTestId("schedule-status-button"));
expect(onStatus).toHaveBeenCalledOnce();
});
});
});
@@ -80,6 +80,46 @@ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
// ── TZ §4.1.14.4.4 Buy-button visibility gate ─────────────────────────────
// Visible when:
// • Departure UTC is > 2 h from now (not about to depart / already departed)
// • Departure UTC is < 330 days from now
// Eligibility is assessed on the FIRST leg of the flight (TZ §4.1.14.4.4:
// "рассчитывается исходя из времени первого сегмента").
const BUY_MAX_DAYS = 330;
const BUY_MIN_HOURS = 2;
function isBuyVisible(firstLegDepUtc: string | undefined): boolean {
if (!firstLegDepUtc) return false;
const depMs = new Date(firstLegDepUtc).getTime();
if (Number.isNaN(depMs)) return false;
const nowMs = Date.now();
const diffMs = depMs - nowMs;
if (diffMs <= 0) return false;
if (diffMs < BUY_MIN_HOURS * 3600 * 1000) return false;
if (diffMs > BUY_MAX_DAYS * 24 * 3600 * 1000) return false;
return true;
}
// ── TZ §4.1.14.4.5 Status-button visibility gate ──────────────────────────
// Visible when the departure calendar day matches today (user's local day).
// Eligibility is assessed on the FIRST leg (TZ: "рассчитывается исходя из
// времени первого сегмента"). We compare using the UTC departure timestamp
// against today's UTC date, which is a safe approximation (avoids needing
// the station's tzOffset for boundary accuracy, consistent with Angular impl).
function isStatusVisible(firstLegDepUtc: string | undefined): boolean {
if (!firstLegDepUtc) return false;
const depMs = new Date(firstLegDepUtc).getTime();
if (Number.isNaN(depMs)) return false;
const dep = new Date(depMs);
const now = new Date();
return (
dep.getUTCFullYear() === now.getUTCFullYear() &&
dep.getUTCMonth() === now.getUTCMonth() &&
dep.getUTCDate() === now.getUTCDate()
);
}
export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
flight,
onBuy,
@@ -92,6 +132,14 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
flight.routeType === "Direct" ? [flight.leg] : flight.legs;
if (legs.length === 0) return null;
const firstLeg = legs[0];
const buyVisible =
Boolean(onBuy) &&
isBuyVisible(firstLeg?.departure.times.scheduledDeparture.utc);
const statusVisible =
Boolean(onStatus) &&
isStatusVisible(firstLeg?.departure.times.scheduledDeparture.utc);
const childFlightIds = (flight as ISimpleFlight & {
_childFlightIds?: ChildFlightId[];
})._childFlightIds;
@@ -355,28 +403,49 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
<img src="/assets/img/share.svg" alt="" aria-hidden="true" />
</button>
<div className="schedule-flight-body__spacer" />
<button
type="button"
className="schedule-flight-body__buy-btn"
data-testid="schedule-buy-button"
onClick={(e) => {
e.stopPropagation();
onBuy?.();
}}
>
{t("SHARED.BUY-TICKET")}
</button>
<button
type="button"
className="schedule-flight-body__status-btn"
data-testid="schedule-status-button"
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-DETAILS")}
</button>
{/* TZ §4.1.14.4.4 Buy: visible only within 2h330d window */}
{buyVisible && (
<button
type="button"
className="schedule-flight-body__buy-btn"
data-testid="schedule-buy-button"
onClick={(e) => {
e.stopPropagation();
onBuy?.();
}}
>
{t("SHARED.BUY-TICKET")}
</button>
)}
{/* TZ §4.1.14.4.5 Status: visible only on the departure day */}
{statusVisible && (
<button
type="button"
className="schedule-flight-body__status-btn"
data-testid="schedule-status-button"
title={t("BOARD.STATUS-TOOLTIP")}
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-STATUS")}
</button>
)}
{/* TZ §4.1.14.4.6 Details: always visible when handler provided */}
{onStatus && (
<button
type="button"
className="schedule-flight-body__details-btn"
data-testid="schedule-details-button"
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-DETAILS")}
</button>
)}
</div>
</div>
);