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:
@@ -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 Mon–Sun 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 Mon–Sun 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 (Mon–Sun).
|
||||
*/
|
||||
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],
|
||||
|
||||
Reference in New Issue
Block a user