Wire cross-section filter hydration into Board/Schedule/Map per TZ 4.1.8 Table 10

This commit is contained in:
2026-04-21 18:03:27 +03:00
parent 986313248e
commit 47fee9d7b5
7 changed files with 370 additions and 18 deletions
@@ -9,6 +9,15 @@ import type * as DictionariesModuleNS from "@/shared/dictionaries/index.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
import type { FlightsMapSearchParams } from "../types.js";
import {
resetCrossSectionStore,
setMapFilter,
setBoardFilter,
setScheduleFilter,
type MapFilterSnapshot,
type BoardFilterSnapshot,
type ScheduleFilterSnapshot,
} from "@/shared/state/crossSectionNavigation.js";
type DictionariesModule = typeof DictionariesModuleNS;
@@ -44,8 +53,12 @@ vi.mock("./MapCanvas.js", () => ({
},
}));
let lastMapFilterProps: Record<string, unknown> | null = null;
vi.mock("./FlightsMapFilter.js", () => ({
FlightsMapFilter: () => <div data-testid="flights-map-filter" />,
FlightsMapFilter: (props: Record<string, unknown>) => {
lastMapFilterProps = props;
return <div data-testid="flights-map-filter" />;
},
}));
vi.mock("@/env/index.js", () => ({
@@ -491,3 +504,77 @@ describe("FlightsMapStartPage — C.4 integration", () => {
expect(last?.connections).toBe(1);
});
});
// ---------------------------------------------------------------------------
// TZ §4.1.8 / §4.1.1-R26: Map filter cross-section isolation tests
// ---------------------------------------------------------------------------
describe("4.1.8 / 4.1.1-R26: Flight-Map filter cross-section isolation", () => {
beforeEach(() => {
lastMapFilterProps = null;
lastMapCanvasProps = null;
dictState.dictionaries = buildDictionaries();
dictState.loading = false;
dictState.error = null;
searchState.routes = [];
searchState.loading = false;
searchState.error = null;
resetCrossSectionStore();
});
it("4.1.8: hydrates map filter from map snapshot on mount", () => {
const mapSnap: MapFilterSnapshot = {
departure: "MOW", arrival: "LED", date: "20260515",
showInternal: true, showInternational: false, showTransfers: true,
};
setMapFilter(mapSnap);
render(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
expect(filterValue["departure"]).toBe("MOW");
expect(filterValue["arrival"]).toBe("LED");
expect(filterValue["domestic"]).toBe(true);
expect(filterValue["international"]).toBe(false);
expect(filterValue["connections"]).toBe(true);
});
it("4.1.1-R26: Board filter does NOT hydrate flight map", () => {
const boardSnap: BoardFilterSnapshot = {
mode: "route", departure: "SVO", arrival: "VVO",
date: "20260515", timeFrom: "0000", timeTo: "2400",
searchExecuted: true,
};
setBoardFilter(boardSnap);
// No map snapshot set
render(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
// Map filter should be at defaults, not seeded from board
expect(filterValue["departure"]).toBeUndefined();
expect(filterValue["arrival"]).toBeUndefined();
});
it("4.1.1-R26: Schedule filter does NOT hydrate flight map", () => {
const schedSnap: ScheduleFilterSnapshot = {
mode: "route", departure: "KZN", arrival: "AER",
dateFrom: "20260511", dateTo: "20260517",
timeFrom: "0000", timeTo: "2400",
onlyDirect: false, showReturn: false, searchExecuted: true,
};
setScheduleFilter(schedSnap);
// No map snapshot set
render(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
// Map filter should be at defaults, not seeded from schedule
expect(filterValue["departure"]).toBeUndefined();
expect(filterValue["arrival"]).toBeUndefined();
});
it("4.1.8: renders empty filter when no map snapshot is in store", () => {
// Store is empty (resetCrossSectionStore called in beforeEach)
render(<FlightsMapStartPage />);
const filterValue = lastMapFilterProps!["value"] as Record<string, unknown>;
expect(filterValue["departure"]).toBeUndefined();
expect(filterValue["arrival"]).toBeUndefined();
expect(filterValue["domestic"]).toBe(false);
expect(filterValue["international"]).toBe(false);
});
});
@@ -24,6 +24,10 @@ import { filterRoutes } from "../filterRoutes.js";
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
import { useDictionaries, getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import { getCityZoomLevel } from "../cityCategory.js";
import {
getMapFilter,
setMapFilter,
} from "@/shared/state/crossSectionNavigation.js";
import type {
IFlightsMapFilterState,
FlightsMapSearchParams,
@@ -89,10 +93,25 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
error: dictionariesError,
} = useDictionaries(language);
const [filterState, setFilterState] = useState<IFlightsMapFilterState>({
connections: false,
domestic: false,
international: false,
const [filterState, setFilterState] = useState<IFlightsMapFilterState>(() => {
// TZ §4.1.8 / 4.1.1-R26: Map filter is stored independently and NEVER
// projected from/to Board or Schedule.
const mapSnap = getMapFilter();
if (mapSnap) {
return {
departure: mapSnap.departure ?? undefined,
arrival: mapSnap.arrival ?? undefined,
date: mapSnap.date ?? undefined,
connections: mapSnap.showTransfers,
domestic: mapSnap.showInternal,
international: mapSnap.showInternational,
};
}
return {
connections: false,
domestic: false,
international: false,
};
});
useGeolocationDefault(dictionaries, filterState, setFilterState);
@@ -161,6 +180,15 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
setFilterState(newState);
// TZ §4.1.8 / 4.1.1-R26: persist map filter independently from Board/Schedule.
setMapFilter({
departure: newState.departure ?? null,
arrival: newState.arrival ?? null,
date: newState.date ?? null,
showInternal: newState.domestic,
showInternational: newState.international,
showTransfers: newState.connections,
});
}, []);
const handleMarkerClick = useCallback(
@@ -18,6 +18,7 @@ import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { buildOnlineBoardUrl } from "../url.js";
import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js";
import "./OnlineBoardFilter.scss";
function minutesToTime(minutes: number): string {
@@ -233,6 +234,17 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
const carrier = "SU";
const num = cleaned;
if (!num) return;
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "flight-number",
flightNumber: `${carrier}${num}`,
date: dateParam,
timeFrom: "0000",
timeTo: "2400",
searchExecuted: true,
});
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
void navigate(`/${locale}/${url}`);
},
@@ -273,10 +285,38 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
let url: string;
if (depCode && !arrCode) {
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "departure",
departure: depCode,
date: dateParam,
timeFrom: timeExtras.timeFrom ?? "0000",
timeTo: timeExtras.timeTo ?? "2400",
searchExecuted: true,
});
url = buildOnlineBoardUrl({ type: "departure", station: depCode, date: dateParam, ...timeExtras });
} else if (!depCode && arrCode) {
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "arrival",
arrival: arrCode,
date: dateParam,
timeFrom: timeExtras.timeFrom ?? "0000",
timeTo: timeExtras.timeTo ?? "2400",
searchExecuted: true,
});
url = buildOnlineBoardUrl({ type: "arrival", station: arrCode, date: dateParam, ...timeExtras });
} else {
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "route",
departure: depCode,
arrival: arrCode,
date: dateParam,
timeFrom: timeExtras.timeFrom ?? "0000",
timeTo: timeExtras.timeTo ?? "2400",
searchExecuted: true,
});
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
}
void navigate(`/${locale}/${url}`);
@@ -11,6 +11,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import {
resetCrossSectionStore,
setScheduleFilter,
setBoardFilter,
type ScheduleFilterSnapshot,
type BoardFilterSnapshot,
} from "@/shared/state/crossSectionNavigation.js";
const mockNavigate = vi.fn();
@@ -58,6 +65,7 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
placeholder={props["placeholder"] as string}
defaultValue={(props["value"] as string) ?? ""}
/>
),
}));
@@ -181,3 +189,56 @@ describe("OnlineBoardStartPage", () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// TZ §4.1.8: cross-section hydration tests (Board ↔ Schedule)
// ---------------------------------------------------------------------------
describe("4.1.8: Online-Board hydrates from cross-section store on mount", () => {
beforeEach(() => {
vi.clearAllMocks();
resetCrossSectionStore();
});
it("4.1.8: hydrates departure + arrival from Schedule filter (projected) when no own state", () => {
const schedSnap: ScheduleFilterSnapshot = {
mode: "route", departure: "MOW", arrival: "LED",
dateFrom: "20260511", dateTo: "20260517",
timeFrom: "0000", timeTo: "2400",
onlyDirect: false, showReturn: false, searchExecuted: true,
};
setScheduleFilter(schedSnap);
render(<OnlineBoardStartPage />);
// departure input should be seeded with "MOW"
const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("MOW");
const arrInput = screen.getByTestId("route-arrival-input") as HTMLInputElement;
expect(arrInput.defaultValue).toBe("LED");
});
it("4.1.8: uses own board snapshot (not schedule) when board state is set", () => {
// Set board first (own state)
setBoardFilter({
mode: "route", departure: "KZN", arrival: "AER",
date: "20260515", timeFrom: "0000", timeTo: "2400",
searchExecuted: false,
});
// Also set schedule — it should be ignored since board is present
setScheduleFilter({
mode: "route", departure: "SVO", arrival: "VVO",
dateFrom: "20260511", dateTo: "20260517",
timeFrom: "0000", timeTo: "2400",
onlyDirect: false, showReturn: false, searchExecuted: true,
});
render(<OnlineBoardStartPage />);
const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("KZN");
});
it("4.1.8: renders empty filter when both board and schedule store are empty", () => {
// No cross-section state set
render(<OnlineBoardStartPage />);
const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("");
});
});
@@ -12,7 +12,7 @@
* @module
*/
import { type FC, useCallback, useState } from "react";
import { type FC, useCallback, useState, useEffect } from "react";
import { useNavigate } from "@modern-js/runtime/router";
import { useLocale } from "@/i18n/useLocale.js";
import { useTranslation } from "@/i18n/provider.js";
@@ -26,6 +26,12 @@ import {
readAndClearTransientPrefill,
writeTransientPrefill,
} from "@/shared/state/transientPrefill.js";
import {
getBoardFilter,
getScheduleFilter,
setBoardFilter,
projectScheduleToBoard,
} from "@/shared/state/crossSectionNavigation.js";
import {
useDictionaries,
getCityCodeByAirportCode,
@@ -97,12 +103,34 @@ export const OnlineBoardStartPage: FC = () => {
// Read-and-clear any prefill the previous page wrote. Stored in
// useState (with a one-shot initializer) so React strict mode's
// double-render doesn't lose the value on the second pass.
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(
() =>
readAndClearTransientPrefill<OnlineBoardPrefillState>(
ONLINE_BOARD_PREFILL_SLOT,
) ?? {},
);
// Falls back to cross-section store projection (Schedule → Board) per
// TZ §4.1.8 Table 10 when no transient prefill is present.
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(() => {
const transient = readAndClearTransientPrefill<OnlineBoardPrefillState>(
ONLINE_BOARD_PREFILL_SLOT,
);
if (transient) return transient;
// TZ §4.1.8: try own snapshot first, then project from Schedule.
const boardSnap = getBoardFilter();
if (boardSnap) {
return {
tab: "route",
departure: boardSnap.departure,
arrival: boardSnap.arrival,
};
}
const schedSnap = getScheduleFilter();
if (schedSnap) {
const projected = projectScheduleToBoard(schedSnap);
return {
tab: "route",
departure: projected.departure,
arrival: projected.arrival,
};
}
return {};
});
// Same-page popular clicks need to re-mount the filter so its
// useState initial values pick up the new prefill. Key bump does it.
const [filterKey, setFilterKey] = useState(0);
@@ -12,6 +12,13 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleStartPage } from "./ScheduleStartPage.js";
import { sessionStore } from "@/shared/storage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import {
resetCrossSectionStore,
setBoardFilter,
setScheduleFilter,
type BoardFilterSnapshot,
type ScheduleFilterSnapshot,
} from "@/shared/state/crossSectionNavigation.js";
const mockNavigate = vi.fn();
@@ -63,6 +70,15 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
}));
vi.mock("@/ui/city-autocomplete/index.js", () => ({
CityAutocomplete: (props: Record<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
/>
),
}));
vi.mock("@/ui/layout/SearchHistory.js", () => ({
SearchHistory: () => <div data-testid="search-history" />,
}));
@@ -114,3 +130,53 @@ describe("ScheduleStartPage", () => {
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
});
});
// ---------------------------------------------------------------------------
// TZ §4.1.8: cross-section hydration tests (Board → Schedule)
// ---------------------------------------------------------------------------
describe("4.1.8: Schedule hydrates from cross-section store on mount", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStore.clear();
resetCrossSectionStore();
});
it("4.1.8: hydrates departure + arrival from Board filter (projected) when no own state", () => {
const boardSnap: BoardFilterSnapshot = {
mode: "route", departure: "MOW", arrival: "LED",
date: "20260515", timeFrom: "0600", timeTo: "2200",
searchExecuted: true,
};
setBoardFilter(boardSnap);
render(<ScheduleStartPage />);
const depInput = screen.getByTestId("schedule-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("MOW");
const arrInput = screen.getByTestId("schedule-arrival-input") as HTMLInputElement;
expect(arrInput.defaultValue).toBe("LED");
});
it("4.1.8: uses own schedule snapshot (not board) when schedule state is set", () => {
setScheduleFilter({
mode: "route", departure: "KZN", arrival: "AER",
dateFrom: "20260511", dateTo: "20260517",
timeFrom: "0000", timeTo: "2400",
onlyDirect: false, showReturn: false, searchExecuted: false,
});
// Also set board — it should be ignored since schedule snapshot is present
setBoardFilter({
mode: "route", departure: "SVO", arrival: "VVO",
date: "20260515", timeFrom: "0000", timeTo: "2400",
searchExecuted: true,
});
render(<ScheduleStartPage />);
const depInput = screen.getByTestId("schedule-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("KZN");
});
it("4.1.8: renders empty filter when both schedule and board store are empty", () => {
render(<ScheduleStartPage />);
const depInput = screen.getByTestId("schedule-departure-input") as HTMLInputElement;
expect(depInput.defaultValue).toBe("");
});
});
@@ -28,6 +28,12 @@ import {
readAndClearTransientPrefill,
writeTransientPrefill,
} from "@/shared/state/transientPrefill.js";
import {
getScheduleFilter,
getBoardFilter,
setScheduleFilter,
projectBoardToSchedule,
} from "@/shared/state/crossSectionNavigation.js";
import {
useDictionaries,
getCityCodeByAirportCode,
@@ -92,12 +98,34 @@ export const ScheduleStartPage: FC = () => {
const { dictionaries } = useDictionaries(language);
// One-shot read of any prefill the previous page wrote.
const [prefill] = useState<SchedulePrefillState>(
() =>
readAndClearTransientPrefill<SchedulePrefillState>(
SCHEDULE_PREFILL_SLOT,
) ?? {},
);
// Falls back to cross-section store projection (Board → Schedule) per
// TZ §4.1.8 Table 10 when no transient prefill is present.
const [prefill] = useState<SchedulePrefillState>(() => {
const transient = readAndClearTransientPrefill<SchedulePrefillState>(
SCHEDULE_PREFILL_SLOT,
);
if (transient) return transient;
// TZ §4.1.8: try own snapshot first, then project from Board.
const schedSnap = getScheduleFilter();
if (schedSnap) {
return {
departure: schedSnap.departure,
arrival: schedSnap.arrival,
withReturn: schedSnap.showReturn,
};
}
const boardSnap = getBoardFilter();
if (boardSnap) {
const projected = projectBoardToSchedule(boardSnap);
return {
departure: projected.departure,
arrival: projected.arrival,
withReturn: false,
};
}
return {};
});
const today = new Date();
@@ -184,6 +212,20 @@ export const ScheduleStartPage: FC = () => {
});
}
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setScheduleFilter({
mode: "route",
departure: dep,
arrival: arr,
dateFrom: dateFromParam,
dateTo: dateToParam,
timeFrom: outbound.timeFrom ?? "0000",
timeTo: outbound.timeTo ?? "2400",
onlyDirect: directOnly,
showReturn: isRoundTrip,
searchExecuted: true,
});
void navigate(`/${locale}/${url}`);
},
[departureCode, arrivalCode, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, locale],