Support suffixed online-board flight numbers
This commit is contained in:
@@ -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(
|
||||
<OnlineBoardFilter
|
||||
initialTab="flight"
|
||||
initialFlightNumber="38d"
|
||||
today="20260505"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
|
||||
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(<OnlineBoardFilter initialTab="flight" />);
|
||||
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(<OnlineBoardFilter initialTab="flight" />);
|
||||
fireEvent.change(screen.getByTestId("flight-number-input"), {
|
||||
target: { value: "abc" },
|
||||
|
||||
@@ -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<OnlineBoardFilterProps> = ({
|
||||
// flight number or empty route produces no API call.
|
||||
const flightCalendarParams = useMemo<CalendarParams | null>(() => {
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
// 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<OnlineBoardFilterProps> = ({
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
setFlightNumber(e.target.value);
|
||||
if (flightNumberError) setFlightNumberError(null);
|
||||
}}
|
||||
onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))}
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
{flightNumber && (
|
||||
|
||||
@@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({
|
||||
PageTabs: () => <div data-testid="page-tabs" />,
|
||||
}));
|
||||
|
||||
let capturedFilterProps: Record<string, unknown> | undefined;
|
||||
vi.mock("./OnlineBoardFilter.js", () => ({
|
||||
OnlineBoardFilter: () => <div data-testid="online-board-filter" />,
|
||||
OnlineBoardFilter: (props: Record<string, unknown>) => {
|
||||
capturedFilterProps = props;
|
||||
return <div data-testid="online-board-filter" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
|
||||
@@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
|
||||
useFeatureFlag: () => false,
|
||||
}));
|
||||
|
||||
let capturedSearchParams: Record<string, unknown> | undefined;
|
||||
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||
useOnlineBoard: () => ({
|
||||
useOnlineBoard: (params: Record<string, unknown>) => {
|
||||
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(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(capturedSearchParams?.flightNumber).toBe("SU0100D");
|
||||
expect(capturedFilterProps?.initialFlightNumber).toBe("0100D");
|
||||
});
|
||||
|
||||
it("renders for route search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
|
||||
@@ -115,7 +115,7 @@ function toSearchParams(
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
@@ -159,7 +159,7 @@ function toCalendarParams(
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
@@ -229,7 +229,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
: 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<OnlineBoardSearchPageProps> = ({
|
||||
: 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<OnlineBoardSearchPageProps> = ({
|
||||
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<OnlineBoardSearchPageProps> = ({
|
||||
}
|
||||
: params.type === "flight"
|
||||
? {
|
||||
initialFlightNumber: params.flightNumber,
|
||||
initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
|
||||
initialDate: params.date,
|
||||
initialTab: "flight" as const,
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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Разрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",
|
||||
|
||||
Reference in New Issue
Block a user