diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 43a2e0c5..1dd20752 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -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(" { 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: "" }); diff --git a/src/features/schedule/api.ts b/src/features/schedule/api.ts index 6417c2a4..f5951f80 100644 --- a/src/features/schedule/api.ts +++ b/src/features/schedule/api.ts @@ -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++) { diff --git a/src/features/schedule/components/ScheduleFilter.test.tsx b/src/features/schedule/components/ScheduleFilter.test.tsx index e943e400..9b14b4fa 100644 --- a/src/features/schedule/components/ScheduleFilter.test.tsx +++ b/src/features/schedule/components/ScheduleFilter.test.tsx @@ -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) => { const inputRef = props["inputRef"] as React.RefObject | undefined; + const formatLocalYmd = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return ( @@ -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( + , + ); + + 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(); + + 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(); + + 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; }); diff --git a/src/features/schedule/components/ScheduleFilter.tsx b/src/features/schedule/components/ScheduleFilter.tsx index b5ee7de9..44a09959 100644 --- a/src/features/schedule/components/ScheduleFilter.tsx +++ b/src/features/schedule/components/ScheduleFilter.tsx @@ -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 = ({ 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 = ({ 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(() => { if (!returnFlights) return null; @@ -224,29 +220,35 @@ export const ScheduleFilter: FC = ({ 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. diff --git a/src/features/schedule/hooks/useScheduleCalendar.ts b/src/features/schedule/hooks/useScheduleCalendar.ts index fb172620..74ee9c03 100644 --- a/src/features/schedule/hooks/useScheduleCalendar.ts +++ b/src/features/schedule/hooks/useScheduleCalendar.ts @@ -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([]); 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 }; } diff --git a/tests/e2e/schedule-calendar-operating-days.spec.ts b/tests/e2e/schedule-calendar-operating-days.spec.ts index 33912e71..b3b5ecc4 100644 --- a/tests/e2e/schedule-calendar-operating-days.spec.ts +++ b/tests/e2e/schedule-calendar-operating-days.spec.ts @@ -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"); });