diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index be038dce..116478c3 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -7,8 +7,8 @@ * @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js"; import type { PopularRequest } from "@/features/popular-requests/types.js"; import { @@ -19,6 +19,31 @@ import { type BoardFilterSnapshot, } from "@/shared/state/crossSectionNavigation.js"; +// --------------------------------------------------------------------------- +// Hook mocks for geo + viewport — controlled per test +// --------------------------------------------------------------------------- + +// Capture the latest onCity callback so individual tests can invoke it. +let capturedOnCity: ((code: string) => void) | null = null; +let capturedShouldApply: (() => boolean) | null = null; +let geoMockEnabled = false; + +vi.mock("@/shared/hooks/useGeoCityDefault.js", () => ({ + useGeoCityDefault: (opts: { shouldApply: () => boolean; onCity: (code: string) => void }) => { + capturedOnCity = opts.onCity; + capturedShouldApply = opts.shouldApply; + // If the test enabled geo, fire it synchronously so we don't need async. + if (geoMockEnabled && opts.shouldApply()) { + opts.onCity("MOW"); + } + }, +})); + +let isMobileMockValue = false; +vi.mock("@/shared/hooks/useIsMobileViewport.js", () => ({ + useIsMobileViewport: () => isMobileMockValue, +})); + const mockNavigate = vi.fn(); vi.mock("@modern-js/runtime/router", () => ({ @@ -242,3 +267,135 @@ describe("4.1.8: Online-Board hydrates from cross-section store on mount", () => expect(depInput.defaultValue).toBe(""); }); }); + +// --------------------------------------------------------------------------- +// TZ §4.1.1-R1/R3: first-entry geolocation auto-fill +// --------------------------------------------------------------------------- + +describe("4.1.1-R1/R3: first-entry geolocation + defaults", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetCrossSectionStore(); + geoMockEnabled = false; + isMobileMockValue = false; + capturedOnCity = null; + capturedShouldApply = null; + }); + + afterEach(() => { + geoMockEnabled = false; + isMobileMockValue = false; + }); + + it("4.1.1-R1: populates departure with geolocated city when no stored filter", () => { + geoMockEnabled = true; // mock will call onCity("MOW") synchronously on shouldApply() + render(); + const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe("MOW"); + }); + + it("4.1.1-R3: does not populate departure when geolocation is denied (geoMockEnabled=false)", () => { + // geoMockEnabled stays false → useGeoCityDefault mock never fires onCity + render(); + const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe(""); + }); + + it("4.1.1-R1: does not override departure when cross-section store already has board filter", () => { + setBoardFilter({ + mode: "route", + departure: "LED", + arrival: "KGD", + date: "20260515", + timeFrom: "0000", + timeTo: "2400", + searchExecuted: false, + }); + // shouldApply() checks getBoardFilter() — returns LED snapshot → returns false → onCity not called + geoMockEnabled = true; + render(); + // Departure should be LED from the board snapshot, NOT overridden by geo. + const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe("LED"); + }); + + it("4.1.1-R1: does not override departure when prefill already has departure set", () => { + // Schedule filter sets a departure so prefill.departure = "SVO" + setScheduleFilter({ + mode: "route", + departure: "SVO", + arrival: "", + dateFrom: "20260515", + dateTo: "20260521", + timeFrom: "0000", + timeTo: "2400", + onlyDirect: false, + showReturn: false, + searchExecuted: true, + }); + // shouldApply checks !getBoardFilter() (true) && !prefill.departure (false → SVO) → returns false + geoMockEnabled = true; + render(); + const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe("SVO"); + }); +}); + +// --------------------------------------------------------------------------- +// TZ §4.1.1-R4: Online-Board Маршрут time default on desktop = 00:00-24:00 +// --------------------------------------------------------------------------- + +describe("4.1.1-R4: Online-Board time default desktop = 00:00-24:00", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetCrossSectionStore(); + geoMockEnabled = false; + isMobileMockValue = false; // desktop + }); + + it("desktop viewport passes 00:00-24:00 as initial time range to filter", () => { + render(); + // The time-selector shows "HH:MM — HH:MM" text; default is 00:00 — 24:00. + const timeSelector = screen.getByTestId("time-selector"); + expect(timeSelector.textContent).toContain("00:00"); + expect(timeSelector.textContent).toContain("24:00"); + }); +}); + +// --------------------------------------------------------------------------- +// TZ §4.1.1-R5: Online-Board Маршрут time default on mobile = -1h/+3h +// --------------------------------------------------------------------------- + +describe("4.1.1-R5: Online-Board time default mobile = -1h/+3h", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetCrossSectionStore(); + geoMockEnabled = false; + isMobileMockValue = true; // mobile + }); + + afterEach(() => { + vi.useRealTimers(); + isMobileMockValue = false; + }); + + it("mobile viewport at 10:00 uses 09:00-13:00 time range", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 10, 0, 0)); + render(); + const timeSelector = screen.getByTestId("time-selector"); + // -1h from 10:00 = 09:00, +3h from 10:00 = 13:00 + expect(timeSelector.textContent).toContain("09:00"); + expect(timeSelector.textContent).toContain("13:00"); + }); + + it("mobile viewport at 00:30 clamps from-time to 00:00 (not negative)", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 0, 30, 0)); + render(); + const timeSelector = screen.getByTestId("time-selector"); + // -1h from 00:30 would be -00:30 → clamps to 00:00; +3h = 03:30 + expect(timeSelector.textContent).toContain("00:00"); + expect(timeSelector.textContent).toContain("03:30"); + }); +}); diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index dd874d39..e1688b0d 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.tsx @@ -12,7 +12,7 @@ * @module */ -import { type FC, useCallback, useState, useEffect } from "react"; +import { type FC, useCallback, useState, useRef } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; @@ -37,6 +37,9 @@ import { getCityCodeByAirportCode, } from "@/shared/dictionaries/index.js"; import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js"; +import { useIsMobileViewport } from "@/shared/hooks/useIsMobileViewport.js"; +import { getOnlineBoardDefaultTimeRange } from "../timeDefaults.js"; import "./OnlineBoardStartPage.scss"; /** @@ -99,6 +102,12 @@ export const OnlineBoardStartPage: FC = () => { const navigate = useNavigate(); const { locale, language } = useLocale(); const { dictionaries } = useDictionaries(language); + const isMobile = useIsMobileViewport(); + + // TZ §4.1.1-R4/R5: mobile-aware default time range, computed once on mount. + // On desktop/tablet: 00:00-24:00. On mobile: [-1h, +3h] from now. + // Stored in a ref so the value doesn't shift on re-renders. + const defaultTimeRange = useRef(getOnlineBoardDefaultTimeRange(isMobile)).current; // Read-and-clear any prefill the previous page wrote. Stored in // useState (with a one-shot initializer) so React strict mode's @@ -135,11 +144,28 @@ export const OnlineBoardStartPage: FC = () => { // useState initial values pick up the new prefill. Key bump does it. const [filterKey, setFilterKey] = useState(0); + // TZ §4.1.1-R1: on first session entry with geo consent, auto-fill + // departure city with the user's nearest city when: + // - no stored board filter is present (fresh session), AND + // - the current prefill has no departure already (not populated by + // transient prefill, cross-section hydration, or user interaction). + useGeoCityDefault({ + dictionaries, + shouldApply: () => !getBoardFilter() && !prefill.departure, + onCity: (cityCode) => { + setPrefill((prev) => + prev.departure ? prev : { ...prev, departure: cityCode }, + ); + }, + }); + const filterInitialProps = { ...(prefill.tab ? { initialTab: prefill.tab } : {}), ...(prefill.departure ? { initialDeparture: prefill.departure } : {}), ...(prefill.arrival ? { initialArrival: prefill.arrival } : {}), ...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}), + initialTimeFrom: defaultTimeRange.timeFrom, + initialTimeTo: defaultTimeRange.timeTo, }; const handlePopularRequestClick = useCallback(