diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss index 02a4e8b6..db8f8911 100644 --- a/src/features/schedule/components/ScheduleFlightBody.scss +++ b/src/features/schedule/components/ScheduleFlightBody.scss @@ -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; diff --git a/src/features/schedule/components/ScheduleFlightBody.test.tsx b/src/features/schedule/components/ScheduleFlightBody.test.tsx new file mode 100644 index 00000000..bfd2c152 --- /dev/null +++ b/src/features/schedule/components/ScheduleFlightBody.test.tsx @@ -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 }) => ( + {scheduled} + ), +})); +vi.mock("@/ui/flights/StationDisplay.js", () => ({ + StationDisplay: ({ airportCode }: { airportCode: string }) => ( + {airportCode} + ), +})); +vi.mock("@/ui/flights/OperatorLogo.js", () => ({ + OperatorLogo: ({ carrier }: { carrier: string }) => ( + {carrier} + ), +})); + +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(); + expect(screen.getByTestId("schedule-flight-body")).toBeTruthy(); + }); + + it("renders leg number badge '1' for single leg", () => { + render(); + expect(screen.getByText("1")).toBeTruthy(); + }); + + it("renders flight number SU 1234", () => { + render(); + expect(screen.getByText("SU 1234")).toBeTruthy(); + }); + + it("renders operator logo for the leg carrier", () => { + render(); + expect(screen.getByTestId("operator-logo")).toBeTruthy(); + }); + + it("renders departure and arrival station codes", () => { + render(); + 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(); + expect(screen.getByTestId("schedule-share-button")).toBeTruthy(); + }); + }); + + describe("Multi-leg flight structure", () => { + it("renders 2 leg rows for multi-leg flight", () => { + render(); + // 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(); + expect(screen.getByTestId("schedule-timeline")).toBeTruthy(); + }); + + it("renders transfer box between legs", () => { + render(); + expect(screen.getByTestId("flight-transfer")).toBeTruthy(); + }); + + it("shows SHARED.INTERMEDIATE-LANDING-PLURAL-ONE for multi-leg transfer", () => { + render(); + expect(screen.getByText("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")).toBeTruthy(); + }); + + it("renders transfer content area in transfer box", () => { + render(); + // 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(); + expect(screen.getByText("SU 6188")).toBeTruthy(); + expect(screen.getByText("SU 6233")).toBeTruthy(); + }); + + it("shows SHARED.FLIGHT-TRANSFER label for connecting transfer box", () => { + render(); + 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(); + expect(screen.getByTestId("schedule-buy-button")).toBeTruthy(); + }); + + it("hides buy button when departure is > 330 days ahead", () => { + const onBuy = vi.fn(); + render(); + expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); + }); + + it("hides buy button when departure is less than 2h away", () => { + const onBuy = vi.fn(); + render(); + expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); + }); + + it("hides buy button when departure is in the past", () => { + const onBuy = vi.fn(); + render(); + expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); + }); + + it("hides buy button when onBuy prop not provided", () => { + render(); + expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); + }); + + it("calls onBuy handler when buy button clicked", () => { + const onBuy = vi.fn(); + render(); + 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(); + 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(); + expect(screen.getByTestId("schedule-status-button")).toBeTruthy(); + }); + + it("hides status button when flight departs 10 days from now", () => { + const onStatus = vi.fn(); + render(); + expect(screen.queryByTestId("schedule-status-button")).toBeNull(); + }); + + it("hides status button when flight departed yesterday", () => { + const onStatus = vi.fn(); + render(); + expect(screen.queryByTestId("schedule-status-button")).toBeNull(); + }); + + it("hides status button when onStatus prop not provided", () => { + render(); + expect(screen.queryByTestId("schedule-status-button")).toBeNull(); + }); + + it("calls onStatus handler when status button clicked", () => { + const onStatus = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("schedule-status-button")); + expect(onStatus).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx index 32bd5655..8b1dac38 100644 --- a/src/features/schedule/components/ScheduleFlightBody.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -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 = ({ flight, onBuy, @@ -92,6 +132,14 @@ export const ScheduleFlightBody: FC = ({ 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 = ({
- - + {/* TZ §4.1.14.4.4 – Buy: visible only within 2h–330d window */} + {buyVisible && ( + + )} + {/* TZ §4.1.14.4.5 – Status: visible only on the departure day */} + {statusVisible && ( + + )} + {/* TZ §4.1.14.4.6 – Details: always visible when handler provided */} + {onStatus && ( + + )}
);