plan/react-rewrite #1
@@ -0,0 +1,9 @@
|
||||
# Phase 3D -- Schedule SEO + JSON-LD Tests
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3C (seo.ts, json-ld.ts already created)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/schedule/seo.test.ts` -- SEO builder tests
|
||||
2. `src/features/schedule/json-ld.test.ts` -- JSON-LD builder tests
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Flight, ItemList } from "schema-dts";
|
||||
import { buildScheduleFlightJsonLd, buildScheduleFlightListJsonLd } from "./json-ld.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
function makeDirectFlight(overrides: Partial<{
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
depCode: string;
|
||||
depAirport: string;
|
||||
arrCode: string;
|
||||
arrAirport: string;
|
||||
depTime: string;
|
||||
arrTime: string;
|
||||
}> = {}): ISimpleFlight {
|
||||
return {
|
||||
id: "test-schedule-1",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: overrides.carrier ?? "SU",
|
||||
flightNumber: overrides.flightNumber ?? "0012",
|
||||
suffix: "",
|
||||
date: "20220527",
|
||||
},
|
||||
flyingTime: "1h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "1h 30m",
|
||||
updated: "2022-05-27T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: "Airbus A320", code: "320" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: overrides.depAirport ?? "Sheremetyevo",
|
||||
airportCode: overrides.depCode ?? "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.depTime ?? "2022-05-27T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2022-05-27T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: overrides.arrAirport ?? "Pulkovo",
|
||||
airportCode: overrides.arrCode ?? "LED",
|
||||
city: "Saint Petersburg",
|
||||
cityCode: "LED",
|
||||
countryCode: "RU",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.arrTime ?? "2022-05-27T11:30:00",
|
||||
localTime: "11:30",
|
||||
tzOffset: 3,
|
||||
utc: "2022-05-27T08:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScheduleFlightJsonLd", () => {
|
||||
it("returns a Flight schema with correct @type", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildScheduleFlightJsonLd(flight);
|
||||
|
||||
expect(result["@type"]).toBe("Flight");
|
||||
});
|
||||
|
||||
it("sets flightNumber in IATA format", () => {
|
||||
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0012" });
|
||||
const result = buildScheduleFlightJsonLd(flight);
|
||||
|
||||
expect(result.flightNumber).toBe("SU0012");
|
||||
});
|
||||
|
||||
it("maps departure airport", () => {
|
||||
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
|
||||
const result = buildScheduleFlightJsonLd(flight);
|
||||
|
||||
expect(result.departureAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "SVO",
|
||||
name: "Sheremetyevo",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps arrival airport", () => {
|
||||
const flight = makeDirectFlight({ arrCode: "LED", arrAirport: "Pulkovo" });
|
||||
const result = buildScheduleFlightJsonLd(flight);
|
||||
|
||||
expect(result.arrivalAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "LED",
|
||||
name: "Pulkovo",
|
||||
});
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts Flight", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildScheduleFlightJsonLd(flight);
|
||||
const _typed: Flight = result;
|
||||
expect(_typed["@type"]).toBe("Flight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleFlightListJsonLd", () => {
|
||||
it("returns an ItemList schema", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildScheduleFlightListJsonLd(flights, "Schedule SVO to LED");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
});
|
||||
|
||||
it("sets the description", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildScheduleFlightListJsonLd(flights, "Schedule SVO to LED");
|
||||
|
||||
expect(result.description).toBe("Schedule SVO to LED");
|
||||
});
|
||||
|
||||
it("wraps each flight as a ListItem with position", () => {
|
||||
const flights = [
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0012" }),
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0013" }),
|
||||
];
|
||||
const result = buildScheduleFlightListJsonLd(flights, "Flights");
|
||||
const items = result.itemListElement as unknown as Array<{ position: number }>;
|
||||
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveProperty("position", 1);
|
||||
expect(items[1]).toHaveProperty("position", 2);
|
||||
});
|
||||
|
||||
it("handles empty flight list", () => {
|
||||
const result = buildScheduleFlightListJsonLd([], "No flights");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
expect(result.itemListElement).toEqual([]);
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts ItemList", () => {
|
||||
const result = buildScheduleFlightListJsonLd([], "test");
|
||||
const _typed: ItemList = result;
|
||||
expect(_typed["@type"]).toBe("ItemList");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildScheduleStartSeo,
|
||||
buildScheduleSearchSeo,
|
||||
buildScheduleDetailsSeo,
|
||||
} from "./seo.js";
|
||||
import type { ISimpleFlight, IScheduleFlightId } from "./types.js";
|
||||
import type { IFlightLeg } from "../online-board/types.js";
|
||||
|
||||
/** Stub t() that returns the key + interpolation vars for assertion. */
|
||||
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("buildScheduleStartSeo", () => {
|
||||
it("uses MAIN translation keys", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toBe("SEO.SCHEDULE.MAIN.TITLE");
|
||||
expect(result.description).toBe("SEO.SCHEDULE.MAIN.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to /{locale}/schedule", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe("https://www.aeroflot.ru/en/schedule");
|
||||
});
|
||||
|
||||
it("includes hreflang with 10 entries", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og tags", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.title).toBe(result.title);
|
||||
expect(result.og.type).toBe("website");
|
||||
expect(result.og.locale).toBe("ru");
|
||||
expect(result.og.siteName).toBe("Aeroflot");
|
||||
});
|
||||
|
||||
it("sets twitter card", () => {
|
||||
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.twitter).toBeDefined();
|
||||
expect(result.twitter!.card).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleSearchSeo", () => {
|
||||
const params = {
|
||||
type: "route" as const,
|
||||
outbound: {
|
||||
departure: "MOW",
|
||||
arrival: "KUF",
|
||||
dateFrom: "20220425",
|
||||
dateTo: "20220501",
|
||||
},
|
||||
};
|
||||
|
||||
it("uses SEARCH translation keys with interpolation", () => {
|
||||
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("SEO.SCHEDULE.SEARCH.TITLE");
|
||||
expect(result.title).toContain("departureCity=MOW");
|
||||
expect(result.title).toContain("arrivalCity=KUF");
|
||||
expect(result.title).toContain("dateFrom=25.04.2022");
|
||||
expect(result.title).toContain("dateTo=01.05.2022");
|
||||
});
|
||||
|
||||
it("uses city names when provided", () => {
|
||||
const result = buildScheduleSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ departure: "Moscow", arrival: "Samara" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("departureCity=Moscow");
|
||||
expect(result.title).toContain("arrivalCity=Samara");
|
||||
});
|
||||
|
||||
it("sets canonical to the route search URL", () => {
|
||||
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/schedule/route/MOW-KUF-20220425-20220501",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("handles round-trip params", () => {
|
||||
const rtParams = {
|
||||
type: "roundtrip" as const,
|
||||
outbound: {
|
||||
departure: "MOW",
|
||||
arrival: "KUF",
|
||||
dateFrom: "20220425",
|
||||
dateTo: "20220501",
|
||||
},
|
||||
inbound: {
|
||||
departure: "KUF",
|
||||
arrival: "MOW",
|
||||
dateFrom: "20220502",
|
||||
dateTo: "20220508",
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildScheduleSearchSeo(stubT, rtParams, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toContain(
|
||||
"schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleDetailsSeo", () => {
|
||||
const flightIds: IScheduleFlightId[] = [
|
||||
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
|
||||
];
|
||||
|
||||
const flight: ISimpleFlight = {
|
||||
id: "SU0012-20220527",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0012",
|
||||
suffix: "",
|
||||
date: "20220527",
|
||||
},
|
||||
flyingTime: "1h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "1h 30m",
|
||||
updated: "2022-05-27T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: "Airbus A320", code: "320" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
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: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("uses DETAILS translation keys", () => {
|
||||
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||
|
||||
expect(result.title).toContain("SEO.SCHEDULE.DETAILS.TITLE");
|
||||
expect(result.title).toContain("flights=SU 0012");
|
||||
expect(result.title).toContain("date=27.05.2022");
|
||||
});
|
||||
|
||||
it("sets canonical to the details URL", () => {
|
||||
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/schedule/SU0012-20220527",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og.type to article for details", () => {
|
||||
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||
|
||||
expect(result.og.type).toBe("article");
|
||||
});
|
||||
|
||||
it("handles multiple flights in display", () => {
|
||||
const multiFlightIds: IScheduleFlightId[] = [
|
||||
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
|
||||
{ carrier: "SU", flightNumber: "0013", date: "20220527" },
|
||||
];
|
||||
|
||||
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, multiFlightIds);
|
||||
|
||||
expect(result.title).toContain("SU 0012, SU 0013");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user