From 47fee9d7b5a276ec8a7097c8b8f4250fba46b945 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 18:03:27 +0300 Subject: [PATCH] Wire cross-section filter hydration into Board/Schedule/Map per TZ 4.1.8 Table 10 --- .../components/FlightsMapStartPage.test.tsx | 89 ++++++++++++++++++- .../components/FlightsMapStartPage.tsx | 36 +++++++- .../components/OnlineBoardFilter.tsx | 40 +++++++++ .../components/OnlineBoardStartPage.test.tsx | 61 +++++++++++++ .../components/OnlineBoardStartPage.tsx | 42 +++++++-- .../components/ScheduleStartPage.test.tsx | 66 ++++++++++++++ .../schedule/components/ScheduleStartPage.tsx | 54 +++++++++-- 7 files changed, 370 insertions(+), 18 deletions(-) diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx index 8fcef7dd..185ea20b 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx @@ -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 | null = null; vi.mock("./FlightsMapFilter.js", () => ({ - FlightsMapFilter: () =>
, + FlightsMapFilter: (props: Record) => { + lastMapFilterProps = props; + return
; + }, })); 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(); + const filterValue = lastMapFilterProps!["value"] as Record; + 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(); + const filterValue = lastMapFilterProps!["value"] as Record; + // 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(); + const filterValue = lastMapFilterProps!["value"] as Record; + // 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(); + const filterValue = lastMapFilterProps!["value"] as Record; + expect(filterValue["departure"]).toBeUndefined(); + expect(filterValue["arrival"]).toBeUndefined(); + expect(filterValue["domestic"]).toBe(false); + expect(filterValue["international"]).toBe(false); + }); +}); diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index dbbee606..9ad4d8cc 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -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 = ({ error: dictionariesError, } = useDictionaries(language); - const [filterState, setFilterState] = useState({ - connections: false, - domestic: false, - international: false, + const [filterState, setFilterState] = useState(() => { + // 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 = ({ 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( diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index ed52832a..822b473d 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -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 = ({ 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 = ({ 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}`); diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index dcaa56e4..be038dce 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -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", () => ({ ), })); @@ -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(); + // 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(); + 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(); + const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe(""); + }); +}); diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index 9d4c5142..3e898478 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 } 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( - () => - readAndClearTransientPrefill( - 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(() => { + const transient = readAndClearTransientPrefill( + 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); diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index bc92fbc8..d3c552f9 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -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) => ( + + ), +})); + vi.mock("@/ui/layout/SearchHistory.js", () => ({ SearchHistory: () =>
, })); @@ -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(); + 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(); + 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(); + const depInput = screen.getByTestId("schedule-departure-input") as HTMLInputElement; + expect(depInput.defaultValue).toBe(""); + }); +}); diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index 9e18498b..67bb26aa 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -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( - () => - readAndClearTransientPrefill( - 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(() => { + const transient = readAndClearTransientPrefill( + 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],