This commit is contained in:
+12
-1
@@ -169,7 +169,18 @@ function isSuccessfulUpstream({ err, stdout }) {
|
||||
const lastNewline = stdout.lastIndexOf("\n");
|
||||
const statusStr = lastNewline >= 0 ? stdout.substring(lastNewline + 1).trim() : "";
|
||||
const status = parseInt(statusStr) || 0;
|
||||
return status >= 200 && status < 400;
|
||||
if (status < 200 || status >= 400) return false;
|
||||
|
||||
const body = lastNewline >= 0 ? stdout.substring(0, lastNewline) : stdout;
|
||||
const trimmed = body.trimStart();
|
||||
// Some upstream edges return the Angular shell as HTTP 200 for API
|
||||
// paths on the direct transport. Treat that as a failed API response so
|
||||
// the fallback transport can fetch the JSON payload.
|
||||
if (trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function respondWithCurlResult({ err, stdout }, res) {
|
||||
|
||||
@@ -232,6 +232,19 @@ describe("getScheduleCalendarDays", () => {
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("parses bitmask days from the requested base date", async () => {
|
||||
const { client } = createMockClient({ days: "101" });
|
||||
|
||||
const result = await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
|
||||
@@ -118,8 +118,8 @@ export async function getScheduleDetails(
|
||||
* Maps to: `GET days/{date}/382/{param}/schedule/v1`
|
||||
*
|
||||
* The API returns `{ days: "1111110001..." }` — a 382-char bitmask
|
||||
* where each character represents a day starting from (baseDate - 1).
|
||||
* '1' = available, '0' = no flights. Equivalent to the board endpoint.
|
||||
* where each character represents a day starting from the requested
|
||||
* base date. '1' = available, '0' = no flights.
|
||||
*/
|
||||
export async function getScheduleCalendarDays(
|
||||
client: ApiClient,
|
||||
@@ -163,7 +163,6 @@ function bitmaskToDates(bitmask: string, baseDate: string): string[] {
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return [];
|
||||
const cursor = new Date(Number(y), Number(m) - 1, Number(d));
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < bitmask.length; i++) {
|
||||
|
||||
@@ -14,6 +14,11 @@ import { ScheduleFilter } from "./ScheduleFilter.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const scheduleCalendarMock = vi.hoisted(() => ({
|
||||
days: [] as string[],
|
||||
loaded: false,
|
||||
params: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
@@ -35,17 +40,29 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
|
||||
// useScheduleCalendar would otherwise need the api provider in the test
|
||||
// tree just for the disabled-dates wiring added in TIRREDESIGN-12.
|
||||
vi.mock("../hooks/useScheduleCalendar.js", () => ({
|
||||
useScheduleCalendar: () => ({ days: [], loading: false }),
|
||||
useScheduleCalendar: (params: unknown) => {
|
||||
scheduleCalendarMock.params.push(params);
|
||||
return {
|
||||
days: scheduleCalendarMock.days,
|
||||
loading: false,
|
||||
loaded: scheduleCalendarMock.loaded,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// PrimeReact Calendar stub — read-only input so state is driven by props
|
||||
vi.mock("primereact/calendar", () => ({
|
||||
Calendar: (props: Record<string, unknown>) => {
|
||||
const inputRef = props["inputRef"] as React.RefObject<HTMLInputElement> | undefined;
|
||||
const formatLocalYmd = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return (
|
||||
<input
|
||||
data-testid={props["data-testid"] as string}
|
||||
placeholder={props["placeholder"] as string}
|
||||
data-disabled-dates={((props["disabledDates"] as Date[] | undefined) ?? [])
|
||||
.map(formatLocalYmd)
|
||||
.join(",")}
|
||||
readOnly
|
||||
ref={inputRef}
|
||||
/>
|
||||
@@ -96,6 +113,9 @@ vi.mock("@/shared/dateWindow.js", () => ({
|
||||
describe("ScheduleFilter – clear-button (X) per TZ §4.1.9 Tables 13/14", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = false;
|
||||
scheduleCalendarMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
_sliderOnChanges.length = 0;
|
||||
});
|
||||
@@ -196,6 +216,9 @@ describe("ScheduleFilter – clear-button (X) per TZ §4.1.9 Tables 13/14", () =
|
||||
describe("ScheduleFilter – time slider 1h minimum gap per TZ §4.1.9 Table 14", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = false;
|
||||
scheduleCalendarMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
_sliderOnChanges.length = 0;
|
||||
});
|
||||
@@ -238,9 +261,74 @@ describe("ScheduleFilter – time slider 1h minimum gap per TZ §4.1.9 Table 14"
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleFilter – operating days calendar parity (TIRREDESIGN-12)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = false;
|
||||
scheduleCalendarMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
_sliderOnChanges.length = 0;
|
||||
});
|
||||
|
||||
it("requests schedule days from the calendar minimum date", () => {
|
||||
scheduleCalendarMock.days = ["2026-01-02"];
|
||||
scheduleCalendarMock.loaded = true;
|
||||
|
||||
render(
|
||||
<ScheduleFilter
|
||||
initialDeparture="LED"
|
||||
initialArrival="KUF"
|
||||
initialDirectOnly={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(scheduleCalendarMock.params[0]).toMatchObject({
|
||||
date: "2026-01-01",
|
||||
departure: "LED",
|
||||
arrival: "KUF",
|
||||
connections: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables dates that are missing from the loaded operating-days response", () => {
|
||||
scheduleCalendarMock.days = ["2026-01-02"];
|
||||
scheduleCalendarMock.loaded = true;
|
||||
|
||||
render(<ScheduleFilter initialDeparture="LED" initialArrival="KUF" />);
|
||||
|
||||
const disabledDates = screen
|
||||
.getByTestId("schedule-date-input")
|
||||
.getAttribute("data-disabled-dates")
|
||||
?.split(",");
|
||||
|
||||
expect(disabledDates).toContain("2026-01-01");
|
||||
expect(disabledDates).not.toContain("2026-01-02");
|
||||
expect(disabledDates).toContain("2026-01-03");
|
||||
});
|
||||
|
||||
it("treats a loaded empty operating-days response as no available dates", () => {
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = true;
|
||||
|
||||
render(<ScheduleFilter initialDeparture="LED" initialArrival="KUF" />);
|
||||
|
||||
const disabledDates = screen
|
||||
.getByTestId("schedule-date-input")
|
||||
.getAttribute("data-disabled-dates")
|
||||
?.split(",");
|
||||
|
||||
expect(disabledDates).toContain("2026-01-01");
|
||||
expect(disabledDates).toContain("2026-01-02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleFilter – validation per TZ §4.1.9.4", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = false;
|
||||
scheduleCalendarMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
_sliderOnChanges.length = 0;
|
||||
});
|
||||
|
||||
@@ -71,14 +71,6 @@ function yyyymmddToDate(yyyymmdd?: string): Date | null {
|
||||
return new Date(y, m, d);
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as `yyyy-MM-dd` — the shape returned by the schedule
|
||||
* `/calendar` API (`bitmaskToDates` in api.ts). Used only for the
|
||||
@@ -200,6 +192,10 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
|
||||
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
||||
const scheduleMaxDate = useRef(getScheduleMaxDate()).current;
|
||||
const scheduleCalendarBaseDate = useMemo(
|
||||
() => dateToIsoYmd(scheduleMinDate),
|
||||
[scheduleMinDate],
|
||||
);
|
||||
|
||||
// TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the
|
||||
// selected route so PrimeReact greys out days without flights. The
|
||||
@@ -211,12 +207,12 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
const arr = arrival.trim().toUpperCase();
|
||||
if (!dep || !arr || dep === arr) return null;
|
||||
return {
|
||||
date: todayIso(),
|
||||
date: scheduleCalendarBaseDate,
|
||||
departure: dep,
|
||||
arrival: arr,
|
||||
connections: !directOnly,
|
||||
};
|
||||
}, [departure, arrival, directOnly]);
|
||||
}, [departure, arrival, directOnly, scheduleCalendarBaseDate]);
|
||||
|
||||
const returnCalendarParams = useMemo<IScheduleCalendarParams | null>(() => {
|
||||
if (!returnFlights) return null;
|
||||
@@ -224,29 +220,35 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
const arr = arrival.trim().toUpperCase();
|
||||
if (!dep || !arr || dep === arr) return null;
|
||||
return {
|
||||
date: todayIso(),
|
||||
date: scheduleCalendarBaseDate,
|
||||
departure: arr,
|
||||
arrival: dep,
|
||||
connections: !directOnly,
|
||||
};
|
||||
}, [departure, arrival, directOnly, returnFlights]);
|
||||
}, [departure, arrival, directOnly, returnFlights, scheduleCalendarBaseDate]);
|
||||
|
||||
const { days: scheduleAvailableDays } = useScheduleCalendar(scheduleCalendarParams);
|
||||
const { days: returnAvailableDays } = useScheduleCalendar(returnCalendarParams);
|
||||
const {
|
||||
days: scheduleAvailableDays,
|
||||
loaded: scheduleCalendarLoaded,
|
||||
} = useScheduleCalendar(scheduleCalendarParams);
|
||||
const {
|
||||
days: returnAvailableDays,
|
||||
loaded: returnCalendarLoaded,
|
||||
} = useScheduleCalendar(returnCalendarParams);
|
||||
|
||||
const scheduleDisabledDates = useMemo(
|
||||
() =>
|
||||
scheduleAvailableDays.length === 0
|
||||
!scheduleCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(scheduleAvailableDays, scheduleMinDate, scheduleMaxDate),
|
||||
[scheduleAvailableDays, scheduleMinDate, scheduleMaxDate],
|
||||
[scheduleAvailableDays, scheduleCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||
);
|
||||
const returnDisabledDates = useMemo(
|
||||
() =>
|
||||
returnAvailableDays.length === 0
|
||||
!returnCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(returnAvailableDays, scheduleMinDate, scheduleMaxDate),
|
||||
[returnAvailableDays, scheduleMinDate, scheduleMaxDate],
|
||||
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||
);
|
||||
|
||||
// §4.1.11 — submit button locked for 30 seconds after each search.
|
||||
|
||||
@@ -43,6 +43,7 @@ async function getCachedScheduleCalendarDays(
|
||||
export interface UseScheduleCalendarResult {
|
||||
days: string[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,28 +57,33 @@ export function useScheduleCalendar(
|
||||
const client = useApiClient();
|
||||
const [days, setDays] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(params));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
|
||||
getCachedScheduleCalendarDays(client, params)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDays(result);
|
||||
setLoading(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,5 +98,5 @@ export function useScheduleCalendar(
|
||||
params?.connections,
|
||||
]);
|
||||
|
||||
return { days, loading };
|
||||
return { days, loading, loaded };
|
||||
}
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
import { test, expect } from "./fixtures/console-gate";
|
||||
|
||||
// TIRREDESIGN-12 — when both schedule cities are filled, the date-picker
|
||||
// must grey out the days the route does NOT operate. The fix in
|
||||
// ScheduleFilter.tsx aligned the date-format used for the
|
||||
// available-days set lookup (yyyy-MM-dd, matching the schedule
|
||||
// `/days` API output) — previously the lookup compared `yyyymmdd`
|
||||
// against `yyyy-MM-dd`, so every day was treated as unavailable
|
||||
// and the entire calendar greyed out.
|
||||
// must grey out the days the route does NOT operate. The schedule
|
||||
// `/days` bitmask is anchored to the requested calendar minimum date:
|
||||
// bit 0 maps to that exact date, not "base date minus one".
|
||||
|
||||
test("Schedule calendar greys out non-operating days for the route", async ({
|
||||
page,
|
||||
consoleMessages,
|
||||
}) => {
|
||||
await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503");
|
||||
await expect(page.locator(".day-grouped-flight-list").first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
function ymd(date: Date): string {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
test("Schedule calendar greys out non-operating days for the route", async ({ page }) => {
|
||||
const minDate = addDays(new Date(), -1);
|
||||
minDate.setHours(0, 0, 0, 0);
|
||||
const dateTo = addDays(minDate, 6);
|
||||
|
||||
await page.route("**/api/flights/v1/*/days/**/schedule/", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ days: `0${"1".repeat(381)}` }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/ru-ru/schedule/route/LED-KUF-${ymd(minDate)}-${ymd(dateTo)}-C0`);
|
||||
|
||||
// Open the picker.
|
||||
await page.locator("button.p-datepicker-trigger").first().click();
|
||||
const panel = page.locator(".p-datepicker-panel, .p-datepicker").first();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Wait for the operating-days API to come back (the disabled-set
|
||||
// is derived from it; before that, every day is enabled).
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page
|
||||
.locator(".p-datepicker td span.p-disabled")
|
||||
.count(),
|
||||
{ timeout: 10000 })
|
||||
.toBeGreaterThan(0);
|
||||
const visibleDaySelector = ".p-datepicker td:not(.p-datepicker-other-month) span";
|
||||
const minDateDay = String(minDate.getDate());
|
||||
const nextDateDay = String(addDays(minDate, 1).getDate());
|
||||
|
||||
// The MOW→MMK route operates roughly daily today onward, so the
|
||||
// visible month must contain BOTH enabled and disabled cells.
|
||||
// The check is intentionally loose because the live operating
|
||||
// schedule shifts by route — but a fully-enabled or fully-disabled
|
||||
// calendar would prove the format-mismatch regression returned.
|
||||
const enabled = await page
|
||||
.locator(".p-datepicker td span:not(.p-disabled)")
|
||||
.count();
|
||||
const disabled = await page
|
||||
.locator(".p-datepicker td span.p-disabled")
|
||||
.count();
|
||||
expect(enabled).toBeGreaterThan(0);
|
||||
expect(disabled).toBeGreaterThan(0);
|
||||
await expect.poll(async () => {
|
||||
const cells = await page.locator(visibleDaySelector).evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
text: node.textContent?.trim(),
|
||||
className: node.className,
|
||||
})),
|
||||
);
|
||||
return cells.find((cell) => cell.text === minDateDay)?.className ?? "";
|
||||
}, { timeout: 10000 }).toContain("p-disabled");
|
||||
|
||||
const cells = await page.locator(visibleDaySelector).evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
text: node.textContent?.trim(),
|
||||
className: node.className,
|
||||
})),
|
||||
);
|
||||
expect(cells.find((cell) => cell.text === nextDateDay)?.className ?? "").not.toContain("p-disabled");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user