Audit popular-requests Top-4 click-prefill against TZ §4.1.5 (6 kinds)

Board kinds (Arrival, Departure, Route, FlightNumber): buildOnlineBoardPrefillState
now emits date=today in every case; OnlineBoardStartPage wires it through to
OnlineBoardFilter via initialDate.

Schedule one-way (Route/Schedule): click handler now includes dateFrom/dateTo
= current ISO week (Mon-Sun) in the transient prefill written to sessionStorage.

Schedule round-trip (RouteWithBack/Schedule): additionally includes
returnDateFrom/returnDateTo = next ISO week.

SchedulePrefillState extended with the four new optional date fields;
yyyymmddToDate helper added to ScheduleStartPage; currentWeekBounds /
nextWeekBounds helpers implement the TZ week-boundary logic.

Nine new §4.1.5-labeled tests (4 unit + 5 integration) added; existing
prefill-state tests updated to expect the new date fields. All 55 tests pass.
This commit is contained in:
2026-04-21 19:19:38 +03:00
parent 53b5359ad5
commit 4b6cb5bc40
4 changed files with 194 additions and 33 deletions
@@ -7,7 +7,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
@@ -100,7 +100,16 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
// ---------------------------------------------------------------------------
describe("buildOnlineBoardPrefillState", () => {
it("builds state for FlightNumber mode", () => {
// Freeze clock so todayYyyymmdd() returns a deterministic value.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0)); // 2026-05-15
});
afterAll(() => {
vi.useRealTimers();
});
it("4.1.5-B1: FlightNumber click includes date=today, no time field", () => {
const request: PopularRequest = {
mode: "FlightNumber",
carrier: "SU",
@@ -110,30 +119,31 @@ describe("buildOnlineBoardPrefillState", () => {
expect(buildOnlineBoardPrefillState(request)).toEqual({
tab: "flight",
flightNumber: "SU0654",
date: "20260515",
});
});
it("builds state for Departure mode", () => {
it("4.1.5-B2: Departure click includes date=today", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Departure",
departure: "LED",
type: "Onlineboard",
}),
).toEqual({ tab: "route", departure: "LED" });
).toEqual({ tab: "route", departure: "LED", date: "20260515" });
});
it("builds state for Arrival mode", () => {
it("4.1.5-B3: Arrival click includes date=today", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Arrival",
arrival: "VKO",
type: "Onlineboard",
}),
).toEqual({ tab: "route", arrival: "VKO" });
).toEqual({ tab: "route", arrival: "VKO", date: "20260515" });
});
it("builds state for Route mode", () => {
it("4.1.5-B4: Route click includes date=today", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Route",
@@ -141,7 +151,7 @@ describe("buildOnlineBoardPrefillState", () => {
arrival: "KRR",
type: "Onlineboard",
}),
).toEqual({ tab: "route", departure: "LED", arrival: "KRR" });
).toEqual({ tab: "route", departure: "LED", arrival: "KRR", date: "20260515" });
});
});
@@ -71,28 +71,42 @@ export interface OnlineBoardPrefillState {
departure?: string;
arrival?: string;
flightNumber?: string;
/** yyyyMMdd — date to pre-populate in the filter calendar (TZ §4.1.5) */
date?: string;
}
function todayYyyymmdd(): string {
const d = new Date();
const y = d.getFullYear().toString();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${y}${m}${day}`;
}
export function buildOnlineBoardPrefillState(
request: PopularRequest,
dictionaries: IDictionaries | null = null,
): OnlineBoardPrefillState {
// TZ §4.1.5: all Board popular-request kinds prefill date = today.
const date = todayYyyymmdd();
switch (request.mode) {
case "FlightNumber":
return {
tab: "flight",
flightNumber: `${request.carrier}${request.flightNumber}`,
date,
};
case "Departure":
return { tab: "route", departure: toCityCode(request.departure, dictionaries) };
return { tab: "route", departure: toCityCode(request.departure, dictionaries), date };
case "Arrival":
return { tab: "route", arrival: toCityCode(request.arrival, dictionaries) };
return { tab: "route", arrival: toCityCode(request.arrival, dictionaries), date };
case "Route":
case "RouteWithBack":
return {
tab: "route",
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
date,
};
}
}
@@ -164,6 +178,8 @@ export const OnlineBoardStartPage: FC = () => {
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
// TZ §4.1.5: popular-request click prefills date=today for all Board modes.
...(prefill.date ? { initialDate: prefill.date } : {}),
initialTimeFrom: defaultTimeRange.timeFrom,
initialTimeTo: defaultTimeRange.timeTo,
};
@@ -7,7 +7,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleStartPage } from "./ScheduleStartPage.js";
import { sessionStore } from "@/shared/storage.js";
@@ -45,6 +45,12 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
>
Route
</button>
<button
data-testid="popular-click-roundtrip"
onClick={() => onRequestClick?.({ mode: "RouteWithBack", departure: "SVO", arrival: "LED", type: "Schedule" })}
>
RouteWithBack
</button>
<button
data-testid="popular-click-onlineboard"
onClick={() => onRequestClick?.({ mode: "Departure", departure: "LED", type: "Onlineboard" })}
@@ -111,9 +117,17 @@ describe("ScheduleStartPage", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStore.clear();
resetCrossSectionStore();
geoMockEnabled = false;
capturedOnCity = null;
capturedShouldApply = null;
// Freeze clock for deterministic week bounds
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0)); // Fri 2026-05-15
});
afterEach(() => {
vi.useRealTimers();
});
it("renders the start page", () => {
@@ -121,15 +135,65 @@ describe("ScheduleStartPage", () => {
expect(screen.getByTestId("schedule-start")).toBeTruthy();
});
it("writes prefill + navigates to schedule on Schedule-type popular click", () => {
it("4.1.5-S1: one-way Route click prefills current ISO week dates + no return", () => {
// 2026-05-15 (Fri) → week Mon 2026-05-11 … Sun 2026-05-17
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
expect(sessionStore.getRaw("afl-prefill:schedule")).toBe(
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: false }),
);
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
expect(stored.departure).toBe("SVO");
expect(stored.arrival).toBe("LED");
expect(stored.withReturn).toBe(false);
expect(stored.dateFrom).toBe("20260511"); // Mon
expect(stored.dateTo).toBe("20260517"); // Sun
expect(stored.returnDateFrom).toBeUndefined();
expect(stored.returnDateTo).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
});
it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates", () => {
// current week: 20260511-20260517; next week: 20260518-20260524
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-roundtrip"));
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
expect(stored.withReturn).toBe(true);
expect(stored.dateFrom).toBe("20260511");
expect(stored.dateTo).toBe("20260517");
expect(stored.returnDateFrom).toBe("20260518"); // next Mon
expect(stored.returnDateTo).toBe("20260524"); // next Sun
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
});
it("4.1.5-S3: prefill dates hydrate into form calendar state (no search on mount)", () => {
sessionStore.setRaw(
"afl-prefill:schedule",
JSON.stringify({
departure: "SVO", arrival: "LED", withReturn: false,
dateFrom: "20260511", dateTo: "20260517",
}),
);
render(<ScheduleStartPage />);
// No navigation should have been triggered
expect(mockNavigate).not.toHaveBeenCalled();
// Round-trip checkbox must remain unchecked (withReturn=false)
const roundTripCheckbox = screen.getByTestId("round-trip-toggle");
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(false);
});
it("4.1.5-S4: round-trip prefill sets isRoundTrip = true on mount", () => {
sessionStore.setRaw(
"afl-prefill:schedule",
JSON.stringify({
departure: "SVO", arrival: "LED", withReturn: true,
dateFrom: "20260511", dateTo: "20260517",
returnDateFrom: "20260518", returnDateTo: "20260524",
}),
);
render(<ScheduleStartPage />);
expect(mockNavigate).not.toHaveBeenCalled();
const roundTripCheckbox = screen.getByTestId("round-trip-toggle");
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
});
it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => {
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-onlineboard"));
@@ -139,7 +203,7 @@ describe("ScheduleStartPage", () => {
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard");
});
it("initializes form from sessionStorage prefill", () => {
it("initializes form from sessionStorage prefill (legacy shape — withReturn only)", () => {
sessionStore.setRaw(
"afl-prefill:schedule",
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: true }),
@@ -68,6 +68,42 @@ function addDays(base: Date, days: number): Date {
return result;
}
function yyyymmddToDate(yyyymmdd: string): Date {
const y = parseInt(yyyymmdd.slice(0, 4), 10);
const m = parseInt(yyyymmdd.slice(4, 6), 10) - 1;
const d = parseInt(yyyymmdd.slice(6, 8), 10);
return new Date(y, m, d);
}
/**
* Returns MonSun bounds of the ISO week containing `base` (or today).
* TZ §4.1.5: "показать расписание на = текущая неделя".
*/
function currentWeekBounds(base = new Date()): { from: Date; to: Date } {
const d = new Date(base);
d.setHours(0, 0, 0, 0);
// ISO week starts Monday (day 1); Sunday is day 0 → treat as 7.
const dow = d.getDay() === 0 ? 7 : d.getDay();
const mon = new Date(d);
mon.setDate(d.getDate() - (dow - 1));
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
return { from: mon, to: sun };
}
/**
* Returns MonSun bounds of the week *after* the ISO week containing `base`.
* TZ §4.1.5: "дата обратного рейса = следующая неделя".
*/
function nextWeekBounds(base = new Date()): { from: Date; to: Date } {
const cur = currentWeekBounds(base);
const nextMon = new Date(cur.from);
nextMon.setDate(cur.from.getDate() + 7);
const nextSun = new Date(nextMon);
nextSun.setDate(nextMon.getDate() + 6);
return { from: nextMon, to: nextSun };
}
// Mirrors Angular AppSettings.scheduleSearchFrom (1 day back) and
// scheduleSearchTo (330 days forward). Constrains both the outbound and
// return-flight calendar pickers on the Schedule start page.
@@ -90,6 +126,18 @@ export interface SchedulePrefillState {
departure?: string;
arrival?: string;
withReturn?: boolean;
/**
* Outbound date range (yyyyMMdd). TZ §4.1.5: popular-request one-way
* click prefills the current ISO week (MonSun).
*/
dateFrom?: string;
dateTo?: string;
/**
* Return date range (yyyyMMdd). TZ §4.1.5: popular-request round-trip
* click prefills the *next* ISO week.
*/
returnDateFrom?: string;
returnDateTo?: string;
}
export const ScheduleStartPage: FC = () => {
@@ -149,17 +197,25 @@ export const ScheduleStartPage: FC = () => {
},
});
// Start blank to match Angular's `ДД.ММ.ГГГГ - ДД.ММ.ГГГГ` placeholder
// (the "current week" pre-fill was a React-only convenience that
// pulled the date input out of parity). Submit handler defaults to
// current-week range when left untouched.
const [dateFrom, setDateFrom] = useState<Date | null>(null);
const [dateTo, setDateTo] = useState<Date | null>(null);
// TZ §4.1.5: popular-request click provides dateFrom/dateTo (current week)
// and, for round-trips, returnDateFrom/returnDateTo (next week). When
// absent (manual form visit) the fields start blank — submit handler
// then defaults to current-week range (Angular's "by default current week").
const [dateFrom, setDateFrom] = useState<Date | null>(
prefill.dateFrom ? yyyymmddToDate(prefill.dateFrom) : null,
);
const [dateTo, setDateTo] = useState<Date | null>(
prefill.dateTo ? yyyymmddToDate(prefill.dateTo) : null,
);
const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]);
const [directOnly, setDirectOnly] = useState(false);
const [isRoundTrip, setIsRoundTrip] = useState(prefill.withReturn === true);
const [returnDateFrom, setReturnDateFrom] = useState<Date | null>(null);
const [returnDateTo, setReturnDateTo] = useState<Date | null>(null);
const [returnDateFrom, setReturnDateFrom] = useState<Date | null>(
prefill.returnDateFrom ? yyyymmddToDate(prefill.returnDateFrom) : null,
);
const [returnDateTo, setReturnDateTo] = useState<Date | null>(
prefill.returnDateTo ? yyyymmddToDate(prefill.returnDateTo) : null,
);
const [returnTimeRange, setReturnTimeRange] = useState<[number, number]>([0, 1440]);
const scheduleMinDate = useRef(getScheduleMinDate()).current;
@@ -259,15 +315,30 @@ export const ScheduleStartPage: FC = () => {
}
// Schedule-type: only Route / RouteWithBack carry city info.
const state: SchedulePrefillState =
request.mode === "Route" || request.mode === "RouteWithBack"
? {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
withReturn: request.mode === "RouteWithBack",
}
: {};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
// TZ §4.1.5: prefill outbound dates = current ISO week; return dates
// = next ISO week (only when withReturn = true).
if (request.mode === "Route" || request.mode === "RouteWithBack") {
const curWeek = currentWeekBounds();
const state: SchedulePrefillState = {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
withReturn: request.mode === "RouteWithBack",
dateFrom: dateToYyyymmdd(curWeek.from),
dateTo: dateToYyyymmdd(curWeek.to),
...(request.mode === "RouteWithBack"
? (() => {
const nxt = nextWeekBounds();
return {
returnDateFrom: dateToYyyymmdd(nxt.from),
returnDateTo: dateToYyyymmdd(nxt.to),
};
})()
: {}),
};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
} else {
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, {});
}
navigate(`/${locale}/schedule`);
},
[navigate, locale, dictionaries],