diff --git a/playwright.config.ts b/playwright.config.ts index 05e91125..552f74fa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,7 +22,6 @@ const QUARANTINED_PATTERNS = [ "Online Board.*flight number clear button", "Online Board.*route search results page hydrates", "TIRREDESIGN-8.*Onlineboard day-tabs", - "Onlineboard time-range filter.*TIRREDESIGN-11", "P1.*Table 7: breadcrumbs on search pages.*Schedule route", "Schedule date-range picker.*single click snaps to Mon-Sun", "Schedule date-range picker.*next-month bleed-in", diff --git a/src/routes/[lang]/onlineboard/_guards.test.ts b/src/routes/[lang]/onlineboard/_guards.test.ts index dcc9c46f..ebc8bb9e 100644 --- a/src/routes/[lang]/onlineboard/_guards.test.ts +++ b/src/routes/[lang]/onlineboard/_guards.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { boardDateRedirect } from "./_guards.js"; +import { boardDateRedirect, boardSearchRedirect } from "./_guards.js"; describe("boardDateRedirect (clock frozen 2026-05-15)", () => { beforeEach(() => { @@ -40,3 +40,53 @@ describe("boardDateRedirect (clock frozen 2026-05-15)", () => { expect(boardDateRedirect("en-us", "20260101")).toBe("/en-us/onlineboard"); }); }); + +describe("boardSearchRedirect time-range guard", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12)); // May 15 2026 noon + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows a valid time-range suffix", () => { + expect( + boardSearchRedirect("ru-ru", { + date: "20260515", + timeFrom: "1400", + timeTo: "1800", + }), + ).toBeNull(); + }); + + it("allows 2400 as the upper bound", () => { + expect( + boardSearchRedirect("ru-ru", { + date: "20260515", + timeFrom: "2359", + timeTo: "2400", + }), + ).toBeNull(); + }); + + it("redirects when the time range is reversed", () => { + expect( + boardSearchRedirect("ru-ru", { + date: "20260515", + timeFrom: "1800", + timeTo: "1400", + }), + ).toBe("/ru-ru/onlineboard"); + }); + + it("redirects when a time component is outside HHmm bounds", () => { + expect( + boardSearchRedirect("ru-ru", { + date: "20260515", + timeFrom: "1460", + timeTo: "1800", + }), + ).toBe("/ru-ru/onlineboard"); + }); +}); diff --git a/src/routes/[lang]/onlineboard/_guards.ts b/src/routes/[lang]/onlineboard/_guards.ts index 22a39215..1ea03a5d 100644 --- a/src/routes/[lang]/onlineboard/_guards.ts +++ b/src/routes/[lang]/onlineboard/_guards.ts @@ -17,3 +17,46 @@ export function boardDateRedirect(locale: string, yyyymmdd: string): string | nu if (!isInBoardWindow(yyyymmdd)) return `/${locale}/onlineboard`; return null; } + +function hhmmToMinutes(value: string): number | null { + if (!/^\d{4}$/.test(value)) return null; + const hours = Number(value.slice(0, 2)); + const minutes = Number(value.slice(2, 4)); + if (hours === 24 && minutes === 0) return 24 * 60; + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return hours * 60 + minutes; +} + +/** + * Angular parity for Online-Board date + optional `Время рейса` URL guards. + * + * A missing time range is valid. When a range is present, both ends must be + * valid HHmm values and `timeFrom` must be strictly before `timeTo`. + * `2400` is accepted as the upper bound, matching Angular/moment behavior. + */ +export function boardSearchRedirect( + locale: string, + params: { date: string; timeFrom?: string; timeTo?: string }, +): string | null { + const dateRedirect = boardDateRedirect(locale, params.date); + if (dateRedirect) return dateRedirect; + + const hasFrom = params.timeFrom !== undefined; + const hasTo = params.timeTo !== undefined; + if (!hasFrom && !hasTo) return null; + if (!hasFrom || !hasTo) return `/${locale}/onlineboard`; + + const timeFrom = params.timeFrom; + const timeTo = params.timeTo; + if (timeFrom === undefined || timeTo === undefined) { + return `/${locale}/onlineboard`; + } + + const from = hhmmToMinutes(timeFrom); + const to = hhmmToMinutes(timeTo); + if (from === null || to === null || from >= to) { + return `/${locale}/onlineboard`; + } + + return null; +} diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx index 3ff4fb1e..297565f9 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx @@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; import { getEnv } from "@/env/index.js"; import { useCityName } from "@/shared/hooks/useDictionaries.js"; -import { boardDateRedirect } from "../../_guards.js"; +import { boardSearchRedirect } from "../../_guards.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -35,7 +35,7 @@ export default function ArrivalSearchPage(): JSX.Element { if (!parsed) return ; - const redirect = boardDateRedirect(locale, parsed.date); + const redirect = boardSearchRedirect(locale, parsed); if (redirect) return ; const canonicalOrigin = getEnv().PROD_ORIGIN; diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx index 44c3b62d..e9a55040 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx @@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; import { getEnv } from "@/env/index.js"; import { useCityName } from "@/shared/hooks/useDictionaries.js"; -import { boardDateRedirect } from "../../_guards.js"; +import { boardSearchRedirect } from "../../_guards.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -35,7 +35,7 @@ export default function DepartureSearchPage(): JSX.Element { if (!parsed) return ; - const redirect = boardDateRedirect(locale, parsed.date); + const redirect = boardSearchRedirect(locale, parsed); if (redirect) return ; const canonicalOrigin = getEnv().PROD_ORIGIN; diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx index 27bfed6c..bc42032e 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx @@ -105,6 +105,18 @@ describe("RouteSearchPage — date-window guard", () => { expect(mockNavigate).toHaveBeenCalledWith("/en-us/onlineboard"); }); + it("renders normally for a valid time-range suffix", () => { + renderWithParams("SVO-LED-20260515-14001800"); + expect(screen.getByTestId("seo-head")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("redirects when the time range is invalid", () => { + renderWithParams("SVO-LED-20260515-18001400"); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard"); + }); + it("shows 404 for a malformed URL (no date)", () => { renderWithParams("SVO-LED"); expect(screen.getByTestId("error-404")).toBeTruthy(); diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx index b28331f4..f3bad49c 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx @@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; import { getEnv } from "@/env/index.js"; import { useCityName } from "@/shared/hooks/useDictionaries.js"; -import { boardDateRedirect } from "../../_guards.js"; +import { boardSearchRedirect } from "../../_guards.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -36,7 +36,7 @@ export default function RouteSearchPage(): JSX.Element { if (!parsed) return ; - const redirect = boardDateRedirect(locale, parsed.date); + const redirect = boardSearchRedirect(locale, parsed); if (redirect) return ; const canonicalOrigin = getEnv().PROD_ORIGIN; diff --git a/tests/e2e/onlineboard-time-filter.spec.ts b/tests/e2e/onlineboard-time-filter.spec.ts index e2a82a0c..b080f16a 100644 --- a/tests/e2e/onlineboard-time-filter.spec.ts +++ b/tests/e2e/onlineboard-time-filter.spec.ts @@ -8,7 +8,18 @@ import { test, expect } from "./fixtures/console-gate"; // 3. Assert URL gained the `-{timeFrom}{timeTo}` suffix // 4. Assert the rendered list shrank to flights inside the new window -const ROUTE_URL = "/ru-ru/onlineboard/route/MOW-LED-20260423"; +function todayYyyymmdd(): string { + const today = new Date(); + return [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, "0"), + String(today.getDate()).padStart(2, "0"), + ].join(""); +} + +function routeUrl(): string { + return `/ru-ru/onlineboard/route/MOW-LED-${todayYyyymmdd()}`; +} test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { test("URL with time-range suffix filters the list (URL → state path)", async ({ @@ -16,7 +27,8 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { consoleMessages, }) => { // Baseline: no filter - await page.goto(ROUTE_URL); + const baseUrl = routeUrl(); + await page.goto(baseUrl); await expect(page.locator(".flight-card").first()).toBeVisible({ timeout: 15000, }); @@ -24,7 +36,16 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { expect(baseline).toBeGreaterThan(15); // Filtered: 14:00–18:00 - await page.goto(`${ROUTE_URL}-14001800`); + const filteredRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return ( + url.pathname.endsWith("/api/flights/v1.1/ru/board") && + url.searchParams.get("timeFrom") === "14:00:00" && + url.searchParams.get("timeTo") === "18:00:00" + ); + }); + await page.goto(`${baseUrl}-14001800`); + await filteredRequest; await expect(page.locator(".flight-card").first()).toBeVisible({ timeout: 15000, }); @@ -41,32 +62,18 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { page, consoleMessages, }) => { - await page.goto(ROUTE_URL); + await page.goto(routeUrl()); await expect(page.locator(".flight-card").first()).toBeVisible({ timeout: 15000, }); - // Drag the start thumb roughly to the 50 % mark (= 12:00). + // Click the track near the 50 % mark. PrimeReact moves the nearest + // handle, which is the start handle while the range is 00:00-24:00. const slider = page.locator(".p-slider").first(); - const startThumb = page.locator(".p-slider-handle-start").first(); const sliderBox = await slider.boundingBox(); - const thumbBox = await startThumb.boundingBox(); - expect(sliderBox && thumbBox).toBeTruthy(); - if (!sliderBox || !thumbBox) return; - - const fromX = thumbBox.x + thumbBox.width / 2; - const fromY = thumbBox.y + thumbBox.height / 2; - // 50 % position along the track - const targetX = sliderBox.x + sliderBox.width * 0.5; - - await page.mouse.move(fromX, fromY); - await page.mouse.down(); - // Step the move a few times so PrimeReact's mousemove handler picks it up. - for (let i = 1; i <= 10; i++) { - const x = fromX + ((targetX - fromX) * i) / 10; - await page.mouse.move(x, fromY); - } - await page.mouse.up(); + expect(sliderBox).toBeTruthy(); + if (!sliderBox) return; + await slider.click({ position: { x: sliderBox.width * 0.5, y: sliderBox.height / 2 } }); // The thumb should have left 0 — exact value depends on snap-to-step. const labelAfterDrag = await page @@ -79,6 +86,6 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { await page.locator('[data-testid="search-submit"]').click(); // URL must have grown a time suffix. - await expect(page).toHaveURL(/\/onlineboard\/route\/MOW-LED-20260423-\d{8}/); + await expect(page).toHaveURL(/\/onlineboard\/route\/MOW-LED-\d{8}-\d{8}/); }); });