diff --git a/docs/superpowers/plans/2026-04-15-phase-3d-seo.md b/docs/superpowers/plans/2026-04-15-phase-3d-seo.md new file mode 100644 index 00000000..c5ac2282 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-phase-3d-seo.md @@ -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 diff --git a/src/features/schedule/json-ld.test.ts b/src/features/schedule/json-ld.test.ts new file mode 100644 index 00000000..351c4d33 --- /dev/null +++ b/src/features/schedule/json-ld.test.ts @@ -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"); + }); +}); diff --git a/src/features/schedule/seo.test.ts b/src/features/schedule/seo.test.ts new file mode 100644 index 00000000..d24b9dd4 --- /dev/null +++ b/src/features/schedule/seo.test.ts @@ -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 { + 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"); + }); +});