diff --git a/src/features/schedule/components/ScheduleFilter.test.tsx b/src/features/schedule/components/ScheduleFilter.test.tsx index 9b14b4fa..81b048c1 100644 --- a/src/features/schedule/components/ScheduleFilter.test.tsx +++ b/src/features/schedule/components/ScheduleFilter.test.tsx @@ -90,7 +90,10 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({ CityAutocomplete: (props: Record) => ( { + (props["onChange"] as (value: string) => void)(e.currentTarget.value); + }} /> ), SwapCityButton: (props: { onClick: () => void; testId?: string }) => ( @@ -468,3 +471,40 @@ describe("ScheduleFilter – validation per TZ §4.1.9.4", () => { expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull(); }); }); + +describe("ScheduleFilter – repeated-submit lock", () => { + beforeEach(() => { + vi.clearAllMocks(); + scheduleCalendarMock.days = []; + scheduleCalendarMock.loaded = false; + scheduleCalendarMock.params = []; + _sliderOnChange = null; + _sliderOnChanges.length = 0; + }); + + it("keeps identical resubmits locked but allows an immediate changed search", () => { + render( + , + ); + + fireEvent.submit(screen.getByTestId("search-form")); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(true); + + fireEvent.change(screen.getByTestId("schedule-arrival-input"), { + target: { value: "KUF" }, + }); + expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false); + + fireEvent.submit(screen.getByTestId("search-form")); + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith( + "/ru-ru/schedule/route/SVO-KUF-20260601-20260607", + ); + }); +}); diff --git a/src/features/schedule/components/ScheduleFilter.tsx b/src/features/schedule/components/ScheduleFilter.tsx index 44a09959..1b1ba477 100644 --- a/src/features/schedule/components/ScheduleFilter.tsx +++ b/src/features/schedule/components/ScheduleFilter.tsx @@ -253,16 +253,43 @@ export const ScheduleFilter: FC = ({ // §4.1.11 — submit button locked for 30 seconds after each search. // The 30-second constant is intentionally hardcoded (not configurable). - const [submitLockedUntil, setSubmitLockedUntil] = useState(0); + const [submitLock, setSubmitLock] = useState<{ until: number; key: string } | null>(null); const [nowTs, setNowTs] = useState(() => Date.now()); useEffect(() => { - if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return; + if (!submitLock || nowTs >= submitLock.until) return; const id = setTimeout(() => setNowTs(Date.now()), 1000); return () => clearTimeout(id); - }, [submitLockedUntil, nowTs]); + }, [submitLock, nowTs]); + const formSearchKey = useMemo( + () => + JSON.stringify({ + departure: departure.trim().toUpperCase(), + arrival: arrival.trim().toUpperCase(), + dateFrom: dateRange[0] ? dateToYyyymmdd(dateRange[0]) : "", + dateTo: dateRange[1] ? dateToYyyymmdd(dateRange[1]) : "", + timeFrom: timeRange[0], + timeTo: timeRange[1], + directOnly, + returnFlights, + returnDateFrom: returnDateRange[0] ? dateToYyyymmdd(returnDateRange[0]) : "", + returnDateTo: returnDateRange[1] ? dateToYyyymmdd(returnDateRange[1]) : "", + returnTimeFrom: returnTimeRange[0], + returnTimeTo: returnTimeRange[1], + }), + [ + departure, + arrival, + dateRange, + timeRange, + directOnly, + returnFlights, + returnDateRange, + returnTimeRange, + ], + ); const isSubmitLocked = useMemo( - () => submitLockedUntil > 0 && nowTs < submitLockedUntil, - [submitLockedUntil, nowTs], + () => Boolean(submitLock && nowTs < submitLock.until && submitLock.key === formSearchKey), + [submitLock, nowTs, formSearchKey], ); // TZ §4.1.9.4 Table 16: when the outbound range moves forward such @@ -490,8 +517,9 @@ export const ScheduleFilter: FC = ({ searchExecuted: true, }); // Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable) - setSubmitLockedUntil(Date.now() + 30_000); - setNowTs(Date.now()); + const submittedAt = Date.now(); + setSubmitLock({ until: submittedAt + 30_000, key: formSearchKey }); + setNowTs(submittedAt); void navigate(`/${locale}/${url}`); }, [ @@ -506,6 +534,7 @@ export const ScheduleFilter: FC = ({ navigate, locale, isSubmitLocked, + formSearchKey, ], ); diff --git a/src/shared/hooks/useSearchHistory.ts b/src/shared/hooks/useSearchHistory.ts index 53ef9d00..ec8c86a2 100644 --- a/src/shared/hooks/useSearchHistory.ts +++ b/src/shared/hooks/useSearchHistory.ts @@ -156,36 +156,38 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { const add = useCallback( (item: SearchHistoryItem) => { - setItems((prev) => { - // Deduplicate by URL — don't add if the most recent entry matches - if (prev[0]?.url === item.url) { - return prev; - } + const prev = + (sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; - // Remove any existing entry with the same URL, then prepend. - const filtered = prev.filter((existing) => existing.url !== item.url); - const combined = [item, ...filtered]; + // Deduplicate by URL — don't add if the most recent entry matches. + if (prev[0]?.url === item.url) { + setItems(prev); + return; + } - // Cap per section — the board/schedule/flight-number buckets each - // hold up to MAX_PER_SECTION (8) entries independently. This keeps - // the resulting list ordered by recency across sections but - // prevents a burst of board searches from evicting schedule - // history (and vice versa). - const perSectionCount = { board: 0, schedule: 0, "flight-number": 0 }; - const next: SearchHistoryItem[] = []; - for (const entry of combined) { - const bucket = sectionFor(entry.type); - if (perSectionCount[bucket] >= MAX_PER_SECTION) continue; - perSectionCount[bucket]++; - next.push(entry); - } + // Remove any existing entry with the same URL, then prepend. + const filtered = prev.filter((existing) => existing.url !== item.url); + const combined = [item, ...filtered]; - sessionStore.set(key, next, searchHistorySchema); - if (typeof window !== "undefined") { - window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT)); - } - return next; - }); + // Cap per section — the board/schedule/flight-number buckets each + // hold up to MAX_PER_SECTION (8) entries independently. This keeps + // the resulting list ordered by recency across sections but + // prevents a burst of board searches from evicting schedule + // history (and vice versa). + const perSectionCount = { board: 0, schedule: 0, "flight-number": 0 }; + const next: SearchHistoryItem[] = []; + for (const entry of combined) { + const bucket = sectionFor(entry.type); + if (perSectionCount[bucket] >= MAX_PER_SECTION) continue; + perSectionCount[bucket]++; + next.push(entry); + } + + sessionStore.set(key, next, searchHistorySchema); + setItems(next); + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT)); + } }, [key], ); diff --git a/tests/e2e/schedule-filter-resubmit.spec.ts b/tests/e2e/schedule-filter-resubmit.spec.ts new file mode 100644 index 00000000..5d0c6809 --- /dev/null +++ b/tests/e2e/schedule-filter-resubmit.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "./fixtures/console-gate"; + +test.describe("Schedule results filter", () => { + test("changed route criteria can be submitted immediately after a previous search", async ({ + page, + consoleMessages, + }) => { + await page.goto("/ru-ru/schedule/route/VVO-MJZ-20260518-20260524"); + + await expect(page.locator("h1")).toContainText(/Владивосток.*Мирный|VVO.*MJZ/, { + timeout: 30000, + }); + await expect(page.getByTestId("loader-bar")).toBeHidden({ timeout: 30000 }); + + const submit = page.getByTestId("search-submit"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(submit).toBeDisabled(); + + await page.getByTestId("swap-cities-button").click(); + + await expect(submit).toBeEnabled(); + + const scheduleResponse = page.waitForResponse( + (response) => + response.url().includes("/api/flights/1/ru/schedule") && + response.url().includes("departure=MJZ") && + response.url().includes("arrival=VVO"), + ); + await submit.click(); + + await expect(page).toHaveURL(/\/ru-ru\/schedule\/route\/MJZ-VVO-20260518-20260524/); + expect((await scheduleResponse).status()).toBe(200); + expect(consoleMessages).toEqual([]); + }); +});