Wire cross-section filter hydration into Board/Schedule/Map per TZ 4.1.8 Table 10
This commit is contained in:
@@ -9,6 +9,15 @@ import type * as DictionariesModuleNS from "@/shared/dictionaries/index.js";
|
|||||||
import { transformDictionaries } from "@/shared/dictionaries/index.js";
|
import { transformDictionaries } from "@/shared/dictionaries/index.js";
|
||||||
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
|
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
|
||||||
import type { FlightsMapSearchParams } from "../types.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;
|
type DictionariesModule = typeof DictionariesModuleNS;
|
||||||
|
|
||||||
@@ -44,8 +53,12 @@ vi.mock("./MapCanvas.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let lastMapFilterProps: Record<string, unknown> | null = null;
|
||||||
vi.mock("./FlightsMapFilter.js", () => ({
|
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", () => ({
|
vi.mock("@/env/index.js", () => ({
|
||||||
@@ -491,3 +504,77 @@ describe("FlightsMapStartPage — C.4 integration", () => {
|
|||||||
expect(last?.connections).toBe(1);
|
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 { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
|
||||||
import { useDictionaries, getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
|
import { useDictionaries, getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
|
||||||
import { getCityZoomLevel } from "../cityCategory.js";
|
import { getCityZoomLevel } from "../cityCategory.js";
|
||||||
|
import {
|
||||||
|
getMapFilter,
|
||||||
|
setMapFilter,
|
||||||
|
} from "@/shared/state/crossSectionNavigation.js";
|
||||||
import type {
|
import type {
|
||||||
IFlightsMapFilterState,
|
IFlightsMapFilterState,
|
||||||
FlightsMapSearchParams,
|
FlightsMapSearchParams,
|
||||||
@@ -89,10 +93,25 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
|||||||
error: dictionariesError,
|
error: dictionariesError,
|
||||||
} = useDictionaries(language);
|
} = useDictionaries(language);
|
||||||
|
|
||||||
const [filterState, setFilterState] = useState<IFlightsMapFilterState>({
|
const [filterState, setFilterState] = useState<IFlightsMapFilterState>(() => {
|
||||||
connections: false,
|
// TZ §4.1.8 / 4.1.1-R26: Map filter is stored independently and NEVER
|
||||||
domestic: false,
|
// projected from/to Board or Schedule.
|
||||||
international: false,
|
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);
|
useGeolocationDefault(dictionaries, filterState, setFilterState);
|
||||||
@@ -161,6 +180,15 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
|||||||
|
|
||||||
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
|
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
|
||||||
setFilterState(newState);
|
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(
|
const handleMarkerClick = useCallback(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
|
|||||||
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
|
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
|
||||||
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
||||||
import { buildOnlineBoardUrl } from "../url.js";
|
import { buildOnlineBoardUrl } from "../url.js";
|
||||||
|
import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js";
|
||||||
import "./OnlineBoardFilter.scss";
|
import "./OnlineBoardFilter.scss";
|
||||||
|
|
||||||
function minutesToTime(minutes: number): string {
|
function minutesToTime(minutes: number): string {
|
||||||
@@ -233,6 +234,17 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
const carrier = "SU";
|
const carrier = "SU";
|
||||||
const num = cleaned;
|
const num = cleaned;
|
||||||
if (!num) return;
|
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 });
|
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
|
||||||
void navigate(`/${locale}/${url}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
@@ -273,10 +285,38 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
|
|
||||||
let url: string;
|
let url: string;
|
||||||
if (depCode && !arrCode) {
|
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 });
|
url = buildOnlineBoardUrl({ type: "departure", station: depCode, date: dateParam, ...timeExtras });
|
||||||
} else if (!depCode && arrCode) {
|
} 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 });
|
url = buildOnlineBoardUrl({ type: "arrival", station: arrCode, date: dateParam, ...timeExtras });
|
||||||
} else {
|
} 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 });
|
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
|
||||||
}
|
}
|
||||||
void navigate(`/${locale}/${url}`);
|
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 { render, screen, fireEvent } 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 {
|
||||||
|
resetCrossSectionStore,
|
||||||
|
setScheduleFilter,
|
||||||
|
setBoardFilter,
|
||||||
|
type ScheduleFilterSnapshot,
|
||||||
|
type BoardFilterSnapshot,
|
||||||
|
} from "@/shared/state/crossSectionNavigation.js";
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
|
|
||||||
@@ -58,6 +65,7 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
|
|||||||
<input
|
<input
|
||||||
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
|
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
|
||||||
placeholder={props["placeholder"] as string}
|
placeholder={props["placeholder"] as string}
|
||||||
|
defaultValue={(props["value"] as string) ?? ""}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -181,3 +189,56 @@ describe("OnlineBoardStartPage", () => {
|
|||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
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
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState, useEffect } 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";
|
||||||
@@ -26,6 +26,12 @@ import {
|
|||||||
readAndClearTransientPrefill,
|
readAndClearTransientPrefill,
|
||||||
writeTransientPrefill,
|
writeTransientPrefill,
|
||||||
} from "@/shared/state/transientPrefill.js";
|
} from "@/shared/state/transientPrefill.js";
|
||||||
|
import {
|
||||||
|
getBoardFilter,
|
||||||
|
getScheduleFilter,
|
||||||
|
setBoardFilter,
|
||||||
|
projectScheduleToBoard,
|
||||||
|
} from "@/shared/state/crossSectionNavigation.js";
|
||||||
import {
|
import {
|
||||||
useDictionaries,
|
useDictionaries,
|
||||||
getCityCodeByAirportCode,
|
getCityCodeByAirportCode,
|
||||||
@@ -97,12 +103,34 @@ export const OnlineBoardStartPage: FC = () => {
|
|||||||
// 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
|
||||||
// double-render doesn't lose the value on the second pass.
|
// double-render doesn't lose the value on the second pass.
|
||||||
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(
|
// Falls back to cross-section store projection (Schedule → Board) per
|
||||||
() =>
|
// TZ §4.1.8 Table 10 when no transient prefill is present.
|
||||||
readAndClearTransientPrefill<OnlineBoardPrefillState>(
|
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(() => {
|
||||||
ONLINE_BOARD_PREFILL_SLOT,
|
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
|
// Same-page popular clicks need to re-mount the filter so its
|
||||||
// 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);
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
|||||||
import { ScheduleStartPage } from "./ScheduleStartPage.js";
|
import { ScheduleStartPage } from "./ScheduleStartPage.js";
|
||||||
import { sessionStore } from "@/shared/storage.js";
|
import { sessionStore } from "@/shared/storage.js";
|
||||||
import type { PopularRequest } from "@/features/popular-requests/types.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();
|
const mockNavigate = vi.fn();
|
||||||
|
|
||||||
@@ -63,6 +70,15 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({
|
|||||||
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
|
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", () => ({
|
vi.mock("@/ui/layout/SearchHistory.js", () => ({
|
||||||
SearchHistory: () => <div data-testid="search-history" />,
|
SearchHistory: () => <div data-testid="search-history" />,
|
||||||
}));
|
}));
|
||||||
@@ -114,3 +130,53 @@ describe("ScheduleStartPage", () => {
|
|||||||
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
|
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,
|
readAndClearTransientPrefill,
|
||||||
writeTransientPrefill,
|
writeTransientPrefill,
|
||||||
} from "@/shared/state/transientPrefill.js";
|
} from "@/shared/state/transientPrefill.js";
|
||||||
|
import {
|
||||||
|
getScheduleFilter,
|
||||||
|
getBoardFilter,
|
||||||
|
setScheduleFilter,
|
||||||
|
projectBoardToSchedule,
|
||||||
|
} from "@/shared/state/crossSectionNavigation.js";
|
||||||
import {
|
import {
|
||||||
useDictionaries,
|
useDictionaries,
|
||||||
getCityCodeByAirportCode,
|
getCityCodeByAirportCode,
|
||||||
@@ -92,12 +98,34 @@ export const ScheduleStartPage: FC = () => {
|
|||||||
const { dictionaries } = useDictionaries(language);
|
const { dictionaries } = useDictionaries(language);
|
||||||
|
|
||||||
// One-shot read of any prefill the previous page wrote.
|
// One-shot read of any prefill the previous page wrote.
|
||||||
const [prefill] = useState<SchedulePrefillState>(
|
// Falls back to cross-section store projection (Board → Schedule) per
|
||||||
() =>
|
// TZ §4.1.8 Table 10 when no transient prefill is present.
|
||||||
readAndClearTransientPrefill<SchedulePrefillState>(
|
const [prefill] = useState<SchedulePrefillState>(() => {
|
||||||
SCHEDULE_PREFILL_SLOT,
|
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();
|
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}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[departureCode, arrivalCode, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, locale],
|
[departureCode, arrivalCode, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, locale],
|
||||||
|
|||||||
Reference in New Issue
Block a user