diff --git a/src/features/online-board/components/FullRouteTimeline/Timeline.tsx b/src/features/online-board/components/FullRouteTimeline/Timeline.tsx index bb65c3a1..67ec1219 100644 --- a/src/features/online-board/components/FullRouteTimeline/Timeline.tsx +++ b/src/features/online-board/components/FullRouteTimeline/Timeline.tsx @@ -11,14 +11,14 @@ export interface TimelineProps { function depTime(leg: IFlightLeg, canChange: boolean): string { const t = leg.departure.times; - if (canChange) return t.actualBlockOff?.local ?? t.scheduledDeparture.local; - return t.scheduledDeparture.local; + if (canChange) return t.actualBlockOff?.localTime ?? t.scheduledDeparture.localTime; + return t.scheduledDeparture.localTime; } function arrTime(leg: IFlightLeg, canChange: boolean): string { const t = leg.arrival.times; - if (canChange) return t.actualBlockOn?.local ?? t.scheduledArrival.local; - return t.scheduledArrival.local; + if (canChange) return t.actualBlockOn?.localTime ?? t.scheduledArrival.localTime; + return t.scheduledArrival.localTime; } /** diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index fed1e98c..52cf7e0a 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -17,16 +17,10 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { useLocale } from "@/i18n/useLocale.js"; import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js"; import { ScheduleFlightBody } from "./ScheduleFlightBody.js"; +import type { ScheduleSortMode } from "./ScheduleColumnHeaders.js"; import "./DayGroupedFlightList.scss"; -type SortMode = - | "none" - | "departureUp" - | "departureDown" - | "timeUp" - | "timeDown" - | "arrivalUp" - | "arrivalDown"; +type SortMode = ScheduleSortMode; export interface DayGroupedFlightListProps { flights: ISimpleFlight[]; @@ -35,6 +29,13 @@ export interface DayGroupedFlightListProps { /** Aeroflot booking URL for each flight. Returning null hides the Buy link. */ buyUrlFor?: (flight: ISimpleFlight) => string | null; initialCurrentFlightId?: string | null; + /** Lifted sort state — when set, the caller (sticky `ScheduleColumnHeaders`) + * drives sort, and the internal header sort buttons sync to it. */ + sortMode?: SortMode; + onSortChange?: (mode: SortMode) => void; + /** Suppress the internal column-header row — used when the page renders + * its own sticky `ScheduleColumnHeaders` above the list. */ + hideColumnHeaders?: boolean; } interface DayGroup { @@ -129,10 +130,19 @@ export const DayGroupedFlightList: FC = ({ onFlightClick, buyUrlFor, initialCurrentFlightId, + sortMode: sortModeProp, + onSortChange, + hideColumnHeaders, }) => { const { language } = useLocale(); const { t } = useTranslation(); - const [sortMode, setSortMode] = useState("none"); + const [internalSortMode, setInternalSortMode] = useState("none"); + const sortMode = sortModeProp ?? internalSortMode; + const setSortMode = (next: SortMode | ((prev: SortMode) => SortMode)) => { + const value = typeof next === "function" ? next(sortMode) : next; + if (onSortChange) onSortChange(value); + if (sortModeProp === undefined) setInternalSortMode(value); + }; // Track which days the user has expanded. Default: today's day group // (if it's in scope). Angular's `p-accordion` is `[multiple]="true"` // and `[activeIndex]` defaults to the index of today's date when @@ -262,7 +272,7 @@ export const DayGroupedFlightList: FC = ({ if (groups.length === 1) { return (
- {headerRow} + {hideColumnHeaders ? null : headerRow} = ({ return (
- {headerRow} + {hideColumnHeaders ? null : headerRow} {groups.map((g) => { const weekday = weekdayFmt.format(g.parsed); const dayMonth = dayMonthFmt.format(g.parsed); diff --git a/src/features/schedule/components/ScheduleDetailsPage.scss b/src/features/schedule/components/ScheduleDetailsPage.scss index 43cd00cb..c39e794a 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.scss +++ b/src/features/schedule/components/ScheduleDetailsPage.scss @@ -2,6 +2,46 @@ @use "../../../styles/variables" as vars; @use "../../../styles/fonts" as fonts; +// Summary block (badges + share/buy + last-update + full-route timeline) +// renders just below the day-tabs sticky strip. The block itself is one +// `.frame` card; we add internal vertical spacing between the badges +// row and the timeline so the two don't touch. +.schedule-details__summary { + display: flex; + flex-direction: column; + gap: vars.$space-l; + margin-bottom: vars.$space-l; + + &-row { + display: flex; + align-items: flex-start; + gap: vars.$space-l; + flex-wrap: wrap; + } + + // For connecting itineraries we render one DetailsHeaderBadge per + // flight in a horizontal row (SU 6188 + SU 6341 with their airline + // logos), matching Angular's `` (plural). + &-badges { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: vars.$space-l; + flex: 1 1 auto; + } + + // Right-side cluster: share + buy buttons stacked above the + // "Последнее обновление: …" line, mirroring Angular's + // `.schedule-details-header-right` layout. + &-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: vars.$space-s; + flex: 0 0 auto; + } +} + .schedule-details { display: flex; flex-direction: column; diff --git a/src/features/schedule/components/ScheduleDetailsPage.test.tsx b/src/features/schedule/components/ScheduleDetailsPage.test.tsx index 7645f1a8..6cc2db81 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.test.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.test.tsx @@ -9,6 +9,7 @@ * @vitest-environment jsdom */ +import { forwardRef } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { ScheduleDetailsPage } from "./ScheduleDetailsPage.js"; @@ -23,15 +24,14 @@ vi.mock("@modern-js/runtime/router", () => ({ useSearchParams: () => [{ get: (k: string) => mockSearchParamsGet(k) }], useNavigate: () => vi.fn(), useParams: () => ({ lang: "ru-ru" }), - Link: ({ - children, - to, - ...props - }: { - children: React.ReactNode; - to: string; - [k: string]: unknown; - }) => {children}, + Link: forwardRef< + HTMLAnchorElement, + React.AnchorHTMLAttributes & { to: string } + >(({ children, to, ...props }, ref) => ( + + {children} + + )), })); vi.mock("@/shared/hooks/useDictionaries.js", () => ({ @@ -254,6 +254,9 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)", mockSearchParamsGet = () => null; // Reset to loading state (breadcrumb tests rely on this) mockScheduleDetailsResult = { flights: [], loading: true, error: null }; + // The new ScheduleFlightsMiniList scrolls the highlighted row into + // view on mount; JSDOM doesn't ship `scrollIntoView`, so stub it. + Element.prototype.scrollIntoView = vi.fn(); }); it("4.1.16.1-R2: renders FlightsMiniList in the left column when flights data available", () => { diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index 7fe26be0..2afb3e90 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -35,6 +35,9 @@ import { TransferBar } from "@/features/online-board/components/TransferBar/Tran import { FlightSchedule } from "@/features/online-board/components/FlightSchedule/index.js"; import { FullRouteTimeline } from "@/features/online-board/components/FullRouteTimeline/index.js"; import { ScheduleFlightsMiniList } from "./ScheduleFlightsMiniList.js"; +import { DetailsHeaderBadge } from "@/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.js"; +import { FlightActions } from "@/features/online-board/components/BoardDetailsHeader/FlightActions.js"; +import { LastUpdate } from "@/features/online-board/components/BoardDetailsHeader/LastUpdate.js"; import { DayTabs } from "@/features/online-board/components/DayTabs/index.js"; import type { IScheduleFlightId, IFlightLeg, ISimpleFlight } from "../types.js"; import "./ScheduleDetailsPage.scss"; @@ -153,15 +156,15 @@ export const ScheduleDetailsPage: FC = ({ // Mirrors Angular's `CurrentScheduleService.getScheduleType`: // • If the route-search result contains the open flight (matched // by the same carrier+number sequence), show those matching - // instances across the [-1, +1] day window — the SAME flight on - // adjacent days. - // • Otherwise fall back to a 1-item list with just the open - // flight ('default-schedule' branch). - // Old behaviour returned the entire route search which dumped every - // unrelated MOW-MMK option into the left rail. - const fallback = flights as unknown as ISimpleFlight[]; - if (!miniListSearchParams) return fallback; - if (siblingSearch.loading || siblingSearch.error) return fallback; + // instances across the [-1, +1] day window. + // • Otherwise fall back to an empty list — `ScheduleFlightsMiniList` + // will still render the highlighted current flight on its own + // ('default-schedule' branch). NEVER fall back to `flights` (the + // URL-parsed per-leg breakdown) — for connecting itineraries + // that would leak first-leg-Direct + second-leg-Direct rows + // alongside the synthesized combined view, producing duplicates. + if (!miniListSearchParams) return []; + if (siblingSearch.loading || siblingSearch.error) return []; // Build the open flight's signature: ordered "CARRIER+NUMBER" // segments. For connecting itineraries the URL carries every leg @@ -169,12 +172,12 @@ export const ScheduleDetailsPage: FC = ({ const openSignature = flightIds .map((f) => `${f.carrier}${f.flightNumber}`) .join("+"); - if (!openSignature) return fallback; + if (!openSignature) return []; const allSiblings = extractSimpleFlights( siblingSearch.flights as Array<{ routeType: string }>, ); - const matches = allSiblings.filter((sib) => { + return allSiblings.filter((sib) => { const childIds = (sib as ISimpleFlight & { _childFlightIds?: typeof sib.flightId[]; })._childFlightIds; @@ -183,17 +186,60 @@ export const ScheduleDetailsPage: FC = ({ : `${sib.flightId.carrier}${sib.flightId.flightNumber}`; return sig === openSignature; }); - - return matches.length > 0 ? matches : fallback; }, [ miniListSearchParams, siblingSearch.flights, siblingSearch.loading, siblingSearch.error, - flights, flightIds, ]); + // For connecting itineraries the URL is parsed into per-leg Direct + // entries, so `flights[0]` is only the FIRST leg (Moscow→SPB for + // SU 6188+SU 6341). The sibling search assembles the combined + // MultiLeg (Moscow→Murmansk) for adjacent days, but the day-0 entry + // sometimes comes back as separate Directs (the connecting algorithm + // can fail to fold them on the boundary day). Pick the day-0 MultiLeg + // from `miniListFlights` when present; otherwise synthesize one from + // the URL legs so the highlighted row matches its day-±1 siblings' + // combined origin/destination instead of collapsing to first-leg-only. + const miniListCurrentFlight = useMemo(() => { + const selectedIso = + selectedYmd.length === 8 + ? `${selectedYmd.slice(0, 4)}-${selectedYmd.slice(4, 6)}-${selectedYmd.slice(6, 8)}` + : ""; + const dayZero = miniListFlights.find((f) => { + const leg = f.routeType === "Direct" ? f.leg : f.legs[0]; + const iso = leg?.departure.times.scheduledDeparture.local ?? ""; + return selectedIso && iso.startsWith(selectedIso); + }); + if (dayZero && (dayZero.routeType !== "Direct" || flights.length === 1)) { + return dayZero as ISimpleFlight; + } + if (flights.length > 1) { + const first = flights[0] as ISimpleFlight | undefined; + if (!first) return null; + const legs: IFlightLeg[] = []; + for (const f of flights) { + const fl = f as ISimpleFlight; + if (fl.routeType === "Direct") legs.push(fl.leg); + else legs.push(...fl.legs); + } + const synthetic = { + routeType: "MultiLeg", + flightId: first.flightId, + flyingTime: first.flyingTime, + operatingBy: first.operatingBy, + id: flights.map((f) => (f as ISimpleFlight).id).join("+"), + status: first.status, + legs, + _childFlightIds: flights.map((f) => (f as ISimpleFlight).flightId), + } as unknown as ISimpleFlight; + return synthetic; + } + return (flights[0] ?? null) as ISimpleFlight | null; + }, [miniListFlights, selectedYmd, flights]); + // Angular's schedule details page adds a third crumb only when the // user reached the page from a search context (?request=schedule-…). // The leaf reads "{depCity} - {arrCity}" (no "Маршрут:" prefix — @@ -407,8 +453,6 @@ export const ScheduleDetailsPage: FC = ({ ); }; - const miniListCurrentFlight = (flights[0] ?? null) as ISimpleFlight | null; - return ( = ({ } > - {/* Angular renders `flight-details-full-route` once at the top for - multi-leg flights (connecting or single chained). Mirror that - for the first flight when it has more than one leg. */} - {flights[0] && flights[0].routeType !== "Direct" && ( -
- + {/* Summary header — Angular's `` renders + one `` per flight (so a connecting + itinerary shows BOTH SU 6188 and SU 6341 badges side-by-side), + a shared share/buy row, and a `` line. Followed + by `` — the connecting timeline. */} + {miniListCurrentFlight && ( +
+
+
+ {(flights as unknown as ISimpleFlight[]).map((f) => ( + + ))} +
+
+ + +
+
+ {miniListCurrentFlight.routeType !== "Direct" && ( + + )}
)}
diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index 4340e0b0..8d52448b 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -493,8 +493,6 @@ export const ScheduleSearchPage: FC = ({ params }) => { loading={outboundLoading} onFlightClick={handleFlightClick} buyUrlFor={buyUrlFor} - dateFrom={formatApiDate(outbound.dateFrom)} - dateTo={formatApiDate(outbound.dateTo)} sortMode={sortMode} onSortChange={setSortMode} hideColumnHeaders @@ -507,8 +505,6 @@ export const ScheduleSearchPage: FC = ({ params }) => { loading={inboundLoading} onFlightClick={handleFlightClick} buyUrlFor={buyUrlFor} - dateFrom={formatApiDate(inbound.dateFrom)} - dateTo={formatApiDate(inbound.dateTo)} sortMode={sortMode} onSortChange={setSortMode} hideColumnHeaders diff --git a/src/shared/hooks/useAppSettings.test.ts b/src/shared/hooks/useAppSettings.test.ts index a85b7922..2cbb5595 100644 --- a/src/shared/hooks/useAppSettings.test.ts +++ b/src/shared/hooks/useAppSettings.test.ts @@ -109,9 +109,25 @@ describe("useAppSettings", () => { const { result } = renderHook(() => useAppSettings()); await waitFor(() => expect(result.current.loading).toBe(false)); - // Defaults match Angular (flightStatusAvailableFrom: 2h, buyPeriod.min: 2h, buyPeriod.max: 72h). + // Defaults match Angular (flightStatusAvailableFrom: 2h, buyPeriod.min: 2h, buyPeriod.max: 330d). expect(result.current.flightStatusAvailableFromHours).toBe(2); expect(result.current.buyTicketMinHours).toBe(2); - expect(result.current.buyTicketMaxHours).toBe(72); + expect(result.current.buyTicketMaxHours).toBe(330 * 24); + }); + + it("parses buyPeriod.max in days (Angular API returns '330d')", async () => { + const response: AppSettingsResponse = { + uiOptions: { + buttons: { + buyTicket: { period: { min: "2h", max: "330d" } }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.buyTicketMinHours).toBe(2); + expect(result.current.buyTicketMaxHours).toBe(330 * 24); }); }); diff --git a/src/shared/hooks/useAppSettings.ts b/src/shared/hooks/useAppSettings.ts index 34ace7f0..0260ac68 100644 --- a/src/shared/hooks/useAppSettings.ts +++ b/src/shared/hooks/useAppSettings.ts @@ -10,9 +10,10 @@ const HOURS_PATTERN = /^(\d+)h$/; // boardSearchFrom: 1, boardSearchTo: 7, // scheduleSearchFrom: 1, scheduleSearchTo: 330, // flightStatusAvailableFrom: 2 (hours), buyPeriod.min: 2h, buyPeriod.max: 330d -// These are used when the UI-options API fails or is unreachable — -// keeping them aligned with Angular avoids a silent calendar-window -// mismatch in offline / error paths. +// `buyTicketMaxHours` mirrors Angular's `330d` → 330 * 24 = 7920 hours; +// without this the Buy button hides for any flight more than ~3 days +// out, which made the schedule details page look like 'no Купить +// button' for the typical search-2-weeks-ahead flow. const DEFAULTS = { onlineboardSearchFrom: 1, onlineboardSearchTo: 7, @@ -20,7 +21,7 @@ const DEFAULTS = { scheduleSearchTo: 330, flightStatusAvailableFromHours: 2, buyTicketMinHours: 2, - buyTicketMaxHours: 72, + buyTicketMaxHours: 330 * 24, } as const; function parsePattern( @@ -42,6 +43,19 @@ function parseHours(value: string | undefined, fallback: number): number { return parsePattern(value, HOURS_PATTERN, fallback); } +/** Parse an `h` or `d` duration into hours. Days expand to 24×n. + * Used for `buyPeriod.max` which Angular's settings API returns as + * `"330d"` (any other consumer in the React codebase that needs to + * accept either unit should use this rather than parseHours). */ +function parseHoursOrDays(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const hMatch = HOURS_PATTERN.exec(value); + if (hMatch?.[1]) return parseInt(hMatch[1], 10); + const dMatch = DAYS_PATTERN.exec(value); + if (dMatch?.[1]) return parseInt(dMatch[1], 10) * 24; + return fallback; +} + export interface UseAppSettingsResult { onlineboardSearchFrom: number; onlineboardSearchTo: number; @@ -84,8 +98,8 @@ export function useAppSettings(): UseAppSettingsResult { fs?.availableFrom, DEFAULTS.flightStatusAvailableFromHours, ), - buyTicketMinHours: parseHours(bt?.period?.min, DEFAULTS.buyTicketMinHours), - buyTicketMaxHours: parseHours(bt?.period?.max, DEFAULTS.buyTicketMaxHours), + buyTicketMinHours: parseHoursOrDays(bt?.period?.min, DEFAULTS.buyTicketMinHours), + buyTicketMaxHours: parseHoursOrDays(bt?.period?.max, DEFAULTS.buyTicketMaxHours), }); setLoading(false); }) diff --git a/tests/e2e/schedule-details-mini-list-scoped.spec.ts b/tests/e2e/schedule-details-mini-list-scoped.spec.ts index 3fecfe2b..a4d2a7e5 100644 --- a/tests/e2e/schedule-details-mini-list-scoped.spec.ts +++ b/tests/e2e/schedule-details-mini-list-scoped.spec.ts @@ -1,50 +1,52 @@ import { test, expect } from "@playwright/test"; -// On the schedule details page, the left mini-list must show only the -// CURRENTLY-OPEN flight's instance on each day in the [-1, +1] window — -// matching Angular's `CurrentScheduleService.getScheduleType` / -// `compareFlightsByPId` filtering. The old behaviour dumped the entire -// MOW→MMK route search into the rail (every flight number, every -// option), making the rail useless when the user came from the -// search-results list. +// On the schedule details page, the left mini-list must: +// 1. Show only the CURRENTLY-OPEN flight's instance on each day in the +// [-1, +1] window — matching Angular's +// `CurrentScheduleService.getScheduleType` / `compareFlightsByPId` +// filtering. The old behaviour dumped the entire MOW→MMK route +// search into the rail. +// 2. Render the open day's row with the SAME combined origin/ +// destination as its day-±1 siblings — for connecting itineraries +// that means Moscow→Murmansk on every row, not first-leg-only +// Moscow→St Petersburg on the highlighted row. Earlier we passed +// `flights[0]` (the first leg) as `currentFlight`, which produced +// a stub row that visually disagreed with the rest of the rail. // // Reference URL: connecting itinerary SU 6188 + SU 6341 (Moscow → St -// Petersburg → Murmansk) on 2026-04-26. Each visible day in the -// mini-list must list ≤ 1 entry — the same SU 6188 itinerary. +// Petersburg → Murmansk) on 2026-04-26. const URL = "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; -test("mini-list shows only the open flight per day, not the full route search", async ({ - page, -}) => { +test("mini-list — flat list scoped to the open SU 6188 itinerary", async ({ page }) => { await page.goto(URL); - // Wait for the mini-list to render. const miniList = page.locator(".schedule-mini-list"); await expect(miniList).toBeVisible({ timeout: 15000 }); - // Wait until the day-headers appear (they exist for both the today - // and ±1 days). Three day headers are expected total. - const dayHeaders = miniList.locator("[data-testid^='mini-list-day-header-']"); - await expect(dayHeaders).toHaveCount(3); + // Day-grouping accordions were removed — rows are flat. + await expect(miniList.locator("[data-testid^='mini-list-day-header-']")).toHaveCount(0); - // The expanded body must contain at most one flight entry — the - // open SU 6188 itinerary. The old behaviour rendered 4+ entries - // (every MOW-MMK option for that day). - const openBody = miniList - .locator("[data-testid^='mini-list-day-']:not([data-testid*='header'])") - .first(); - await expect(openBody).toBeVisible({ timeout: 10000 }); - // Mini-list items use SU 6188 in their visible label. - const items = openBody.locator(".flights-mini-list-item, [class*='mini-list'] [class*='flight']"); - // Loose assertion — there should be at MOST one entry per day, and - // the visible text must include 'SU 6188' (NOT a different - // route-mate flight number). - const text = (await openBody.innerText()).replace(/\s+/g, " "); - expect(text).toContain("SU 6188"); - // Sanity: should NOT contain other Sunday MOW-MMK flight numbers - // that the old listing pulled in (SU 6190 / SU 6699 are typical). - expect(text).not.toMatch(/SU\s*6190/); - expect(text).not.toMatch(/SU\s*6699/); + const items = miniList.locator("[data-testid^='mini-list-item-']"); + await expect(items.first()).toBeVisible({ timeout: 10000 }); + // [-1, 0, +1] window for a daily itinerary: 3 rows, all SU 6188. + await expect(items).toHaveCount(3); + + // Every row must reference SU 6188 and must NOT contain unrelated + // MOW-MMK route-mates (SU 6190 / SU 6699 used to leak in when the + // rail was unfiltered). + const railText = (await miniList.innerText()).replace(/\s+/g, " "); + expect(railText).toContain("SU 6188"); + expect(railText).not.toMatch(/SU\s*6190/); + expect(railText).not.toMatch(/SU\s*6699/); + + // The current row (highlighted) must show the same combined + // destination as its siblings — Murmansk, not St Petersburg + // (otherwise the highlighted row collapses to the first leg only). + const cityCount = (railText.match(/Мурманск/g) ?? []).length; + expect(cityCount).toBeGreaterThanOrEqual(3); + // No row should show St Petersburg as an arrival (that would mean + // the current row regressed to first-leg-only). + expect(railText).not.toContain("Санкт-Петербург"); }); diff --git a/tests/e2e/schedule-details-summary-header.spec.ts b/tests/e2e/schedule-details-summary-header.spec.ts new file mode 100644 index 00000000..4db86634 --- /dev/null +++ b/tests/e2e/schedule-details-summary-header.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +// Schedule details page must render Angular's `` +// summary block between the day-tabs strip and the per-leg cards: +// +// • One `details-header-badge` per flight in the URL (so a connecting +// itinerary like SU 6188 + SU 6341 shows BOTH badges, not just one). +// • A right-side cluster with the share/buy/last-update controls. +// • For multi-leg trips, a `flight-details-full-route` timeline that +// spans every leg, with formatted times (`00:30`, not raw ISO). +// +// This test pins those guarantees so the page can't regress to "no +// summary header" or "first-leg-only badge / raw ISO timeline" again. + +const URL = + "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; + +test("summary header — both badges + last-update + formatted full-route timeline", async ({ page }) => { + await page.goto(URL); + + const summary = page.locator(".schedule-details__summary"); + await expect(summary).toBeVisible({ timeout: 15000 }); + + // Both flight-number badges present (SU 6188 + SU 6341). + const badges = summary.locator(".details-header-badge"); + await expect(badges).toHaveCount(2); + await expect(summary).toContainText("SU 6188"); + await expect(summary).toContainText("SU 6341"); + + // Last-update line is rendered (right-side cluster). + await expect(summary).toContainText(/Последнее обновление:\s*\d{2}:\d{2}/); + + // Full-route timeline is present and shows formatted clock times + // (00:30 / 02:00 / 06:30 / 08:20) — NOT raw ISO timestamps with + // T-separators or '+03:00' offsets, which was a regression from + // using `.local` instead of `.localTime`. + const timeline = summary.locator(".full-route-timeline"); + await expect(timeline).toBeVisible(); + const timelineText = (await timeline.innerText()).replace(/\s+/g, " "); + expect(timelineText).toMatch(/\b00:30\b/); + expect(timelineText).toMatch(/\b02:00\b/); + expect(timelineText).toMatch(/\b08:20\b/); + expect(timelineText).not.toMatch(/2026-04-\d{2}T/); + expect(timelineText).not.toContain("+03:00"); +});