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 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) {
+13
View File
@@ -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: "" });
+2 -3
View File
@@ -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");
});