Wire first-entry geolocation + mobile time default into Online-Board start page (TZ 4.1.1)
This commit is contained in:
@@ -7,8 +7,8 @@
|
|||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
|
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
|
||||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,31 @@ import {
|
|||||||
type BoardFilterSnapshot,
|
type BoardFilterSnapshot,
|
||||||
} from "@/shared/state/crossSectionNavigation.js";
|
} 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();
|
const mockNavigate = vi.fn();
|
||||||
|
|
||||||
vi.mock("@modern-js/runtime/router", () => ({
|
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("");
|
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(<OnlineBoardStartPage />);
|
||||||
|
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(<OnlineBoardStartPage />);
|
||||||
|
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(<OnlineBoardStartPage />);
|
||||||
|
// 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(<OnlineBoardStartPage />);
|
||||||
|
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(<OnlineBoardStartPage />);
|
||||||
|
// 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(<OnlineBoardStartPage />);
|
||||||
|
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(<OnlineBoardStartPage />);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* @module
|
* @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 { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
import { useLocale } from "@/i18n/useLocale.js";
|
||||||
import { useTranslation } from "@/i18n/provider.js";
|
import { useTranslation } from "@/i18n/provider.js";
|
||||||
@@ -37,6 +37,9 @@ import {
|
|||||||
getCityCodeByAirportCode,
|
getCityCodeByAirportCode,
|
||||||
} from "@/shared/dictionaries/index.js";
|
} from "@/shared/dictionaries/index.js";
|
||||||
import type { IDictionaries } 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";
|
import "./OnlineBoardStartPage.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +102,12 @@ export const OnlineBoardStartPage: FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { locale, language } = useLocale();
|
const { locale, language } = useLocale();
|
||||||
const { dictionaries } = useDictionaries(language);
|
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
|
// Read-and-clear any prefill the previous page wrote. Stored in
|
||||||
// useState (with a one-shot initializer) so React strict mode's
|
// 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.
|
// useState initial values pick up the new prefill. Key bump does it.
|
||||||
const [filterKey, setFilterKey] = useState(0);
|
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 = {
|
const filterInitialProps = {
|
||||||
...(prefill.tab ? { initialTab: prefill.tab } : {}),
|
...(prefill.tab ? { initialTab: prefill.tab } : {}),
|
||||||
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
|
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
|
||||||
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
|
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
|
||||||
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
|
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
|
||||||
|
initialTimeFrom: defaultTimeRange.timeFrom,
|
||||||
|
initialTimeTo: defaultTimeRange.timeTo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePopularRequestClick = useCallback(
|
const handlePopularRequestClick = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user