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-04");
|
||||||
expect(disabledDates).toContain("2026-05-05");
|
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", () => {
|
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/);
|
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" />);
|
render(<OnlineBoardFilter initialTab="flight" />);
|
||||||
fireEvent.change(screen.getByTestId("flight-number-input"), {
|
fireEvent.change(screen.getByTestId("flight-number-input"), {
|
||||||
target: { value: "abc" },
|
target: { value: "abc" },
|
||||||
|
|||||||
@@ -53,28 +53,45 @@ function dateToIsoYmd(value: Date): string {
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizedFlightNumber {
|
||||||
|
flightNumber: string;
|
||||||
|
suffix?: string;
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a flight number string per TZ §4.1.9.3:
|
* Normalise the Angular flight-number field:
|
||||||
* - Must be 1-4 digits only (no letters, no spaces).
|
* `38` -> `0038`, `38D` -> `0038D`, `123d` -> `0123D`.
|
||||||
* - Error message: "только из цифр и не должен быть длиннее 4-х символов".
|
|
||||||
* Shorter inputs (1-3 digits) are valid; they get zero-padded at submit time.
|
|
||||||
*/
|
*/
|
||||||
|
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 {
|
function validateFlightNumber(value: string): string | null {
|
||||||
if (!value.trim()) {
|
const raw = value.trim();
|
||||||
|
if (!raw) {
|
||||||
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
|
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
|
||||||
}
|
}
|
||||||
const reg = /^\d{1,4}$/;
|
if (!normalizeFlightNumberInput(raw)) {
|
||||||
if (!reg.test(value.trim())) {
|
|
||||||
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
|
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
|
||||||
}
|
}
|
||||||
return null;
|
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 {
|
export interface OnlineBoardFilterProps {
|
||||||
/** Pre-populate filter from URL params on search results pages */
|
/** Pre-populate filter from URL params on search results pages */
|
||||||
initialDeparture?: string;
|
initialDeparture?: string;
|
||||||
@@ -217,12 +234,12 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
// flight number or empty route produces no API call.
|
// flight number or empty route produces no API call.
|
||||||
const flightCalendarParams = useMemo<CalendarParams | null>(() => {
|
const flightCalendarParams = useMemo<CalendarParams | null>(() => {
|
||||||
if (activeTab !== "flight") return null;
|
if (activeTab !== "flight") return null;
|
||||||
const digits = flightNumber.trim();
|
const normalized = normalizeFlightNumberInput(flightNumber);
|
||||||
if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null;
|
if (!normalized) return null;
|
||||||
return {
|
return {
|
||||||
date: boardCalendarBaseDate,
|
date: boardCalendarBaseDate,
|
||||||
searchType: "flight",
|
searchType: "flight",
|
||||||
flightNumber: `SU${padFlightNumber(digits)}`,
|
flightNumber: `SU${normalized.flightNumber}${normalized.suffix ?? ""}`,
|
||||||
};
|
};
|
||||||
}, [activeTab, flightNumber, boardCalendarBaseDate]);
|
}, [activeTab, flightNumber, boardCalendarBaseDate]);
|
||||||
|
|
||||||
@@ -415,14 +432,13 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
// the placeholder ДД.ММ.ГГГГ is left untouched.
|
// the placeholder ДД.ММ.ГГГГ is left untouched.
|
||||||
const dateParam = dateToYyyymmdd(flightDate ?? new Date());
|
const dateParam = dateToYyyymmdd(flightDate ?? new Date());
|
||||||
const carrier = "SU";
|
const carrier = "SU";
|
||||||
// TZ §4.1.9.3: zero-pad to 4 digits (38 → 0038, 383 → 0383).
|
const normalized = normalizeFlightNumberInput(flightNumber);
|
||||||
const num = padFlightNumber(flightNumber);
|
if (!normalized) return;
|
||||||
if (!num) return;
|
|
||||||
|
|
||||||
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
|
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
|
||||||
setBoardFilter({
|
setBoardFilter({
|
||||||
mode: "flight-number",
|
mode: "flight-number",
|
||||||
flightNumber: `${carrier}${num}`,
|
flightNumber: `${carrier}${normalized.flightNumber}${normalized.suffix ?? ""}`,
|
||||||
date: dateParam,
|
date: dateParam,
|
||||||
timeFrom: "0000",
|
timeFrom: "0000",
|
||||||
timeTo: "2400",
|
timeTo: "2400",
|
||||||
@@ -434,7 +450,21 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
setSubmitLockedUntil(Date.now() + 30_000);
|
setSubmitLockedUntil(Date.now() + 30_000);
|
||||||
setNow(Date.now());
|
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}`);
|
void navigate(`/${locale}/${url}`);
|
||||||
},
|
},
|
||||||
[flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature],
|
[flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature],
|
||||||
@@ -579,6 +609,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
|||||||
setFlightNumber(e.target.value);
|
setFlightNumber(e.target.value);
|
||||||
if (flightNumberError) setFlightNumberError(null);
|
if (flightNumberError) setFlightNumberError(null);
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))}
|
||||||
data-testid="flight-number-input"
|
data-testid="flight-number-input"
|
||||||
/>
|
/>
|
||||||
{flightNumber && (
|
{flightNumber && (
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({
|
|||||||
PageTabs: () => <div data-testid="page-tabs" />,
|
PageTabs: () => <div data-testid="page-tabs" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let capturedFilterProps: Record<string, unknown> | undefined;
|
||||||
vi.mock("./OnlineBoardFilter.js", () => ({
|
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", () => ({
|
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
|
||||||
@@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
|
|||||||
useFeatureFlag: () => false,
|
useFeatureFlag: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let capturedSearchParams: Record<string, unknown> | undefined;
|
||||||
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||||
useOnlineBoard: () => ({
|
useOnlineBoard: (params: Record<string, unknown>) => {
|
||||||
flights: [],
|
capturedSearchParams = params;
|
||||||
loading: false,
|
return {
|
||||||
error: null,
|
flights: [],
|
||||||
refresh: vi.fn(),
|
loading: false,
|
||||||
}),
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
||||||
@@ -143,6 +151,8 @@ describe("OnlineBoardSearchPage", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
capturedInitialCurrentFlightId = undefined;
|
capturedInitialCurrentFlightId = undefined;
|
||||||
|
capturedFilterProps = undefined;
|
||||||
|
capturedSearchParams = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -176,6 +186,23 @@ describe("OnlineBoardSearchPage", () => {
|
|||||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
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", () => {
|
it("renders for route search type", () => {
|
||||||
render(
|
render(
|
||||||
<OnlineBoardSearchPage
|
<OnlineBoardSearchPage
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function toSearchParams(
|
|||||||
|
|
||||||
switch (params.type) {
|
switch (params.type) {
|
||||||
case "flight":
|
case "flight":
|
||||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||||
break;
|
break;
|
||||||
case "departure":
|
case "departure":
|
||||||
base.departure = params.station;
|
base.departure = params.station;
|
||||||
@@ -159,7 +159,7 @@ function toCalendarParams(
|
|||||||
|
|
||||||
switch (params.type) {
|
switch (params.type) {
|
||||||
case "flight":
|
case "flight":
|
||||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||||
break;
|
break;
|
||||||
case "departure":
|
case "departure":
|
||||||
base.departure = params.station;
|
base.departure = params.station;
|
||||||
@@ -229,7 +229,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
: params.type === "arrival" ? params.station
|
: params.type === "arrival" ? params.station
|
||||||
: undefined;
|
: undefined;
|
||||||
const flightNumber = isFlightNumber
|
const flightNumber = isFlightNumber
|
||||||
? `${params.carrier} ${params.flightNumber}`
|
? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const labelParts: string[] = [];
|
const labelParts: string[] = [];
|
||||||
if (departure) labelParts.push(departure);
|
if (departure) labelParts.push(departure);
|
||||||
@@ -256,6 +256,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
params.type === "flight" ? params.carrier : undefined,
|
params.type === "flight" ? params.carrier : undefined,
|
||||||
params.type === "flight" ? params.flightNumber : undefined,
|
params.type === "flight" ? params.flightNumber : undefined,
|
||||||
|
params.type === "flight" ? params.suffix : undefined,
|
||||||
params.date,
|
params.date,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -295,7 +296,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||||
break;
|
break;
|
||||||
case "flight":
|
case "flight":
|
||||||
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`;
|
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
||||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -503,7 +504,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
}
|
}
|
||||||
: params.type === "flight"
|
: params.type === "flight"
|
||||||
? {
|
? {
|
||||||
initialFlightNumber: params.flightNumber,
|
initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
|
||||||
initialDate: params.date,
|
initialDate: params.date,
|
||||||
initialTab: "flight" as const,
|
initialTab: "flight" as const,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"FLIGHT_NUMBER": "Flight number",
|
"FLIGHT_NUMBER": "Flight number",
|
||||||
"FLIGHT_NUMBER-ERROR-BIG": "Incorrect flight number. The flight number may not be longer than 4 digits",
|
"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-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-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.",
|
"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.",
|
"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": "Номер рейса",
|
||||||
"FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов",
|
"FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов",
|
||||||
"FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса",
|
"FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса",
|
||||||
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса может состоять только из цифр и не должен быть длиннее 4-х символов.",
|
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса должен состоять из 1-4 цифр и может содержать в конце одну латинскую букву-суффикс.",
|
||||||
"GPS-BUTTON": "Определить мое местоположение",
|
"GPS-BUTTON": "Определить мое местоположение",
|
||||||
"GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.",
|
"GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.",
|
||||||
"NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",
|
"NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",
|
||||||
|
|||||||
Reference in New Issue
Block a user