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
|
||||
*/
|
||||
|
||||
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(<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
|
||||
*/
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user