diff --git a/src/shared/detailsRequestParam.test.ts b/src/shared/detailsRequestParam.test.ts new file mode 100644 index 00000000..d28bd9f1 --- /dev/null +++ b/src/shared/detailsRequestParam.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + buildDetailsRequestParam, + parseDetailsRequestParam, + type DetailsRequest, +} from "./detailsRequestParam.js"; + +describe("detailsRequestParam โ€” build", () => { + it("4.1.2-R-Request-flight: encodes flight-number parent", () => { + const r: DetailsRequest = { + area: "onlineboard", + kind: "flight", + flightNumber: "SU1234", + date: "20260515", + }; + expect(buildDetailsRequestParam(r)).toBe("onlineboard-flight-SU1234-20260515"); + }); + + it("4.1.2-R-Request-departure: encodes departure parent without time", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + }), + ).toBe("onlineboard-departure-SVO-20260515"); + }); + + it("4.1.2-R-Request-departure-time: encodes departure parent with time range", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }), + ).toBe("onlineboard-departure-SVO-20260515-06002200"); + }); + + it("4.1.2-R-Request-arrival: encodes arrival parent", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "arrival", + station: "SVO", + date: "20260515", + }), + ).toBe("onlineboard-arrival-SVO-20260515"); + }); + + it("4.1.2-R-Request-route: encodes route parent", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + }), + ).toBe("onlineboard-route-MOW-LED-20260515"); + }); + + it("4.1.2-R-Request-route-time: encodes route parent with time range", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }), + ).toBe("onlineboard-route-MOW-LED-20260515-06002200"); + }); +}); + +describe("detailsRequestParam โ€” parse", () => { + it("returns null for empty or non-matching input", () => { + expect(parseDetailsRequestParam("")).toBeNull(); + expect(parseDetailsRequestParam("garbage")).toBeNull(); + expect(parseDetailsRequestParam("schedule-flight-SU1234-20260515")).toBeNull(); + }); + + it("parses flight-number form", () => { + expect(parseDetailsRequestParam("onlineboard-flight-SU1234-20260515")).toEqual({ + area: "onlineboard", + kind: "flight", + flightNumber: "SU1234", + date: "20260515", + }); + }); + + it("parses departure without time", () => { + expect(parseDetailsRequestParam("onlineboard-departure-SVO-20260515")).toEqual({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + }); + }); + + it("parses departure with time range", () => { + expect(parseDetailsRequestParam("onlineboard-departure-SVO-20260515-06002200")).toEqual({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }); + }); + + it("parses arrival", () => { + expect(parseDetailsRequestParam("onlineboard-arrival-LED-20260515")).toEqual({ + area: "onlineboard", + kind: "arrival", + station: "LED", + date: "20260515", + }); + }); + + it("parses route without time", () => { + expect(parseDetailsRequestParam("onlineboard-route-MOW-LED-20260515")).toEqual({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + }); + }); + + it("parses route with time range", () => { + expect(parseDetailsRequestParam("onlineboard-route-MOW-LED-20260515-06002200")).toEqual({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }); + }); +}); + +describe("detailsRequestParam โ€” roundtrip", () => { + it.each([ + { area: "onlineboard", kind: "flight", flightNumber: "SU1234", date: "20260515" }, + { area: "onlineboard", kind: "departure", station: "SVO", date: "20260515" }, + { area: "onlineboard", kind: "departure", station: "SVO", date: "20260515", timeFrom: "0600", timeTo: "2200" }, + { area: "onlineboard", kind: "arrival", station: "LED", date: "20260515" }, + { area: "onlineboard", kind: "route", departure: "MOW", arrival: "LED", date: "20260515" }, + { area: "onlineboard", kind: "route", departure: "MOW", arrival: "LED", date: "20260515", timeFrom: "0600", timeTo: "2200" }, + ])("round-trips %o", (input) => { + const encoded = buildDetailsRequestParam(input); + const decoded = parseDetailsRequestParam(encoded); + expect(decoded).toEqual(input); + }); +}); diff --git a/src/shared/detailsRequestParam.ts b/src/shared/detailsRequestParam.ts new file mode 100644 index 00000000..fc2254d2 --- /dev/null +++ b/src/shared/detailsRequestParam.ts @@ -0,0 +1,113 @@ +/** + * Codec for the `?request=...` query parameter on Online-Board flight-details URLs. + * Per TZ ยง4.1.2 Table 5 row 6. The param encodes the parent search context so + * the mini-list can rebuild the list on refresh / deep-link entry. + * + * Forms (canonical): + * onlineboard-flight-{flightNumber}-{yyyyMMdd} + * onlineboard-departure-{iata}-{yyyyMMdd}[-{HHmmHHmm}] + * onlineboard-arrival-{iata}-{yyyyMMdd}[-{HHmmHHmm}] + * onlineboard-route-{depIata}-{arrIata}-{yyyyMMdd}[-{HHmmHHmm}] + */ + +export type DetailsRequest = + | { area: "onlineboard"; kind: "flight"; flightNumber: string; date: string } + | { + area: "onlineboard"; + kind: "departure" | "arrival"; + station: string; + date: string; + timeFrom?: string; + timeTo?: string; + } + | { + area: "onlineboard"; + kind: "route"; + departure: string; + arrival: string; + date: string; + timeFrom?: string; + timeTo?: string; + }; + +const AREA = "onlineboard"; + +function isDate(s: string | undefined): s is string { + return typeof s === "string" && /^\d{8}$/.test(s); +} + +function isTimeRange(s: string | undefined): s is string { + return typeof s === "string" && /^\d{8}$/.test(s); +} + +export function buildDetailsRequestParam(r: DetailsRequest): string { + switch (r.kind) { + case "flight": + return `${r.area}-flight-${r.flightNumber}-${r.date}`; + case "departure": + case "arrival": { + const base = `${r.area}-${r.kind}-${r.station}-${r.date}`; + return r.timeFrom && r.timeTo ? `${base}-${r.timeFrom}${r.timeTo}` : base; + } + case "route": { + const base = `${r.area}-route-${r.departure}-${r.arrival}-${r.date}`; + return r.timeFrom && r.timeTo ? `${base}-${r.timeFrom}${r.timeTo}` : base; + } + } +} + +export function parseDetailsRequestParam(raw: string): DetailsRequest | null { + if (!raw) return null; + const parts = raw.split("-"); + if (parts.length < 4) return null; + if (parts[0] !== AREA) return null; + + const kind = parts[1]; + + if (kind === "flight") { + const [, , flightNumber, date] = parts; + if (!flightNumber || !isDate(date) || parts.length !== 4) return null; + return { area: "onlineboard", kind: "flight", flightNumber, date }; + } + + if (kind === "departure" || kind === "arrival") { + const [, , station, date, maybeTime] = parts; + if (!station || !isDate(date)) return null; + if (parts.length === 4) { + return { area: "onlineboard", kind, station, date }; + } + if (parts.length === 5 && isTimeRange(maybeTime)) { + return { + area: "onlineboard", + kind, + station, + date, + timeFrom: maybeTime.slice(0, 4), + timeTo: maybeTime.slice(4, 8), + }; + } + return null; + } + + if (kind === "route") { + const [, , departure, arrival, date, maybeTime] = parts; + if (!departure || !arrival || !isDate(date)) return null; + if (parts.length === 5) { + return { area: "onlineboard", kind: "route", departure, arrival, date }; + } + if (parts.length === 6 && isTimeRange(maybeTime)) { + return { + area: "onlineboard", + kind: "route", + departure, + arrival, + date, + timeFrom: maybeTime.slice(0, 4), + timeTo: maybeTime.slice(4, 8), + }; + } + return null; + } + + return null; +}