Add P1 e2e coverage: URL guards + breadcrumbs + cross-section nav per TZ 4.1.2/4/8

This commit is contained in:
2026-04-21 18:13:24 +03:00
parent ef0e1e38e5
commit e935596813
+260
View File
@@ -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, "Расписание", <route heading>] → 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;
},
);
});