plan/react-rewrite #1
@@ -0,0 +1,12 @@
|
||||
# Phase 3E -- Schedule Parity + Integration Tests
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3A-3D (all schedule code)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. URL parity test registered in harness
|
||||
2. URL fixture corpus for schedule
|
||||
3. Integration tests (~10)
|
||||
4. Updated barrel (`index.ts`)
|
||||
5. Updated MF expose (`Schedule.tsx`)
|
||||
@@ -1,2 +1,55 @@
|
||||
// Public barrel for the schedule feature. See frozen-barrels.md.
|
||||
export {};
|
||||
|
||||
// 3A — URL serializer/parser
|
||||
export type { ScheduleParams } from "./url.js";
|
||||
export {
|
||||
parseScheduleUrl,
|
||||
buildScheduleUrl,
|
||||
parseScheduleRouteParams,
|
||||
buildScheduleRouteParams,
|
||||
} from "./url.js";
|
||||
|
||||
// 3A — Types
|
||||
export type {
|
||||
IScheduleRouteDirectionParams,
|
||||
IScheduleFlightId,
|
||||
IScheduleSearchRequest,
|
||||
IScheduleResponse,
|
||||
IScheduleDetailsResponse,
|
||||
IScheduleCalendarParams,
|
||||
IScheduleDaysResponse,
|
||||
IScheduleRouteParams,
|
||||
IScheduleDetailsParams,
|
||||
} from "./types.js";
|
||||
|
||||
// Re-exported shared types
|
||||
export type { IFlight, ISimpleFlight, IBoardResponse, IFlightLeg } from "./types.js";
|
||||
|
||||
// 3B — API functions
|
||||
export { searchSchedule, getScheduleDetails, getScheduleCalendarDays } from "./api.js";
|
||||
|
||||
// 3B — React hooks
|
||||
export { useScheduleSearch } from "./hooks/useScheduleSearch.js";
|
||||
export type { UseScheduleSearchResult } from "./hooks/useScheduleSearch.js";
|
||||
export { useScheduleDetails } from "./hooks/useScheduleDetails.js";
|
||||
export type { UseScheduleDetailsResult, UseScheduleDetailsParams } from "./hooks/useScheduleDetails.js";
|
||||
export { useScheduleCalendar } from "./hooks/useScheduleCalendar.js";
|
||||
export type { UseScheduleCalendarResult } from "./hooks/useScheduleCalendar.js";
|
||||
|
||||
// 3D — SEO builder functions
|
||||
export {
|
||||
buildScheduleStartSeo,
|
||||
buildScheduleSearchSeo,
|
||||
buildScheduleDetailsSeo,
|
||||
} from "./seo.js";
|
||||
export type { TFunction, CityNames } from "./seo.js";
|
||||
|
||||
// 3D — JSON-LD builder functions
|
||||
export { buildScheduleFlightJsonLd, buildScheduleFlightListJsonLd } from "./json-ld.js";
|
||||
|
||||
// 3C — Feature-specific page components
|
||||
export { ScheduleStartPage } from "./components/ScheduleStartPage.js";
|
||||
export { ScheduleSearchPage } from "./components/ScheduleSearchPage.js";
|
||||
export type { ScheduleSearchPageProps } from "./components/ScheduleSearchPage.js";
|
||||
export { ScheduleDetailsPage } from "./components/ScheduleDetailsPage.js";
|
||||
export type { ScheduleDetailsPageProps } from "./components/ScheduleDetailsPage.js";
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { HostContract } from "@/host-contract";
|
||||
|
||||
/**
|
||||
* MF expose wrapper for the Schedule feature.
|
||||
* Phase 3 (schedule port) replaces the body with the real root.
|
||||
*
|
||||
* Lazy-loads the ScheduleStartPage via React.lazy + Suspense.
|
||||
* Full MF integration with host routing is deferred to a later phase;
|
||||
* for now this renders the start page for embedded usage.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { HostContract } from "@/host-contract";
|
||||
|
||||
const ScheduleStartPage = lazy(() =>
|
||||
import("@/features/schedule/components/ScheduleStartPage.js").then(
|
||||
(m) => ({ default: m.ScheduleStartPage }),
|
||||
),
|
||||
);
|
||||
|
||||
export interface ScheduleRemoteProps {
|
||||
hostContract: HostContract;
|
||||
}
|
||||
@@ -11,7 +22,9 @@ export interface ScheduleRemoteProps {
|
||||
export default function ScheduleRemote(_props: ScheduleRemoteProps): JSX.Element {
|
||||
return (
|
||||
<div data-mf-expose="Schedule">
|
||||
<p>Schedule remote — stub. Populated in Phase 3.</p>
|
||||
<Suspense fallback={<div aria-busy="true">Loading Schedule...</div>}>
|
||||
<ScheduleStartPage />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
[
|
||||
{
|
||||
"url": "schedule",
|
||||
"expected": { "type": "start" }
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-06002200",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-C1",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"connections": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-C0",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"connections": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-06002200-C0",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200",
|
||||
"connections": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508",
|
||||
"expected": {
|
||||
"type": "roundtrip",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501"
|
||||
},
|
||||
"inbound": {
|
||||
"departure": "KUF",
|
||||
"arrival": "MOW",
|
||||
"dateFrom": "20220502",
|
||||
"dateTo": "20220508"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-06002200/KUF-MOW-20220502-20220508-06002200",
|
||||
"expected": {
|
||||
"type": "roundtrip",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200"
|
||||
},
|
||||
"inbound": {
|
||||
"departure": "KUF",
|
||||
"arrival": "MOW",
|
||||
"dateFrom": "20220502",
|
||||
"dateTo": "20220508",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-C0/KUF-MOW-20220502-20220508-C0",
|
||||
"expected": {
|
||||
"type": "roundtrip",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"connections": 0
|
||||
},
|
||||
"inbound": {
|
||||
"departure": "KUF",
|
||||
"arrival": "MOW",
|
||||
"dateFrom": "20220502",
|
||||
"dateTo": "20220508",
|
||||
"connections": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/MOW-KUF-20220425-20220501-06002200-C0/KUF-MOW-20220502-20220508-06002200-C0",
|
||||
"expected": {
|
||||
"type": "roundtrip",
|
||||
"outbound": {
|
||||
"departure": "MOW",
|
||||
"arrival": "KUF",
|
||||
"dateFrom": "20220425",
|
||||
"dateTo": "20220501",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200",
|
||||
"connections": 0
|
||||
},
|
||||
"inbound": {
|
||||
"departure": "KUF",
|
||||
"arrival": "MOW",
|
||||
"dateFrom": "20220502",
|
||||
"dateTo": "20220508",
|
||||
"timeFrom": "0600",
|
||||
"timeTo": "2200",
|
||||
"connections": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/SU0012-20220527",
|
||||
"expected": {
|
||||
"type": "details",
|
||||
"flights": [
|
||||
{ "carrier": "SU", "flightNumber": "0012", "date": "20220527" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/SU0012-20220501/SU0013-20220507",
|
||||
"expected": {
|
||||
"type": "details",
|
||||
"flights": [
|
||||
{ "carrier": "SU", "flightNumber": "0012", "date": "20220501" },
|
||||
{ "carrier": "SU", "flightNumber": "0013", "date": "20220507" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/SU1765-20220307",
|
||||
"expected": {
|
||||
"type": "details",
|
||||
"flights": [
|
||||
{ "carrier": "SU", "flightNumber": "1765", "date": "20220307" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/SU1210-20220307/SU1765-20220307",
|
||||
"expected": {
|
||||
"type": "details",
|
||||
"flights": [
|
||||
{ "carrier": "SU", "flightNumber": "1210", "date": "20220307" },
|
||||
{ "carrier": "SU", "flightNumber": "1765", "date": "20220307" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/SVO-LED-20250115-20250122",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "SVO",
|
||||
"arrival": "LED",
|
||||
"dateFrom": "20250115",
|
||||
"dateTo": "20250122"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/SVO-LED-20250115-20250122-08001800",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "SVO",
|
||||
"arrival": "LED",
|
||||
"dateFrom": "20250115",
|
||||
"dateTo": "20250122",
|
||||
"timeFrom": "0800",
|
||||
"timeTo": "1800"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/route/SVO-LED-20250115-20250122-C2",
|
||||
"expected": {
|
||||
"type": "route",
|
||||
"outbound": {
|
||||
"departure": "SVO",
|
||||
"arrival": "LED",
|
||||
"dateFrom": "20250115",
|
||||
"dateTo": "20250122",
|
||||
"connections": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "schedule/SU0100D-20250115",
|
||||
"expected": {
|
||||
"type": "details",
|
||||
"flights": [
|
||||
{ "carrier": "SU", "flightNumber": "0100", "suffix": "D", "date": "20250115" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared test fixtures for Schedule integration tests.
|
||||
*
|
||||
* Provides realistic mock data matching the API response shapes
|
||||
* for schedule endpoints.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ISimpleFlight,
|
||||
IDirectFlight,
|
||||
IFlightLeg,
|
||||
IBoardResponse,
|
||||
} from "@/features/online-board/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight leg builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeLeg(overrides: Partial<IFlightLeg> & { index: number }): IFlightLeg {
|
||||
return {
|
||||
index: overrides.index,
|
||||
status: overrides.status ?? "Scheduled",
|
||||
flyingTime: overrides.flyingTime ?? "01:30",
|
||||
dayChange: overrides.dayChange ?? 0,
|
||||
updated: overrides.updated ?? "2022-05-27T10:00:00Z",
|
||||
equipment: overrides.equipment ?? { name: "Airbus A320", code: "320" },
|
||||
flags: overrides.flags ?? {
|
||||
checkinAvailable: false,
|
||||
returnToAirport: false,
|
||||
routeChanged: false,
|
||||
},
|
||||
operatingBy: overrides.operatingBy ?? { carrier: "SU", flightNumber: "0012" },
|
||||
departure: overrides.departure ?? {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2022-05-27T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2022-05-27T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: overrides.arrival ?? {
|
||||
scheduled: {
|
||||
airport: "Pulkovo",
|
||||
airportCode: "LED",
|
||||
city: "Saint Petersburg",
|
||||
cityCode: "LED",
|
||||
countryCode: "RU",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2022-05-27T11:30:00",
|
||||
localTime: "11:30",
|
||||
tzOffset: 3,
|
||||
utc: "2022-05-27T08:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SCHEDULE_FLIGHT_1: IDirectFlight = {
|
||||
id: "SU0012-20220527",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0012",
|
||||
suffix: "",
|
||||
date: "20220527",
|
||||
},
|
||||
routeType: "Direct",
|
||||
status: "Scheduled",
|
||||
flyingTime: "1h 30m",
|
||||
operatingBy: { carrier: "SU", flightNumber: "0012" },
|
||||
leg: makeLeg({ index: 0 }),
|
||||
};
|
||||
|
||||
export const SCHEDULE_FLIGHT_2: IDirectFlight = {
|
||||
id: "SU0013-20220527",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0013",
|
||||
suffix: "",
|
||||
date: "20220527",
|
||||
},
|
||||
routeType: "Direct",
|
||||
status: "Scheduled",
|
||||
flyingTime: "1h 30m",
|
||||
operatingBy: { carrier: "SU", flightNumber: "0013" },
|
||||
leg: makeLeg({ index: 0 }),
|
||||
};
|
||||
|
||||
export const ALL_SCHEDULE_FLIGHTS: ISimpleFlight[] = [
|
||||
SCHEDULE_FLIGHT_1,
|
||||
SCHEDULE_FLIGHT_2,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Schedule search response -- IFlight[] */
|
||||
export const SCHEDULE_SEARCH_RESPONSE: ISimpleFlight[] = ALL_SCHEDULE_FLIGHTS;
|
||||
|
||||
/** Schedule details response -- IBoardResponse */
|
||||
export const SCHEDULE_DETAILS_RESPONSE: IBoardResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: ALL_SCHEDULE_FLIGHTS,
|
||||
daysOfFlight: ["2022-05-27"],
|
||||
},
|
||||
};
|
||||
|
||||
export const EMPTY_DETAILS_RESPONSE: IBoardResponse = {
|
||||
data: {
|
||||
partners: [],
|
||||
routes: [],
|
||||
daysOfFlight: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const CALENDAR_DAYS = ["2022-05-25", "2022-05-26", "2022-05-27", "2022-05-28"];
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Integration tests for Schedule SEO + JSON-LD.
|
||||
*
|
||||
* Verifies SEO builders produce complete, well-formed output and
|
||||
* JSON-LD schemas conform to schema.org types.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildScheduleStartSeo,
|
||||
buildScheduleSearchSeo,
|
||||
buildScheduleDetailsSeo,
|
||||
} from "@/features/schedule/seo.js";
|
||||
import {
|
||||
buildScheduleFlightJsonLd,
|
||||
buildScheduleFlightListJsonLd,
|
||||
} from "@/features/schedule/json-ld.js";
|
||||
import { SCHEDULE_FLIGHT_1, SCHEDULE_FLIGHT_2 } from "./fixtures.js";
|
||||
import type { IScheduleFlightId } from "@/features/schedule/types.js";
|
||||
|
||||
function stubT(key: string, opts?: Record<string, unknown>): string {
|
||||
if (opts && Object.keys(opts).length > 0) {
|
||||
const vars = Object.entries(opts)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",");
|
||||
return `${key}|${vars}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
const CANONICAL = "https://www.aeroflot.ru";
|
||||
|
||||
describe("Schedule SEO integration", () => {
|
||||
it("start page SEO has all required fields", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toBeTruthy();
|
||||
expect(result.description).toBeTruthy();
|
||||
expect(result.canonical).toBeTruthy();
|
||||
expect(result.og).toBeDefined();
|
||||
expect(result.hreflang).toBeDefined();
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("search page SEO canonical matches route format", () => {
|
||||
const params = {
|
||||
type: "route" as const,
|
||||
outbound: {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250122",
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/schedule/route/SVO-LED-20250115-20250122",
|
||||
);
|
||||
});
|
||||
|
||||
it("details page SEO uses article og type", () => {
|
||||
const flightIds: IScheduleFlightId[] = [
|
||||
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
|
||||
];
|
||||
|
||||
const result = buildScheduleDetailsSeo(
|
||||
stubT,
|
||||
[SCHEDULE_FLIGHT_1],
|
||||
"ru",
|
||||
CANONICAL,
|
||||
flightIds,
|
||||
);
|
||||
|
||||
expect(result.og.type).toBe("article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schedule JSON-LD integration", () => {
|
||||
it("single flight produces valid Flight schema", () => {
|
||||
const jsonLd = buildScheduleFlightJsonLd(SCHEDULE_FLIGHT_1);
|
||||
|
||||
expect(jsonLd["@type"]).toBe("Flight");
|
||||
expect(jsonLd.flightNumber).toBe("SU0012");
|
||||
expect(jsonLd.departureAirport).toBeDefined();
|
||||
expect(jsonLd.arrivalAirport).toBeDefined();
|
||||
});
|
||||
|
||||
it("flight list produces valid ItemList schema", () => {
|
||||
const jsonLd = buildScheduleFlightListJsonLd(
|
||||
[SCHEDULE_FLIGHT_1, SCHEDULE_FLIGHT_2],
|
||||
"Schedule SVO to LED",
|
||||
);
|
||||
|
||||
expect(jsonLd["@type"]).toBe("ItemList");
|
||||
expect(jsonLd.description).toBe("Schedule SVO to LED");
|
||||
|
||||
const items = jsonLd.itemListElement as unknown as Array<{ position: number }>;
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveProperty("position", 1);
|
||||
expect(items[1]).toHaveProperty("position", 2);
|
||||
});
|
||||
|
||||
it("empty flight list produces valid ItemList", () => {
|
||||
const jsonLd = buildScheduleFlightListJsonLd([], "No flights");
|
||||
|
||||
expect(jsonLd["@type"]).toBe("ItemList");
|
||||
expect(jsonLd.itemListElement).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Integration tests for Schedule URL round-tripping.
|
||||
*
|
||||
* Verifies: build URL from params -> parse URL -> params match original.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildScheduleUrl,
|
||||
parseScheduleUrl,
|
||||
type ScheduleParams,
|
||||
} from "@/features/schedule/url.js";
|
||||
|
||||
describe("Schedule URL round-trip integration", () => {
|
||||
const roundTrip = (params: ScheduleParams) => {
|
||||
const url = buildScheduleUrl(params);
|
||||
const parsed = parseScheduleUrl(url);
|
||||
return { url, parsed };
|
||||
};
|
||||
|
||||
it("round-trips start page", () => {
|
||||
const { parsed } = roundTrip({ type: "start" });
|
||||
expect(parsed).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("round-trips one-way route search", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "route",
|
||||
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501" },
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/route/MOW-KUF-20220425-20220501");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("round-trips one-way with time range and connections", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "route",
|
||||
outbound: {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250122",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
connections: 1,
|
||||
},
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/route/SVO-LED-20250115-20250122-08001800-C1");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("round-trips round-trip search", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "roundtrip",
|
||||
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501" },
|
||||
inbound: { departure: "KUF", arrival: "MOW", dateFrom: "20220502", dateTo: "20220508" },
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("round-trips single-flight details", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "details",
|
||||
flights: [{ carrier: "SU", flightNumber: "0012", date: "20220527" }],
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/SU0012-20220527");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("round-trips multi-flight details", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "details",
|
||||
flights: [
|
||||
{ carrier: "SU", flightNumber: "0012", date: "20220501" },
|
||||
{ carrier: "SU", flightNumber: "0013", date: "20220507" },
|
||||
],
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/SU0012-20220501/SU0013-20220507");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("returns null for invalid URL", () => {
|
||||
expect(parseScheduleUrl("")).toBeNull();
|
||||
expect(parseScheduleUrl("not-a-schedule-url")).toBeNull();
|
||||
expect(parseScheduleUrl("schedule/route/")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles leading slash in parse", () => {
|
||||
const parsed = parseScheduleUrl("/schedule/route/SVO-LED-20250115-20250122");
|
||||
expect(parsed).toEqual({
|
||||
type: "route",
|
||||
outbound: {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250122",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("build then parse preserves all search variants", () => {
|
||||
const searchTypes: ScheduleParams[] = [
|
||||
{ type: "start" },
|
||||
{
|
||||
type: "route",
|
||||
outbound: { departure: "SVO", arrival: "LED", dateFrom: "20250115", dateTo: "20250122" },
|
||||
},
|
||||
{
|
||||
type: "roundtrip",
|
||||
outbound: { departure: "SVO", arrival: "LED", dateFrom: "20250115", dateTo: "20250122" },
|
||||
inbound: { departure: "LED", arrival: "SVO", dateFrom: "20250125", dateTo: "20250131" },
|
||||
},
|
||||
{
|
||||
type: "details",
|
||||
flights: [{ carrier: "SU", flightNumber: "0100", date: "20250115" }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const params of searchTypes) {
|
||||
const url = buildScheduleUrl(params);
|
||||
const parsed = parseScheduleUrl(url);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.type).toBe(params.type);
|
||||
}
|
||||
});
|
||||
|
||||
it("details with flight suffix round-trips correctly", () => {
|
||||
const params: ScheduleParams = {
|
||||
type: "details",
|
||||
flights: [{ carrier: "SU", flightNumber: "0100", suffix: "D", date: "20250115" }],
|
||||
};
|
||||
const { url, parsed } = roundTrip(params);
|
||||
expect(url).toBe("schedule/SU0100D-20250115");
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Schedule URL parity tests.
|
||||
*
|
||||
* Registers the Schedule URL serializer against the generic URL parity
|
||||
* harness. Tests fixture corpus + fast-check fuzz roundtrip.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import * as fc from "fast-check";
|
||||
import { parseScheduleUrl, buildScheduleUrl } from "@/features/schedule/url.js";
|
||||
import type { ScheduleParams } from "@/features/schedule/url.js";
|
||||
import { defineUrlParityTests } from "./harness.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fast-check arbitraries for ScheduleParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALPHA_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as [string, ...string[]];
|
||||
|
||||
const alphaCharArb: fc.Arbitrary<string> = fc.constantFrom(...ALPHA_CHARS);
|
||||
|
||||
/** 2-char uppercase carrier code */
|
||||
const carrierArb: fc.Arbitrary<string> = fc
|
||||
.tuple(alphaCharArb, alphaCharArb)
|
||||
.map(([a, b]) => `${a}${b}`);
|
||||
|
||||
/** 3-char uppercase IATA station code */
|
||||
const stationArb: fc.Arbitrary<string> = fc
|
||||
.tuple(alphaCharArb, alphaCharArb, alphaCharArb)
|
||||
.map(([a, b, c]) => `${a}${b}${c}`);
|
||||
|
||||
/** Valid yyyyMMdd date string */
|
||||
const dateArb = fc
|
||||
.record({
|
||||
year: fc.integer({ min: 2020, max: 2030 }),
|
||||
month: fc.integer({ min: 1, max: 12 }),
|
||||
day: fc.integer({ min: 1, max: 28 }),
|
||||
})
|
||||
.map(({ year, month, day }) => {
|
||||
const m = String(month).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}${m}${d}`;
|
||||
});
|
||||
|
||||
/** 4-digit zero-padded flight number */
|
||||
const flightNumberArb = fc
|
||||
.integer({ min: 1, max: 9999 })
|
||||
.map((n) => String(n).padStart(4, "0"));
|
||||
|
||||
/** Optional time range: 4-digit HHmm strings */
|
||||
const timeArb = fc
|
||||
.record({
|
||||
hour: fc.integer({ min: 0, max: 23 }),
|
||||
minute: fc.integer({ min: 0, max: 59 }),
|
||||
})
|
||||
.map(({ hour, minute }) =>
|
||||
`${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route direction params arbitrary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { IScheduleRouteDirectionParams } from "@/features/schedule/types.js";
|
||||
|
||||
const directionBaseArb: fc.Arbitrary<IScheduleRouteDirectionParams> = fc.record({
|
||||
departure: stationArb,
|
||||
arrival: stationArb,
|
||||
dateFrom: dateArb,
|
||||
dateTo: dateArb,
|
||||
});
|
||||
|
||||
const directionWithTimeArb: fc.Arbitrary<IScheduleRouteDirectionParams> = fc.record({
|
||||
departure: stationArb,
|
||||
arrival: stationArb,
|
||||
dateFrom: dateArb,
|
||||
dateTo: dateArb,
|
||||
timeFrom: timeArb,
|
||||
timeTo: timeArb,
|
||||
});
|
||||
|
||||
const directionWithConnArb: fc.Arbitrary<IScheduleRouteDirectionParams> = fc.record({
|
||||
departure: stationArb,
|
||||
arrival: stationArb,
|
||||
dateFrom: dateArb,
|
||||
dateTo: dateArb,
|
||||
connections: fc.integer({ min: 0, max: 5 }),
|
||||
});
|
||||
|
||||
const directionWithAllArb: fc.Arbitrary<IScheduleRouteDirectionParams> = fc.record({
|
||||
departure: stationArb,
|
||||
arrival: stationArb,
|
||||
dateFrom: dateArb,
|
||||
dateTo: dateArb,
|
||||
timeFrom: timeArb,
|
||||
timeTo: timeArb,
|
||||
connections: fc.integer({ min: 0, max: 5 }),
|
||||
});
|
||||
|
||||
const directionArb: fc.Arbitrary<IScheduleRouteDirectionParams> = fc.oneof(
|
||||
directionBaseArb,
|
||||
directionWithTimeArb,
|
||||
directionWithConnArb,
|
||||
directionWithAllArb,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discriminated union arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const startArb: fc.Arbitrary<ScheduleParams> = fc.constant({ type: "start" as const });
|
||||
|
||||
const routeArb: fc.Arbitrary<ScheduleParams> = directionArb.map((d) => ({
|
||||
type: "route" as const,
|
||||
outbound: d,
|
||||
}));
|
||||
|
||||
const roundtripArb: fc.Arbitrary<ScheduleParams> = fc
|
||||
.tuple(directionArb, directionArb)
|
||||
.map(([outbound, inbound]) => ({
|
||||
type: "roundtrip" as const,
|
||||
outbound,
|
||||
inbound,
|
||||
}));
|
||||
|
||||
import type { IScheduleFlightId } from "@/features/schedule/types.js";
|
||||
|
||||
const flightIdArb: fc.Arbitrary<IScheduleFlightId> = fc.record({
|
||||
carrier: carrierArb,
|
||||
flightNumber: flightNumberArb,
|
||||
date: dateArb,
|
||||
});
|
||||
|
||||
const detailsArb: fc.Arbitrary<ScheduleParams> = fc
|
||||
.array(flightIdArb, { minLength: 1, maxLength: 4 })
|
||||
.map((flights) => ({
|
||||
type: "details" as const,
|
||||
flights,
|
||||
}));
|
||||
|
||||
/** Combined arbitrary covering all ScheduleParams discriminants */
|
||||
const scheduleParamsArb: fc.Arbitrary<ScheduleParams> = fc.oneof(
|
||||
{ weight: 1, arbitrary: startArb },
|
||||
{ weight: 3, arbitrary: routeArb },
|
||||
{ weight: 3, arbitrary: roundtripArb },
|
||||
{ weight: 3, arbitrary: detailsArb },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register against harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
defineUrlParityTests<ScheduleParams>({
|
||||
feature: "Schedule",
|
||||
fixturePath: "tests/fixtures/phase-3/url-corpus/schedule.json",
|
||||
parse: parseScheduleUrl,
|
||||
build: buildScheduleUrl,
|
||||
fuzzArbitrary: scheduleParamsArb,
|
||||
});
|
||||
Reference in New Issue
Block a user