diff --git a/tests/e2e/p1-urls-nav.spec.ts b/tests/e2e/p1-urls-nav.spec.ts new file mode 100644 index 00000000..783a2b23 --- /dev/null +++ b/tests/e2e/p1-urls-nav.spec.ts @@ -0,0 +1,260 @@ +/** + * P1 e2e coverage: URL guards, breadcrumbs, and cross-section navigation. + * + * TZ references: + * 4.1.2-R11 — out-of-range dates redirect to section start page + * 4.1.2-R10 — unknown URL renders 404 + * Table 7 — breadcrumb structure per page tier + * Table 10 — cross-section filter carry-over Board ↔ Schedule + * §4.1.1 ¶12 — Flight-Map filter is independent (no cross-section carry-over) + */ + +import { test, expect } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Format a Date as yyyymmdd (no separators). */ +function fmt(d: Date): string { + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; +} + +/** Return a date that is `n` days offset from today. */ +function daysFromNow(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() + n); + return d; +} + +// --------------------------------------------------------------------------- +// 4.1.2-R11: Out-of-range date redirects to section start page +// --------------------------------------------------------------------------- + +test.describe("P1 — 4.1.2-R11: out-of-range dates redirect to start page", () => { + test("Online-Board flight URL with date +30 days (beyond +14 window) redirects to /onlineboard", async ({ + page, + }) => { + const far = fmt(daysFromNow(30)); + await page.goto(`/ru/onlineboard/flight/SU1234-${far}`); + await page.waitForLoadState("domcontentloaded"); + // Must land on the start page (not the search page). + // The server normalises /ru → /ru-ru, so accept both locale forms. + await expect(page).toHaveURL(/\/ru(-ru)?\/onlineboard(\?.*)?$/, { timeout: 10000 }); + await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({ + timeout: 10000, + }); + }); + + test("Online-Board route URL with date -5 days (before -1 window) redirects to /onlineboard", async ({ + page, + }) => { + const past = fmt(daysFromNow(-5)); + await page.goto(`/ru/onlineboard/route/MOW-LED-${past}`); + await page.waitForLoadState("domcontentloaded"); + await expect(page).toHaveURL(/\/ru(-ru)?\/onlineboard(\?.*)?$/, { timeout: 10000 }); + await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({ + timeout: 10000, + }); + }); + + test("Schedule route URL with date +400 days (beyond +330 window) redirects to /schedule", async ({ + page, + }) => { + const farFrom = fmt(daysFromNow(400)); + const farTo = fmt(daysFromNow(407)); + await page.goto(`/ru/schedule/route/MOW-LED-${farFrom}-${farTo}`); + await page.waitForLoadState("domcontentloaded"); + await expect(page).toHaveURL(/\/ru(-ru)?\/schedule(\?.*)?$/, { timeout: 10000 }); + await expect(page.locator('[data-testid="schedule-start"]')).toBeVisible({ + timeout: 10000, + }); + }); +}); + +// --------------------------------------------------------------------------- +// 4.1.2-R10: Unknown URL → 404 +// --------------------------------------------------------------------------- + +test.describe("P1 — 4.1.2-R10: unknown URL shows 404", () => { + test("/ru/nonexistent does not crash — shows error or empty body", async ({ + page, + }) => { + // Matches the existing pattern in navigation.spec.ts: the app handles + // unknown routes gracefully (404 page or redirect, not a JS crash). + await page.goto("/ru/nonexistent"); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator("body")).not.toBeEmpty(); + }); + + test("/error/404 renders 404 content (client-side navigation)", async ({ + page, + }) => { + // Direct URL navigate to the error route produces blank SSR output; + // client-side assign is the established pattern (see navigation.spec.ts). + await page.goto("/ru/onlineboard"); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({ + timeout: 10000, + }); + + await page.evaluate(() => window.location.assign("/error/404")); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator(".error-page__code")).toHaveText("404", { + timeout: 10000, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Table 7: Breadcrumbs — start pages show Home only (1 item) +// --------------------------------------------------------------------------- + +test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { + test("Online-Board start page has exactly 1 breadcrumb (Home)", async ({ + page, + }) => { + await page.goto("/ru/onlineboard"); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({ + timeout: 10000, + }); + + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(1); + }); + + test("Schedule start page has exactly 1 breadcrumb (Home)", async ({ + page, + }) => { + await page.goto("/ru/schedule"); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator('[data-testid="schedule-start"]')).toBeVisible({ + timeout: 10000, + }); + + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(1); + }); + + test("Flight-Map start page has exactly 1 breadcrumb (Home)", async ({ + page, + }) => { + await page.goto("/ru/flights-map"); + await page.waitForLoadState("domcontentloaded"); + + // Feature flag may disable the map; either state is acceptable for + // this breadcrumb test — the breadcrumb renders in both cases. + const mapStart = page.locator('[data-testid="flights-map-start"]'); + const mapDisabled = page.locator('[data-testid="flights-map-disabled"]'); + await expect(mapStart.or(mapDisabled)).toBeVisible({ timeout: 10000 }); + + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(1); + }); +}); + +// --------------------------------------------------------------------------- +// Table 7: Breadcrumbs — search pages show Home + Section (2 items) +// Online-Board search: [Home, "Онлайн-Табло"] +// Schedule search: [Home, "Расписание", ] → 3 items +// --------------------------------------------------------------------------- + +test.describe("P1 — Table 7: breadcrumbs on search pages", () => { + test("Online-Board route search page has 2 breadcrumbs (Home + Section)", async ({ + page, + }) => { + // Use an in-window date so the guard lets the page through. + const today = fmt(new Date()); + await page.goto(`/ru/onlineboard/route/MOW-LED-${today}`); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator("body")).not.toBeEmpty(); + + // Wait for the breadcrumbs to hydrate (lazy load means SSR crumbs may not + // be present immediately). + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(2, { timeout: 10000 }); + }); + + test("Online-Board flight search page has 2 breadcrumbs (Home + Section)", async ({ + page, + }) => { + const today = fmt(new Date()); + await page.goto(`/ru/onlineboard/flight/SU0100-${today}`); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator("body")).not.toBeEmpty(); + + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(2, { timeout: 10000 }); + }); + + test("Schedule route search page has 3 breadcrumbs (Home + Section + Route heading)", async ({ + page, + }) => { + const today = fmt(new Date()); + const weekAhead = fmt(daysFromNow(7)); + await page.goto(`/ru/schedule/route/MOW-LED-${today}-${weekAhead}`); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator("body")).not.toBeEmpty(); + + const crumbs = page.locator('[data-testid="breadcrumbs"] li'); + await expect(crumbs).toHaveCount(3, { timeout: 10000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-section filter carry-over — TZ Table 10 +// Requires city-picker interaction and live API; skipped with TODO markers. +// --------------------------------------------------------------------------- + +test.describe("P1 — Table 10: cross-section filter carry-over Board ↔ Schedule", () => { + // TODO: These tests require filling the city-picker (autocomplete with live + // API) and submitting a search to write the cross-section store. The store + // state is then read when navigating to the other section. This is too + // fiddly to implement reliably without network stubs / API availability + // guarantees in CI. Re-enable when a mock API or recorded fixture is in place. + + test.fixme( + "navigating from Online-Board results to Schedule preserves departure/arrival cities", + async ({ page }) => { + // 1. Search on Online-Board (route tab) → MOW-LED. + // 2. Navigate to /ru/schedule. + // 3. Schedule start page filter should be pre-filled with MOW/LED via + // the cross-section store (crossSectionNavigation.ts). + // Skipped: needs city-picker interaction + live API for autocomplete. + void page; + }, + ); + + test.fixme( + "navigating from Schedule results to Online-Board preserves departure/arrival cities", + async ({ page }) => { + // 1. Search on Schedule (route tab) → MOW-LED. + // 2. Navigate to /ru/onlineboard. + // 3. Online-Board start page filter should be pre-filled with MOW/LED. + // Skipped: needs city-picker interaction + live API for autocomplete. + void page; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Flight-Map filter independence — TZ §4.1.1 ¶12 +// Requires city-picker interaction; skipped with TODO markers. +// --------------------------------------------------------------------------- + +test.describe("P1 — §4.1.1 ¶12: Flight-Map filter is independent (no carry-over)", () => { + // TODO: Verify that searching on Online-Board does NOT pre-fill the + // Flight-Map filter, and vice-versa. Requires live city-picker + submit. + // Re-enable when network stubs are available. + + test.fixme( + "Online-Board search does not affect Flight-Map departure city", + async ({ page }) => { + // 1. Search on Online-Board → MOW-LED. + // 2. Navigate to /ru/flights-map. + // 3. Flight-Map departure input should NOT show MOW (store is separate). + void page; + }, + ); +});