plan/react-rewrite #1
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Flight, ItemList } from "schema-dts";
|
||||
import { buildFlightJsonLd, buildFlightListJsonLd } from "./json-ld.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
function makeDirectFlight(overrides: Partial<{
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
date: string;
|
||||
depCode: string;
|
||||
depCity: string;
|
||||
depAirport: string;
|
||||
arrCode: string;
|
||||
arrCity: string;
|
||||
arrAirport: string;
|
||||
depTime: string;
|
||||
arrTime: string;
|
||||
aircraft: string;
|
||||
}> = {}): ISimpleFlight {
|
||||
return {
|
||||
id: "test-flight-1",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: overrides.carrier ?? "SU",
|
||||
flightNumber: overrides.flightNumber ?? "0100",
|
||||
suffix: "",
|
||||
date: overrides.date ?? "20250115",
|
||||
},
|
||||
flyingTime: "10h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10h 30m",
|
||||
updated: "2025-01-15T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: overrides.aircraft ?? "Boeing 777-300ER", code: "773" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: overrides.depAirport ?? "Sheremetyevo International Airport",
|
||||
airportCode: overrides.depCode ?? "SVO",
|
||||
city: overrides.depCity ?? "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.depTime ?? "2025-01-15T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: overrides.arrAirport ?? "John F. Kennedy International Airport",
|
||||
airportCode: overrides.arrCode ?? "JFK",
|
||||
city: overrides.arrCity ?? "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.arrTime ?? "2025-01-15T14:30:00",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "2025-01-15T19:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFlightJsonLd", () => {
|
||||
it("returns a Flight schema with correct @type", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result["@type"]).toBe("Flight");
|
||||
});
|
||||
|
||||
it("sets flightNumber in IATA format", () => {
|
||||
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0100" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.flightNumber).toBe("SU0100");
|
||||
});
|
||||
|
||||
it("maps departure airport", () => {
|
||||
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.departureAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "SVO",
|
||||
name: "Sheremetyevo",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps arrival airport", () => {
|
||||
const flight = makeDirectFlight({ arrCode: "JFK", arrAirport: "JFK Airport" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.arrivalAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "JFK",
|
||||
name: "JFK Airport",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps departure and arrival times", () => {
|
||||
const flight = makeDirectFlight({
|
||||
depTime: "2025-01-15T10:00:00",
|
||||
arrTime: "2025-01-15T14:30:00",
|
||||
});
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.departureTime).toBe("2025-01-15T10:00:00");
|
||||
expect(result.arrivalTime).toBe("2025-01-15T14:30:00");
|
||||
});
|
||||
|
||||
it("maps estimated flight duration", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.estimatedFlightDuration).toBe("10h 30m");
|
||||
});
|
||||
|
||||
it("maps provider to Aeroflot", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
"@type": "Airline",
|
||||
name: "Aeroflot",
|
||||
iataCode: "SU",
|
||||
});
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts Flight", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
// TypeScript compile-time check: assigning to Flight should not error
|
||||
const _typed: Flight = result;
|
||||
expect(_typed["@type"]).toBe("Flight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightListJsonLd", () => {
|
||||
it("returns an ItemList schema with correct @type", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildFlightListJsonLd(flights, "Flights from Moscow");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
});
|
||||
|
||||
it("sets the description on the ItemList", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildFlightListJsonLd(flights, "Departures from SVO");
|
||||
|
||||
expect(result.description).toBe("Departures from SVO");
|
||||
});
|
||||
|
||||
it("wraps each flight as a ListItem with position", () => {
|
||||
const flights = [
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0100" }),
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0200" }),
|
||||
];
|
||||
const result = buildFlightListJsonLd(flights, "Flights");
|
||||
const items = result.itemListElement;
|
||||
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
const itemArray = items as Array<{ "@type": string; position: number; item: Flight }>;
|
||||
expect(itemArray).toHaveLength(2);
|
||||
expect(itemArray[0]!["@type"]).toBe("ListItem");
|
||||
expect(itemArray[0]!.position).toBe(1);
|
||||
expect(itemArray[1]!.position).toBe(2);
|
||||
});
|
||||
|
||||
it("embeds Flight objects inside ListItem.item", () => {
|
||||
const flights = [makeDirectFlight({ carrier: "SU", flightNumber: "0100" })];
|
||||
const result = buildFlightListJsonLd(flights, "Flights");
|
||||
const items = result.itemListElement as unknown as Array<{ item: Flight }>;
|
||||
|
||||
expect(items[0]!.item["@type"]).toBe("Flight");
|
||||
expect(items[0]!.item.flightNumber).toBe("SU0100");
|
||||
});
|
||||
|
||||
it("handles empty flight list", () => {
|
||||
const result = buildFlightListJsonLd([], "No flights");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
expect(result.itemListElement).toEqual([]);
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts ItemList", () => {
|
||||
const result = buildFlightListJsonLd([], "test");
|
||||
const _typed: ItemList = result;
|
||||
expect(_typed["@type"]).toBe("ItemList");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* JSON-LD schema builders for Online Board pages.
|
||||
*
|
||||
* Produces schema-dts typed objects ready for <JsonLdRenderer>.
|
||||
* Uses schema.org Flight and ItemList types.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Flight, ItemList, ListItem } from "schema-dts";
|
||||
import type { ISimpleFlight, IFlightLeg } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the first leg from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getFirstLeg(flight: ISimpleFlight): IFlightLeg | undefined {
|
||||
if (flight.routeType === "Direct") {
|
||||
return flight.leg;
|
||||
}
|
||||
return flight.legs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last leg from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getLastLeg(flight: ISimpleFlight): IFlightLeg | undefined {
|
||||
if (flight.routeType === "Direct") {
|
||||
return flight.leg;
|
||||
}
|
||||
const { legs } = flight;
|
||||
return legs[legs.length - 1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public JSON-LD builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a schema.org Flight JSON-LD object from a flight record.
|
||||
*
|
||||
* Maps the first leg's departure and last leg's arrival for multi-leg flights.
|
||||
*/
|
||||
export function buildFlightJsonLd(flight: ISimpleFlight): Flight {
|
||||
const firstLeg = getFirstLeg(flight);
|
||||
const lastLeg = getLastLeg(flight);
|
||||
|
||||
const { carrier, flightNumber } = flight.flightId;
|
||||
|
||||
const result: Flight = {
|
||||
"@type": "Flight",
|
||||
flightNumber: `${carrier}${flightNumber}`,
|
||||
provider: {
|
||||
"@type": "Airline",
|
||||
name: "Aeroflot",
|
||||
iataCode: carrier,
|
||||
},
|
||||
estimatedFlightDuration: flight.flyingTime,
|
||||
};
|
||||
|
||||
if (firstLeg) {
|
||||
result.departureAirport = {
|
||||
"@type": "Airport",
|
||||
iataCode: firstLeg.departure.scheduled.airportCode,
|
||||
name: firstLeg.departure.scheduled.airport,
|
||||
};
|
||||
result.departureTime = firstLeg.departure.times.scheduledDeparture.local;
|
||||
|
||||
if (firstLeg.departure.terminal) {
|
||||
result.departureTerminal = firstLeg.departure.terminal;
|
||||
}
|
||||
if (firstLeg.departure.gate) {
|
||||
result.departureGate = firstLeg.departure.gate;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastLeg) {
|
||||
result.arrivalAirport = {
|
||||
"@type": "Airport",
|
||||
iataCode: lastLeg.arrival.scheduled.airportCode,
|
||||
name: lastLeg.arrival.scheduled.airport,
|
||||
};
|
||||
result.arrivalTime = lastLeg.arrival.times.scheduledArrival.local;
|
||||
|
||||
if (lastLeg.arrival.terminal) {
|
||||
result.arrivalTerminal = lastLeg.arrival.terminal;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstLeg?.equipment.name) {
|
||||
result.aircraft = firstLeg.equipment.name;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schema.org ItemList of Flight JSON-LD objects from search results.
|
||||
*
|
||||
* Each flight becomes a ListItem with a position (1-indexed).
|
||||
*/
|
||||
export function buildFlightListJsonLd(
|
||||
flights: ISimpleFlight[],
|
||||
searchDescription: string,
|
||||
): ItemList {
|
||||
const items: ListItem[] = flights.map((flight, index) => ({
|
||||
"@type": "ListItem" as const,
|
||||
position: index + 1,
|
||||
item: buildFlightJsonLd(flight),
|
||||
}));
|
||||
|
||||
return {
|
||||
"@type": "ItemList",
|
||||
description: searchDescription,
|
||||
itemListElement: items,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user