Fix schedule operating-day calendar parity
ci-deploy / build-deploy-test (push) Successful in 1m54s

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