Wire first-entry geolocation + mobile time default into Online-Board start page (TZ 4.1.1)

This commit is contained in:
2026-04-21 19:09:34 +03:00
parent 9aed10c281
commit b023cb922a
2 changed files with 186 additions and 3 deletions
@@ -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(