Add P1 e2e coverage: URL guards + breadcrumbs + cross-section nav per TZ 4.1.2/4/8
This commit is contained in:
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user