Schedule details: summary header, fix mini-list duplicates, fix timeline times

The schedule details page now renders Angular's <schedule-details-header>
summary block (badges per flight + share/last-update + full-route
timeline) between the day-tabs strip and the per-leg cards, so a
connecting itinerary like SU 6188 + SU 6341 surfaces both flight
numbers and the combined Moscow→Murmansk timeline up top instead of
jumping straight from the date tabs to the first-leg detail card.

Mini-list duplicate fix: when the sibling search returned 0 matches
the fallback path used to leak the URL-parsed per-leg breakdown into
the rail, producing a first-leg-only row stacked next to the
synthesized combined row. Now the fallback is empty — the mini-list
just shows the (synthesized) current flight on its own.

FullRouteTimeline now uses the API's pre-formatted .localTime instead
of the full ISO .local, so 00:30 / 02:00 shows up instead of
2026-04-26T00:30:00+03:00.

useAppSettings.buyTicketMaxHours: parse <n>d as well as <n>h (Angular
ships 330d for buyPeriod.max). Without this the Buy button hides for
any flight more than ~3 days out.

Plumbed sortMode/onSortChange/hideColumnHeaders through DayGroupedFlightList
so the sticky ScheduleColumnHeaders and the inner list stay in sync
(removes 2 TS errors in ScheduleSearchPage).
This commit is contained in:
2026-04-23 16:53:38 +03:00
parent 7324b4c03a
commit cbced8d4b6
10 changed files with 286 additions and 96 deletions
@@ -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;
}
/**
@@ -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<DayGroupedFlightListProps> = ({
onFlightClick,
buyUrlFor,
initialCurrentFlightId,
sortMode: sortModeProp,
onSortChange,
hideColumnHeaders,
}) => {
const { language } = useLocale();
const { t } = useTranslation();
const [sortMode, setSortMode] = useState<SortMode>("none");
const [internalSortMode, setInternalSortMode] = useState<SortMode>("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<DayGroupedFlightListProps> = ({
if (groups.length === 1) {
return (
<div className="day-grouped-flight-list">
{headerRow}
{hideColumnHeaders ? null : headerRow}
<FlightList
flights={flights}
loading={false}
@@ -297,7 +307,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
return (
<div className="day-grouped-flight-list" data-testid="day-grouped-flight-list">
{headerRow}
{hideColumnHeaders ? null : headerRow}
{groups.map((g) => {
const weekday = weekdayFmt.format(g.parsed);
const dayMonth = dayMonthFmt.format(g.parsed);
@@ -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 `<details-header-badges>` (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;
@@ -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;
}) => <a href={to} {...props}>{children}</a>,
Link: forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }
>(({ children, to, ...props }, ref) => (
<a ref={ref} href={to} {...props}>
{children}
</a>
)),
}));
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", () => {
@@ -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<ScheduleDetailsPageProps> = ({
// 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<ScheduleDetailsPageProps> = ({
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<ScheduleDetailsPageProps> = ({
: `${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<ISimpleFlight | null>(() => {
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<ScheduleDetailsPageProps> = ({
);
};
const miniListCurrentFlight = (flights[0] ?? null) as ISimpleFlight | null;
return (
<PageLayout
headerLeft={
@@ -444,15 +488,35 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
}
>
<SeoHead {...seoProps} />
{/* 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" && (
<section className="frame">
<FullRouteTimeline
legs={getLegs(flights[0]) as IFlightLeg[]}
viewType="Schedule"
/>
{/* Summary header — Angular's `<schedule-details-header>` renders
one `<details-header-badge>` per flight (so a connecting
itinerary shows BOTH SU 6188 and SU 6341 badges side-by-side),
a shared share/buy row, and a `<last-update>` line. Followed
by `<flight-details-full-route>` — the connecting timeline. */}
{miniListCurrentFlight && (
<section className="frame schedule-details__summary">
<div className="schedule-details__summary-row">
<div className="schedule-details__summary-badges">
{(flights as unknown as ISimpleFlight[]).map((f) => (
<DetailsHeaderBadge key={f.id} flight={f} locale={locale} />
))}
</div>
<div className="schedule-details__summary-actions">
<FlightActions
flight={miniListCurrentFlight}
locale={locale}
showStatus={miniListCurrentFlight.routeType === "Direct"}
showRegister={false}
/>
<LastUpdate flight={miniListCurrentFlight} locale={locale} />
</div>
</div>
{miniListCurrentFlight.routeType !== "Direct" && (
<FullRouteTimeline
legs={getLegs(miniListCurrentFlight) as IFlightLeg[]}
viewType="Schedule"
/>
)}
</section>
)}
<section className="frame">
@@ -493,8 +493,6 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ params }) => {
loading={inboundLoading}
onFlightClick={handleFlightClick}
buyUrlFor={buyUrlFor}
dateFrom={formatApiDate(inbound.dateFrom)}
dateTo={formatApiDate(inbound.dateTo)}
sortMode={sortMode}
onSortChange={setSortMode}
hideColumnHeaders
+18 -2
View File
@@ -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);
});
});
+20 -6
View File
@@ -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 `<n>h` or `<n>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);
})
@@ -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("Санкт-Петербург");
});
@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
// Schedule details page must render Angular's `<schedule-details-header>`
// 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");
});