From ceab49f34f5b501633518eff245d79770473b7e6 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 6 May 2026 21:21:09 +0300 Subject: [PATCH] Support suffixed online-board flight numbers --- .../components/OnlineBoardFilter.test.tsx | 37 +++++++++- .../components/OnlineBoardFilter.tsx | 71 +++++++++++++------ .../components/OnlineBoardSearchPage.test.tsx | 41 +++++++++-- .../components/OnlineBoardSearchPage.tsx | 11 +-- src/i18n/locales/en/common.json | 2 +- src/i18n/locales/ru/common.json | 2 +- 6 files changed, 129 insertions(+), 35 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardFilter.test.tsx b/src/features/online-board/components/OnlineBoardFilter.test.tsx index 8f27379c..26c5a3e1 100644 --- a/src/features/online-board/components/OnlineBoardFilter.test.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.test.tsx @@ -410,6 +410,22 @@ describe("OnlineBoardFilter – operating days calendar parity (TIRREDESIGN-12)" expect(disabledDates).toContain("2026-05-04"); expect(disabledDates).toContain("2026-05-05"); }); + + it("requests flight calendar days with a normalized suffix flight number", () => { + render( + , + ); + + expect(calendarDaysMock.params).toContainEqual({ + date: "2026-05-04", + searchType: "flight", + flightNumber: "SU0038D", + }); + }); }); describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () => { @@ -451,7 +467,26 @@ describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () = expect(url).toMatch(/SU1234/); }); - it("4.1.9.3-R: letters in flight number show error and block submit", () => { + it("TIRREDESIGN-13: suffix flight number submits as normalized SU0038D URL", () => { + render(); + fireEvent.change(screen.getByTestId("flight-number-input"), { + target: { value: "38d" }, + }); + fireEvent.submit(screen.getByTestId("search-form")); + expect(mockNavigate).toHaveBeenCalled(); + const url = mockNavigate.mock.calls[0]![0] as string; + expect(url).toContain("/ru-ru/onlineboard/flight/SU0038D-20260601"); + }); + + it("TIRREDESIGN-13: suffix flight number normalizes on blur like Angular", () => { + render(); + const input = screen.getByTestId("flight-number-input") as HTMLInputElement; + fireEvent.change(input, { target: { value: "123d" } }); + fireEvent.blur(input); + expect(input.value).toBe("0123D"); + }); + + it("4.1.9.3-R: letters without digits show error and block submit", () => { render(); fireEvent.change(screen.getByTestId("flight-number-input"), { target: { value: "abc" }, diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index 6aed0160..6d84ea6a 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -53,28 +53,45 @@ function dateToIsoYmd(value: Date): string { return `${y}-${m}-${d}`; } +interface NormalizedFlightNumber { + flightNumber: string; + suffix?: string; + display: string; +} + /** - * Validates a flight number string per TZ §4.1.9.3: - * - Must be 1-4 digits only (no letters, no spaces). - * - Error message: "только из цифр и не должен быть длиннее 4-х символов". - * Shorter inputs (1-3 digits) are valid; they get zero-padded at submit time. + * Normalise the Angular flight-number field: + * `38` -> `0038`, `38D` -> `0038D`, `123d` -> `0123D`. */ +function normalizeFlightNumberInput(value: string): NormalizedFlightNumber | null { + const raw = value.trim(); + const match = /^(\d{1,4})([A-Za-z])?$/.exec(raw); + if (!match) return null; + const digits = match[1]; + if (!digits) return null; + const flightNumber = digits.padStart(4, "0"); + const suffix = match[2]?.toUpperCase(); + return suffix + ? { flightNumber, suffix, display: `${flightNumber}${suffix}` } + : { flightNumber, display: flightNumber }; +} + +function formatFlightNumberInput(value: string): string { + const normalized = normalizeFlightNumberInput(value); + return normalized?.display ?? value.toUpperCase(); +} + function validateFlightNumber(value: string): string | null { - if (!value.trim()) { + const raw = value.trim(); + if (!raw) { return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY"; } - const reg = /^\d{1,4}$/; - if (!reg.test(value.trim())) { + if (!normalizeFlightNumberInput(raw)) { return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER"; } return null; } -/** Zero-pads a 1-3 digit flight number to 4 digits per TZ §4.1.9.3 (e.g. "38" → "0038"). */ -function padFlightNumber(value: string): string { - return value.trim().padStart(4, "0"); -} - export interface OnlineBoardFilterProps { /** Pre-populate filter from URL params on search results pages */ initialDeparture?: string; @@ -217,12 +234,12 @@ export const OnlineBoardFilter: FC = ({ // flight number or empty route produces no API call. const flightCalendarParams = useMemo(() => { if (activeTab !== "flight") return null; - const digits = flightNumber.trim(); - if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null; + const normalized = normalizeFlightNumberInput(flightNumber); + if (!normalized) return null; return { date: boardCalendarBaseDate, searchType: "flight", - flightNumber: `SU${padFlightNumber(digits)}`, + flightNumber: `SU${normalized.flightNumber}${normalized.suffix ?? ""}`, }; }, [activeTab, flightNumber, boardCalendarBaseDate]); @@ -415,14 +432,13 @@ export const OnlineBoardFilter: FC = ({ // the placeholder ДД.ММ.ГГГГ is left untouched. const dateParam = dateToYyyymmdd(flightDate ?? new Date()); const carrier = "SU"; - // TZ §4.1.9.3: zero-pad to 4 digits (38 → 0038, 383 → 0383). - const num = padFlightNumber(flightNumber); - if (!num) return; + const normalized = normalizeFlightNumberInput(flightNumber); + if (!normalized) return; // TZ §4.1.8: persist filter snapshot for cross-section hydration. setBoardFilter({ mode: "flight-number", - flightNumber: `${carrier}${num}`, + flightNumber: `${carrier}${normalized.flightNumber}${normalized.suffix ?? ""}`, date: dateParam, timeFrom: "0000", timeTo: "2400", @@ -434,7 +450,21 @@ export const OnlineBoardFilter: FC = ({ setSubmitLockedUntil(Date.now() + 30_000); setNow(Date.now()); - const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam }); + const urlParams = normalized.suffix + ? { + type: "flight" as const, + carrier, + flightNumber: normalized.flightNumber, + suffix: normalized.suffix, + date: dateParam, + } + : { + type: "flight" as const, + carrier, + flightNumber: normalized.flightNumber, + date: dateParam, + }; + const url = buildOnlineBoardUrl(urlParams); void navigate(`/${locale}/${url}`); }, [flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature], @@ -579,6 +609,7 @@ export const OnlineBoardFilter: FC = ({ setFlightNumber(e.target.value); if (flightNumberError) setFlightNumberError(null); }} + onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))} data-testid="flight-number-input" /> {flightNumber && ( diff --git a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx index 64b59f2b..a34dbd2a 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx @@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({ PageTabs: () =>
, })); +let capturedFilterProps: Record | undefined; vi.mock("./OnlineBoardFilter.js", () => ({ - OnlineBoardFilter: () =>
, + OnlineBoardFilter: (props: Record) => { + capturedFilterProps = props; + return
; + }, })); vi.mock("@/shared/hooks/useSearchHistory.js", () => ({ @@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({ useFeatureFlag: () => false, })); +let capturedSearchParams: Record | undefined; vi.mock("../hooks/useOnlineBoard.js", () => ({ - useOnlineBoard: () => ({ - flights: [], - loading: false, - error: null, - refresh: vi.fn(), - }), + useOnlineBoard: (params: Record) => { + capturedSearchParams = params; + return { + flights: [], + loading: false, + error: null, + refresh: vi.fn(), + }; + }, })); vi.mock("../hooks/useLiveBoardSearch.js", () => ({ @@ -143,6 +151,8 @@ describe("OnlineBoardSearchPage", () => { beforeEach(() => { vi.clearAllMocks(); capturedInitialCurrentFlightId = undefined; + capturedFilterProps = undefined; + capturedSearchParams = undefined; }); afterEach(() => { @@ -176,6 +186,23 @@ describe("OnlineBoardSearchPage", () => { expect(screen.getByTestId("online-board-search")).toBeTruthy(); }); + it("TIRREDESIGN-13: preserves suffix in flight API params and filter value", () => { + render( + , + ); + + expect(capturedSearchParams?.flightNumber).toBe("SU0100D"); + expect(capturedFilterProps?.initialFlightNumber).toBe("0100D"); + }); + it("renders for route search type", () => { render( = ({ : params.type === "arrival" ? params.station : undefined; const flightNumber = isFlightNumber - ? `${params.carrier} ${params.flightNumber}` + ? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}` : undefined; const labelParts: string[] = []; if (departure) labelParts.push(departure); @@ -256,6 +256,7 @@ export const OnlineBoardSearchPage: FC = ({ : undefined, params.type === "flight" ? params.carrier : undefined, params.type === "flight" ? params.flightNumber : undefined, + params.type === "flight" ? params.suffix : undefined, params.date, ]); @@ -295,7 +296,7 @@ export const OnlineBoardSearchPage: FC = ({ if (dateLabel) searchHeading += `, ${dateLabel}`; break; case "flight": - searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`; + searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`; if (dateLabel) searchHeading += `, ${dateLabel}`; break; default: @@ -503,7 +504,7 @@ export const OnlineBoardSearchPage: FC = ({ } : params.type === "flight" ? { - initialFlightNumber: params.flightNumber, + initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`, initialDate: params.date, initialTab: "flight" as const, } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 08fc79c4..54c984f9 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -23,7 +23,7 @@ "FLIGHT_NUMBER": "Flight number", "FLIGHT_NUMBER-ERROR-BIG": "Incorrect flight number. The flight number may not be longer than 4 digits", "FLIGHT_NUMBER-ERROR-EMPTY": "Specify flight number", - "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number may only consist of digits and must not exceed 4 characters.", + "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number must consist of 1-4 digits and may include one Latin-letter suffix at the end.", "GPS-BUTTON": "Detect my location", "GPS-HELP": "Enable geolocation in your browser to detect the city automatically. Geolocation will not work if any anonymizers are enabled.", "NOT-FOUND-LOCATION": "You are seeing this page because we could not access your current location. \nAllow the app to access your location to view flights to your destination.", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index b4f00646..ff0c97c3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -23,7 +23,7 @@ "FLIGHT_NUMBER": "Номер рейса", "FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов", "FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса", - "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса может состоять только из цифр и не должен быть длиннее 4-х символов.", + "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса должен состоять из 1-4 цифр и может содержать в конце одну латинскую букву-суффикс.", "GPS-BUTTON": "Определить мое местоположение", "GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.", "NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",