Support suffixed online-board flight numbers

This commit is contained in:
2026-05-06 21:21:09 +03:00
parent 3411d71b00
commit ceab49f34f
6 changed files with 129 additions and 35 deletions
@@ -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: () => ({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
}),
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,
}
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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Разрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",