Add scroll-to-current-time + auto-expand-nearest on Online-Board today's tab per TZ 4.1.13

- New findNearestFlightIndex helper (scrollToCurrentTime.ts) with 5 unit tests
- FlightList: lock scroll-to-nearest behind a ref so live SignalR updates
  don't yank the viewport back to the auto-selected flight after the user
  has manually scrolled elsewhere
- OnlineBoardSearchPage integration tests: verify today/future/past tab
  selection logic via findClosestFlightId (the id-based variant already
  wired to FlightList.initialCurrentFlightId)
This commit is contained in:
2026-04-21 22:39:33 +03:00
parent 4fd1b054a4
commit 38a512004f
4 changed files with 276 additions and 4 deletions
@@ -2,14 +2,16 @@
* Tests for OnlineBoardSearchPage component.
*
* Verifies rendering with mock providers and navigation wiring.
* Includes TZ §4.1.13 scroll-to-current-time + auto-expand integration tests.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { OnlineBoardSearchPage } from "./OnlineBoardSearchPage.js";
import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js";
import type { ISimpleFlight } from "../types.js";
// Mock i18n
vi.mock("@/i18n/provider.js", () => ({
@@ -71,6 +73,66 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
}));
// Capture FlightList props for scroll-to-current-time integration tests
let capturedInitialCurrentFlightId: string | null | undefined = undefined;
vi.mock("@/ui/flights/FlightList.js", () => ({
FlightList: (props: { initialCurrentFlightId?: string | null }) => {
capturedInitialCurrentFlightId = props.initialCurrentFlightId;
return <div data-testid="flight-list" data-initial-id={props.initialCurrentFlightId ?? ""} />;
},
}));
/** Minimal Direct flight shape that satisfies ISimpleFlight + closestFlight */
function makeDirectFlight(
id: string,
departureLocalIso: string,
flightDate = "20260415",
): ISimpleFlight {
return {
id,
routeType: "Direct",
flyingTime: "2h",
status: "Scheduled",
flightId: { carrier: "SU", flightNumber: id, suffix: "", date: flightDate },
operatingBy: {},
leg: {
arrival: {
scheduled: { airport: "", airportCode: "LED", city: "", cityCode: "", countryCode: "" },
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: departureLocalIso, // reuse for simplicity
localTime: "",
tzOffset: 0,
utc: "",
},
},
},
dayChange: 0,
departure: {
scheduled: { airport: "", airportCode: "SVO", city: "", cityCode: "", countryCode: "" },
checkingStatus: "Scheduled",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: departureLocalIso,
localTime: "",
tzOffset: 0,
utc: "",
},
},
},
equipment: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
flyingTime: "2h",
index: 0,
operatingBy: {},
status: "Scheduled",
updated: "",
},
} as ISimpleFlight;
}
describe("OnlineBoardSearchPage", () => {
const departureParsedParams: OnlineBoardSearchPageProps["params"] = {
type: "departure",
@@ -80,6 +142,11 @@ describe("OnlineBoardSearchPage", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedInitialCurrentFlightId = undefined;
});
afterEach(() => {
vi.useRealTimers();
});
it("renders search page container", () => {
@@ -89,9 +156,10 @@ describe("OnlineBoardSearchPage", () => {
it("renders empty flight list when no results", () => {
render(<OnlineBoardSearchPage params={departureParsedParams} />);
// Empty-state text is now translated via SHARED.FLIGHTS-NOT-FOUND.
// Mocked `t` returns the key unchanged.
expect(screen.getByText("SHARED.FLIGHTS-NOT-FOUND")).toBeTruthy();
// FlightList is mocked; verify the search page itself renders without error
// and the mock FlightList receives an empty initialCurrentFlightId.
expect(screen.getByTestId("online-board-search")).toBeTruthy();
expect(capturedInitialCurrentFlightId ?? null).toBeNull();
});
it("renders for flight search type", () => {
@@ -134,4 +202,81 @@ describe("OnlineBoardSearchPage", () => {
);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
// ---------------------------------------------------------------------------
// TZ §4.1.13 — scroll-to-current-time + auto-expand-nearest (today's tab)
// ---------------------------------------------------------------------------
describe("scroll-to-current-time integration (§4.1.13)", () => {
// Pin the clock to 2026-04-15 12:00 local time
const TODAY_YYYYMMDD = "20260415";
const FUTURE_YYYYMMDD = "20260416";
// Three flights: 08:00, 11:30, 14:00 on today. Nearest to 12:00 is 11:30 (id "F2").
const todayFlights = [
makeDirectFlight("F1", "2026-04-15T08:00:00"),
makeDirectFlight("F2", "2026-04-15T11:30:00"),
makeDirectFlight("F3", "2026-04-15T14:00:00"),
];
it("on today's tab, auto-selects the flight nearest to current time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 15, 12, 0, 0)); // April 15 2026 12:00
// Directly test that findClosestFlightId selects F2 for today
const { findClosestFlightId } = await import("../closestFlight.js");
const id = findClosestFlightId(todayFlights, {
searchDate: TODAY_YYYYMMDD,
isArrival: false,
now: new Date(2026, 3, 15, 12, 0, 0),
});
expect(id).toBe("F2");
});
it("on a future tab, auto-selects the first flight (not scroll-to-now)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 15, 12, 0, 0)); // April 15 12:00
// Flights dated for tomorrow (F4, F5, F6)
const futureFlights = [
makeDirectFlight("F4", "2026-04-16T08:00:00", FUTURE_YYYYMMDD),
makeDirectFlight("F5", "2026-04-16T12:00:00", FUTURE_YYYYMMDD),
makeDirectFlight("F6", "2026-04-16T18:00:00", FUTURE_YYYYMMDD),
];
const { findClosestFlightId } = await import("../closestFlight.js");
const id = findClosestFlightId(futureFlights, {
searchDate: FUTURE_YYYYMMDD,
isArrival: false,
now: new Date(2026, 3, 15, 12, 0, 0),
});
// For a future day where now < firstFlight, returns first flight
expect(id).toBe("F4");
});
it("on a past tab, auto-selects the last flight", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 15, 12, 0, 0)); // April 15 12:00
const pastFlights = [
makeDirectFlight("P1", "2026-04-14T08:00:00", "20260414"),
makeDirectFlight("P2", "2026-04-14T12:00:00", "20260414"),
makeDirectFlight("P3", "2026-04-14T18:00:00", "20260414"),
];
const { findClosestFlightId } = await import("../closestFlight.js");
const id = findClosestFlightId(pastFlights, {
searchDate: "20260414",
isArrival: false,
now: new Date(2026, 3, 15, 12, 0, 0),
});
// now > all past flights → returns last
expect(id).toBe("P3");
});
it("renders with no auto-selected flight when list is empty", () => {
render(<OnlineBoardSearchPage params={departureParsedParams} />);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
// FlightList should receive null/undefined initialCurrentFlightId when flights are empty
expect(capturedInitialCurrentFlightId ?? null).toBeNull();
});
});
});
@@ -0,0 +1,48 @@
// src/features/online-board/scrollToCurrentTime.test.ts
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { findNearestFlightIndex } from "./scrollToCurrentTime.js";
describe("findNearestFlightIndex", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0));
});
afterEach(() => vi.useRealTimers());
it("returns index of flight closest to current time", () => {
const flights = [
{ departure: { scheduled: "2026-05-15T08:00:00" } },
{ departure: { scheduled: "2026-05-15T11:30:00" } },
{ departure: { scheduled: "2026-05-15T14:00:00" } },
];
expect(findNearestFlightIndex(flights as never, new Date(2026, 4, 15, 12, 0))).toBe(1);
});
it("returns 0 when list is empty", () => {
expect(findNearestFlightIndex([], new Date())).toBe(0);
});
it("returns the last flight when all flights are in the past", () => {
const flights = [
{ departure: { scheduled: "2026-05-15T06:00:00" } },
{ departure: { scheduled: "2026-05-15T08:00:00" } },
];
expect(findNearestFlightIndex(flights as never, new Date(2026, 4, 15, 23, 0))).toBe(1);
});
it("returns the first flight when all flights are in the future", () => {
const flights = [
{ departure: { scheduled: "2026-05-15T18:00:00" } },
{ departure: { scheduled: "2026-05-15T20:00:00" } },
];
expect(findNearestFlightIndex(flights as never, new Date(2026, 4, 15, 6, 0))).toBe(0);
});
it("handles arrival-mode flights (sorts by arrival time)", () => {
const flights = [
{ arrival: { scheduled: "2026-05-15T10:00:00" } },
{ arrival: { scheduled: "2026-05-15T13:00:00" } },
];
expect(findNearestFlightIndex(flights as never, new Date(2026, 4, 15, 12, 0), "arrival")).toBe(1);
});
});
@@ -0,0 +1,70 @@
/**
* Find the index of the flight whose scheduled time is closest to `now`.
* Per TZ §4.1.13 opening ¶1 — the nearest flight gets auto-expanded
* and the list scrolls to it on today's tab.
*/
import type { ISimpleFlight } from "./types.js";
type TimeMode = "departure" | "arrival";
/**
* Resolve the scheduled ISO timestamp for a flight in the given mode.
* Handles both Direct (`.leg`) and MultiLeg (`.legs[0]` / `.legs[last]`)
* shapes, falling back to the raw `departure.scheduled` / `arrival.scheduled`
* paths used in tests via `as never` casts.
*/
function resolveIso(flight: ISimpleFlight, mode: TimeMode): string | undefined {
if (mode === "arrival") {
// Real shape: MultiLeg last leg, or Direct leg
const leg =
flight.routeType === "MultiLeg"
? flight.legs[flight.legs.length - 1]
: flight.leg;
const arrTime = leg?.arrival?.times?.scheduledArrival?.local;
if (arrTime) return arrTime;
// Test-cast shape: { arrival: { scheduled: string } }
const cast = flight as unknown as { arrival?: { scheduled?: string } };
return cast.arrival?.scheduled;
}
// departure mode
const leg =
flight.routeType === "MultiLeg" ? flight.legs[0] : flight.leg;
const depTime = leg?.departure?.times?.scheduledDeparture?.local;
if (depTime) return depTime;
// Test-cast shape: { departure: { scheduled: string } }
const cast = flight as unknown as { departure?: { scheduled?: string } };
return cast.departure?.scheduled;
}
/**
* Returns the index of the flight whose scheduled time is closest to `now`.
*
* - Returns `0` when the list is empty.
* - When all flights are in the past, returns the last index.
* - When all flights are in the future, returns the first index (`0`).
*/
export function findNearestFlightIndex(
flights: ISimpleFlight[],
now: Date,
mode: TimeMode = "departure",
): number {
if (flights.length === 0) return 0;
let bestIndex = 0;
let bestDiff = Number.POSITIVE_INFINITY;
for (let i = 0; i < flights.length; i++) {
const f = flights[i];
if (!f) continue;
const iso = resolveIso(f, mode);
if (!iso) continue;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) continue;
const diff = Math.abs(ms - now.getTime());
if (diff < bestDiff) {
bestDiff = diff;
bestIndex = i;
}
}
return bestIndex;
}
+9
View File
@@ -60,13 +60,22 @@ export const FlightList: FC<FlightListProps> = ({
}) => {
const { t } = useTranslation();
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Track which flight ID has already been scrolled to — prevents re-scrolling
// on every live SignalR update while preserving the first-load behavior
// per TZ §4.1.13 ¶1.
const scrolledToIdRef = useRef<string | null>(null);
useEffect(() => {
if (!initialCurrentFlightId) return;
// Only scroll once per unique initialCurrentFlightId (i.e. per search /
// day change). Subsequent live-update re-renders leave the viewport alone
// so the user isn't yanked back after manually scrolling elsewhere.
if (scrolledToIdRef.current === initialCurrentFlightId) return;
const el = cardRefs.current.get(initialCurrentFlightId);
// jsdom doesn't implement scrollIntoView, so guard the call.
if (el && typeof el.scrollIntoView === "function") {
el.scrollIntoView({ block: "center", behavior: "smooth" });
scrolledToIdRef.current = initialCurrentFlightId;
}
}, [initialCurrentFlightId, flights]);