diff --git a/src/ui/layout/PageLayout.test.tsx b/src/ui/layout/PageLayout.test.tsx new file mode 100644 index 00000000..8419000f --- /dev/null +++ b/src/ui/layout/PageLayout.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +/** + * Tests for PageLayout sticky behavior per TZ §4.1.13 Table 22: + * + * Desktop/tablet: + * - Breadcrumbs: NOT sticky (position: relative on __header). + * - Page title + section-switcher: NOT sticky (inside __header, relative). + * - Filter (left column): sticky on desktop (≥1051px). + * On tablet (≤1050px, stacked layout) layout reverts to relative — matches Angular reference. + * - Day-tabs / Week-tabs (stickyContent): sticky on desktop + tablet (≥641px). + * - On mobile (≤640px): nothing sticky. + * + * NOTE: jsdom does not compute CSS layout, so assertions here are on DOM structure + * (class names, data attributes) rather than computed styles. + * Visual regression (actual sticky scroll behavior) must be verified separately in + * a real browser environment. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PageLayout } from "./PageLayout.js"; + +// Suppress SCSS imports +vi.mock("./PageLayout.scss", () => ({})); +vi.mock("./Breadcrumbs.scss", () => ({})); +vi.mock("./ScrollUpButton.scss", () => ({})); + +// Mock sub-components +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("./ScrollUpButton.js", () => ({ + ScrollUpButton: () => null, +})); + +vi.mock("./Breadcrumbs.js", () => ({ + Breadcrumbs: ({ items }: { items: { label: string }[] }) => ( + + ), +})); + +describe("4.1.13-R-Sticky: PageLayout DOM structure per TZ Table 22", () => { + it("header row is NOT sticky — __header has position:relative semantics (not sticky class)", () => { + render(Title} />); + const header = document.querySelector(".page-layout__header"); + expect(header).toBeTruthy(); + // The header must NOT carry a sticky class or inline sticky style + expect(header?.classList.contains("sticky")).toBe(false); + expect((header as HTMLElement | null)?.style?.position).not.toBe("sticky"); + }); + + it("filter left column has the CSS class that carries sticky positioning on desktop", () => { + // jsdom cannot evaluate @media queries, but we verify the class is applied + // so the CSS rule (position: sticky at desktop) can activate in the browser. + render(Filter} />); + const filter = screen.getByTestId("filter"); + const aside = filter.closest("aside"); + expect(aside).toBeTruthy(); + expect(aside?.classList.contains("page-layout__column-left")).toBe(true); + // NOTE: Visual regression test required in a real browser to assert position:sticky + // is active at viewport ≥1051px and deactivated at ≤1050px (matching Angular reference). + }); + + it("stickyContent is wrapped in __sticky-content which carries sticky positioning at ≥641px", () => { + render( + Day Tabs} + />, + ); + const dayTabs = screen.getByTestId("day-tabs"); + const stickyWrapper = dayTabs.closest(".page-layout__sticky-content"); + expect(stickyWrapper).toBeTruthy(); + // NOTE: The CSS applies position:sticky via @include screen.gt-mobile (≥641px). + // Visual regression required in a real browser to confirm sticky is active on desktop/tablet. + }); + + it("scroll-overlay is rendered when stickyContent is provided", () => { + render( + Tabs} />, + ); + const overlay = document.querySelector(".page-layout__scroll-overlay"); + expect(overlay).toBeTruthy(); + expect(overlay?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("scroll-overlay is NOT rendered when no stickyContent", () => { + render(); + const overlay = document.querySelector(".page-layout__scroll-overlay"); + expect(overlay).toBeNull(); + }); + + it("stickyContent is NOT rendered when not provided", () => { + render(); + const stickyWrapper = document.querySelector(".page-layout__sticky-content"); + expect(stickyWrapper).toBeNull(); + }); + + it("breadcrumbs are rendered inside __title (header, not sticky)", () => { + render( + My Page} + />, + ); + const breadcrumbs = screen.getByTestId("breadcrumbs"); + // Must be inside the header, not in the sticky content area + const headerRight = breadcrumbs.closest(".page-layout__header-right"); + expect(headerRight).toBeTruthy(); + const stickyWrapper = breadcrumbs.closest(".page-layout__sticky-content"); + expect(stickyWrapper).toBeNull(); + }); + + it("title content is inside __header-right (not sticky)", () => { + render(Расписание} />); + const title = screen.getByTestId("page-title"); + const headerRight = title.closest(".page-layout__header-right"); + expect(headerRight).toBeTruthy(); + // Confirm NOT inside stickyContent + const stickyWrapper = title.closest(".page-layout__sticky-content"); + expect(stickyWrapper).toBeNull(); + }); + + it("headerLeft content is inside __header-left (not sticky)", () => { + render( + Tabs} />, + ); + const tabs = screen.getByTestId("page-tabs"); + const headerLeft = tabs.closest(".page-layout__header-left"); + expect(headerLeft).toBeTruthy(); + const stickyWrapper = tabs.closest(".page-layout__sticky-content"); + expect(stickyWrapper).toBeNull(); + }); + + it("main content renders inside __column-right (scrollable content area)", () => { + render( + +
Flights
+
, + ); + const flightList = screen.getByTestId("flight-list"); + const columnRight = flightList.closest(".page-layout__column-right"); + expect(columnRight).toBeTruthy(); + // Confirm it's in the content section, not the header + const contentRow = flightList.closest(".page-layout__content"); + expect(contentRow).toBeTruthy(); + }); +}); diff --git a/src/ui/layout/ScrollUpButton.test.tsx b/src/ui/layout/ScrollUpButton.test.tsx new file mode 100644 index 00000000..5f5875ae --- /dev/null +++ b/src/ui/layout/ScrollUpButton.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +/** + * Tests for ScrollUpButton per TZ §4.1.14 Table 22: + * - Desktop/tablet: scroll-up button appears when breadcrumbs + title + switcher + tabs + * scroll out of view (approximated by a scroll threshold). + * - Mobile: scroll-up button appears when list scrolls past screen bottom. + * + * NOTE: Visual regression (sticky CSS position) must be verified separately in + * a browser environment. jsdom does not compute CSS layout. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { ScrollUpButton } from "./ScrollUpButton.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("./ScrollUpButton.scss", () => ({})); + +// Helper to fire a scroll event at a given scrollY +function fireScrollAt(scrollY: number) { + Object.defineProperty(window, "scrollY", { value: scrollY, writable: true, configurable: true }); + window.dispatchEvent(new Event("scroll")); +} + +afterEach(() => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); +}); + +describe("4.1.14-R-ScrollUp: ScrollUpButton visibility per TZ Table 22", () => { + it("button is hidden on initial render (scrollY = 0)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + expect(screen.queryByTestId("scroll-up-button")).toBeNull(); + }); + + it("button is hidden when scroll is below threshold (scrollY = 100)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(100); }); + expect(screen.queryByTestId("scroll-up-button")).toBeNull(); + }); + + it("button is hidden at exactly the threshold boundary (scrollY = 300)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(300); }); + // threshold is > 300, so at 300 it should still be hidden + expect(screen.queryByTestId("scroll-up-button")).toBeNull(); + }); + + it("button appears when scrolled past threshold (scrollY = 301)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(301); }); + expect(screen.getByTestId("scroll-up-button")).toBeTruthy(); + }); + + it("button appears when scrolled well past threshold (scrollY = 800)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(800); }); + expect(screen.getByTestId("scroll-up-button")).toBeTruthy(); + }); + + it("button disappears after scrolling back up below threshold", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(800); }); + expect(screen.getByTestId("scroll-up-button")).toBeTruthy(); + act(() => { fireScrollAt(50); }); + expect(screen.queryByTestId("scroll-up-button")).toBeNull(); + }); + + it("button has correct aria-label for accessibility", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(400); }); + const btn = screen.getByTestId("scroll-up-button"); + expect(btn.getAttribute("aria-label")).toBe("SHARED.A11Y-SCROLL-TO-TOP"); + }); + + it("button is type=button (not submit) to avoid accidental form submission", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + render(); + act(() => { fireScrollAt(400); }); + const btn = screen.getByTestId("scroll-up-button"); + expect(btn.getAttribute("type")).toBe("button"); + }); + + it("scroll listener is cleaned up on unmount (no memory leaks)", () => { + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = render(); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); + removeEventListenerSpy.mockRestore(); + }); +});