Execute schedule popular route searches

This commit is contained in:
2026-05-05 23:10:20 +03:00
parent 0960b739dd
commit 421a960a82
4 changed files with 103 additions and 65 deletions
@@ -63,6 +63,12 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
>
Popular
</button>
<button
data-testid="popular-click-schedule-route"
onClick={() => onRequestClick?.({ mode: "Route", departure: "MOW", arrival: "MMK", type: "Schedule" })}
>
Schedule Route
</button>
</div>
),
}));
@@ -77,6 +83,7 @@ vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
getCityCodeByAirportCode: () => undefined,
}));
vi.mock("../hooks/useCalendarDays.js", () => ({
@@ -264,6 +271,20 @@ describe("OnlineBoardStartPage", () => {
// Same-page click — the filter remounts via key bump, no nav.
expect(mockNavigate).not.toHaveBeenCalled();
});
it("TIRREDESIGN-9: schedule Route popular click navigates directly to schedule results", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0));
try {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByTestId("popular-click-schedule-route"));
expect(mockNavigate).toHaveBeenCalledWith(
"/ru-ru/schedule/route/MOW-MMK-20260514-20260517",
);
} finally {
vi.useRealTimers();
}
});
});
// ---------------------------------------------------------------------------
@@ -24,7 +24,6 @@ import { PopularRequestsPanel } from "@/features/popular-requests/components/Pop
import type { PopularRequest } from "@/features/popular-requests/types.js";
import {
readAndClearTransientPrefill,
writeTransientPrefill,
} from "@/shared/state/transientPrefill.js";
import {
getBoardFilter,
@@ -39,6 +38,7 @@ 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 { buildScheduleUrl } from "@/features/schedule/url.js";
import "./OnlineBoardStartPage.scss";
/**
@@ -244,36 +244,37 @@ export const OnlineBoardStartPage: FC<OnlineBoardStartPageProps> = ({ today }) =
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
// Schedule-type requests navigate to the schedule feature. City
// codes come off the popular-request API as airport codes in some
// cases (SVO/LED instead of MOW/LED); resolve them to owning city
// so the destination form pre-fills with a city rather than an
// airport pin.
// Schedule route requests execute immediately. The live Angular app
// only pre-fills `/schedule`, but TIRREDESIGN-9 is specifically about
// the popular "Москва - Мурманск" schedule search not running.
if (request.type === "Schedule") {
// TZ §4.1.5: Schedule-bound popular clicks prefill the date range
// to the current ISO week (Mon-Sun); round-trip additionally sets
// the return range to the following ISO week. Without these the
// Schedule calendar renders empty until submit.
const state: Record<string, unknown> =
request.mode === "Route" || request.mode === "RouteWithBack"
? (() => {
const base: Record<string, unknown> = {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
withReturn: request.mode === "RouteWithBack",
};
const cur = currentWeekBoundsYyyymmdd();
base.dateFrom = cur.from;
base.dateTo = cur.to;
if (request.mode === "RouteWithBack") {
if (request.mode === "Route" || request.mode === "RouteWithBack") {
const cur = currentWeekBoundsYyyymmdd();
const outbound = {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
dateFrom: cur.from,
dateTo: cur.to,
};
const url =
request.mode === "RouteWithBack"
? (() => {
const nxt = nextWeekBoundsYyyymmdd();
base.returnDateFrom = nxt.from;
base.returnDateTo = nxt.to;
}
return base;
})()
: {};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
return buildScheduleUrl({
type: "roundtrip",
outbound,
inbound: {
departure: outbound.arrival,
arrival: outbound.departure,
dateFrom: nxt.from,
dateTo: nxt.to,
},
});
})()
: buildScheduleUrl({ type: "route", outbound });
navigate(`/${locale}/${url}`);
return;
}
navigate(`/${locale}/schedule`);
return;
}
@@ -191,35 +191,20 @@ describe("ScheduleStartPage", () => {
});
});
it("4.1.5-S1: one-way Route click populates form with current ISO week dates (from clamped to today-1) + no return", () => {
it("4.1.5-S1: one-way Route click executes current ISO week search (from clamped to today-1)", () => {
// 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17
// `from` is clamped to today1 = 2026-05-14 so the route guard does
// not redirect the search back to the start page.
// Same-page Schedule click updates form state directly (navigate to
// the same route would no-op), so we assert visible form state and
// submit the form to verify the dates landed in component state.
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
expect(mockNavigate).not.toHaveBeenCalled();
expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("SVO");
expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("LED");
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false);
expect(screen.queryByTestId("return-date-range-input")).toBeNull();
// Submit drives the dates from state into the URL — proves they were set.
fireEvent.submit(screen.getByTestId("schedule-search-form"));
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
});
it("4.1.5-S2: round-trip RouteWithBack click populates form with current + next week dates (outbound from clamped)", () => {
it("4.1.5-S2: round-trip RouteWithBack click executes current + next week search (outbound from clamped)", () => {
// current week raw: 20260511-20260517 (clamped from: 20260514-20260517)
// next week: 20260518-20260524 (unclamped — future)
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-roundtrip"));
expect(mockNavigate).not.toHaveBeenCalled();
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(true);
fireEvent.submit(screen.getByTestId("schedule-search-form"));
expect(mockNavigate).toHaveBeenCalledWith(
"/ru-ru/schedule/route/SVO-LED-20260514-20260517/LED-SVO-20260518-20260524",
);
@@ -298,12 +283,11 @@ describe("4.1.9-R: Current-Week label substitution", () => {
vi.useRealTimers();
});
it("4.1.9-R: start page populates date range with current week on Route click", () => {
it("4.1.9-R: start page uses current week on Route popular search", () => {
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
// Current week Sun for 2026-05-15 is 2026-05-17; `from` is clamped to
// today1 = 2026-05-14 so the range is inside Schedule's 1/+330 window.
fireEvent.submit(screen.getByTestId("schedule-search-form"));
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
});
});
@@ -383,29 +383,61 @@ export const ScheduleStartPage: FC = () => {
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
// Deviation from Angular: every popular-request click on Schedule
// populates the form in-place and stays on the page. Angular
// redirects Arrival/Departure/FlightNumber clicks to /onlineboard
// unconditionally; we instead treat the click as a Schedule prefill
// because users land here to plan a trip, not to switch sections.
// Codes sometimes arrive as airport (SVO/LED) — normalize to city.
// Route popular requests execute immediately. TIRREDESIGN-9 covers
// "Расписание туда: Москва - Мурманск"; stopping at a prefilled form
// leaves that request visibly unperformed.
switch (request.mode) {
case "Route":
case "RouteWithBack": {
const curWeek = currentWeekBounds();
setDepartureCode(toCityCode(request.departure, dictionaries));
setArrivalCode(toCityCode(request.arrival, dictionaries));
setIsRoundTrip(request.mode === "RouteWithBack");
setDateFrom(curWeek.from);
setDateTo(curWeek.to);
const dep = toCityCode(request.departure, dictionaries);
const arr = toCityCode(request.arrival, dictionaries);
const outbound = {
departure: dep,
arrival: arr,
dateFrom: dateToYyyymmdd(curWeek.from),
dateTo: dateToYyyymmdd(curWeek.to),
};
if (request.mode === "RouteWithBack") {
const nxt = nextWeekBounds();
setReturnDateFrom(nxt.from);
setReturnDateTo(nxt.to);
} else {
setReturnDateFrom(null);
setReturnDateTo(null);
const inbound = {
departure: arr,
arrival: dep,
dateFrom: dateToYyyymmdd(nxt.from),
dateTo: dateToYyyymmdd(nxt.to),
};
setScheduleFilter({
mode: "route",
departure: dep,
arrival: arr,
dateFrom: outbound.dateFrom,
dateTo: outbound.dateTo,
timeFrom: "0000",
timeTo: "2400",
onlyDirect: false,
showReturn: true,
returnDateFrom: inbound.dateFrom,
returnDateTo: inbound.dateTo,
returnTimeFrom: "0000",
returnTimeTo: "2400",
searchExecuted: true,
});
void navigate(`/${locale}/${buildScheduleUrl({ type: "roundtrip", outbound, inbound })}`);
break;
}
setScheduleFilter({
mode: "route",
departure: dep,
arrival: arr,
dateFrom: outbound.dateFrom,
dateTo: outbound.dateTo,
timeFrom: "0000",
timeTo: "2400",
onlyDirect: false,
showReturn: false,
searchExecuted: true,
});
void navigate(`/${locale}/${buildScheduleUrl({ type: "route", outbound })}`);
break;
}
case "Arrival":
@@ -420,7 +452,7 @@ export const ScheduleStartPage: FC = () => {
}
if (sameCitiesError) setSameCitiesError(null);
},
[dictionaries, sameCitiesError],
[dictionaries, sameCitiesError, navigate, locale],
);
const scheduleFilter = (