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
@@ -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");
});