Extend detailsRequestParam codec for area:schedule (one-way + round-trip + connections)
This commit is contained in:
@@ -160,3 +160,352 @@ describe("detailsRequestParam — roundtrip", () => {
|
||||
expect(decoded).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("detailsRequestParam — schedule area — build", () => {
|
||||
it("4.1.2 Table 5 row 11: encodes one-way schedule route (no time, no connections)", () => {
|
||||
const r: DetailsRequest = {
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
};
|
||||
expect(buildDetailsRequestParam(r)).toBe("schedule-route-NBC-KHV-20220307-20220313");
|
||||
});
|
||||
|
||||
it("encodes one-way with time range", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
}),
|
||||
).toBe("schedule-route-NBC-KHV-20220307-20220313-06002200");
|
||||
});
|
||||
|
||||
it("encodes one-way with connections C0 (direct-only)", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
connections: 0,
|
||||
}),
|
||||
).toBe("schedule-route-NBC-KHV-20220307-20220313-C0");
|
||||
});
|
||||
|
||||
it("encodes one-way with time + connections", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
}),
|
||||
).toBe("schedule-route-NBC-KHV-20220307-20220313-06002200-C0");
|
||||
});
|
||||
|
||||
it("encodes round-trip without time or connections", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
},
|
||||
}),
|
||||
).toBe("schedule-route-NBC-KHV-20220307-20220313-KHV-NBC-20220320-20220326");
|
||||
});
|
||||
|
||||
it("encodes round-trip with time + connections on both legs", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
connections: 1,
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"schedule-route-NBC-KHV-20220307-20220313-06002200-C0-KHV-NBC-20220320-20220326-08001800-C1",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes round-trip with time on outbound only", () => {
|
||||
expect(
|
||||
buildDetailsRequestParam({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20220401",
|
||||
dateTo: "20220407",
|
||||
timeFrom: "1000",
|
||||
timeTo: "2000",
|
||||
returnTrip: {
|
||||
departure: "LED",
|
||||
arrival: "SVO",
|
||||
dateFrom: "20220408",
|
||||
dateTo: "20220414",
|
||||
},
|
||||
}),
|
||||
).toBe("schedule-route-SVO-LED-20220401-20220407-10002000-LED-SVO-20220408-20220414");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detailsRequestParam — schedule area — parse", () => {
|
||||
it("returns null for schedule-flight (not a known schedule kind)", () => {
|
||||
expect(parseDetailsRequestParam("schedule-flight-SU1234-20260515")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses one-way without time or connections", () => {
|
||||
expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313")).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses one-way with time range", () => {
|
||||
expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-06002200")).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses one-way with connections (C0)", () => {
|
||||
expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-C0")).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
connections: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses one-way with Cyrillic С0 (Angular spec compatibility)", () => {
|
||||
// Cyrillic С (U+0421) should be accepted the same as Latin C
|
||||
expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-С0")).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
connections: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses one-way with time + connections", () => {
|
||||
expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-06002200-C0")).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses round-trip without time or connections", () => {
|
||||
expect(
|
||||
parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-KHV-NBC-20220320-20220326"),
|
||||
).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses round-trip with time + connections on both legs", () => {
|
||||
expect(
|
||||
parseDetailsRequestParam(
|
||||
"schedule-route-NBC-KHV-20220307-20220313-06002200-C0-KHV-NBC-20220320-20220326-08001800-C1",
|
||||
),
|
||||
).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
connections: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses round-trip with time on outbound only", () => {
|
||||
expect(
|
||||
parseDetailsRequestParam(
|
||||
"schedule-route-SVO-LED-20220401-20220407-10002000-LED-SVO-20220408-20220414",
|
||||
),
|
||||
).toEqual({
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20220401",
|
||||
dateTo: "20220407",
|
||||
timeFrom: "1000",
|
||||
timeTo: "2000",
|
||||
returnTrip: {
|
||||
departure: "LED",
|
||||
arrival: "SVO",
|
||||
dateFrom: "20220408",
|
||||
dateTo: "20220414",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detailsRequestParam — schedule area — roundtrip property", () => {
|
||||
it.each<DetailsRequest>([
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
},
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
},
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
connections: 0,
|
||||
},
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
},
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
},
|
||||
},
|
||||
{
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: "NBC",
|
||||
arrival: "KHV",
|
||||
dateFrom: "20220307",
|
||||
dateTo: "20220313",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
connections: 0,
|
||||
returnTrip: {
|
||||
departure: "KHV",
|
||||
arrival: "NBC",
|
||||
dateFrom: "20220320",
|
||||
dateTo: "20220326",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
connections: 1,
|
||||
},
|
||||
},
|
||||
])("round-trips %o", (input) => {
|
||||
const encoded = buildDetailsRequestParam(input);
|
||||
const decoded = parseDetailsRequestParam(encoded);
|
||||
expect(decoded).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
* Codec for the `?request=...` query parameter on flight-details URLs.
|
||||
*
|
||||
* Forms (canonical):
|
||||
* Online-Board forms per TZ §4.1.2 Table 5 row 6:
|
||||
* onlineboard-flight-{flightNumber}-{yyyyMMdd}
|
||||
* onlineboard-departure-{iata}-{yyyyMMdd}[-{HHmmHHmm}]
|
||||
* onlineboard-arrival-{iata}-{yyyyMMdd}[-{HHmmHHmm}]
|
||||
* onlineboard-route-{depIata}-{arrIata}-{yyyyMMdd}[-{HHmmHHmm}]
|
||||
*
|
||||
* Schedule forms per TZ §4.1.2 Table 5 row 11:
|
||||
* schedule-route-{dep}-{arr}-{dateFrom}-{dateTo}[-{HHmmHHmm}][-C{n}]
|
||||
* schedule-route-{dep}-{arr}-{dateFrom}-{dateTo}[-{HHmmHHmm}][-C{n}]-{arr}-{dep}-{returnDateFrom}-{returnDateTo}[-{HHmmHHmm}][-C{n}]
|
||||
*/
|
||||
|
||||
export type DetailsRequest =
|
||||
@@ -28,9 +30,30 @@ export type DetailsRequest =
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
}
|
||||
| {
|
||||
area: "schedule";
|
||||
kind: "route";
|
||||
departure: string;
|
||||
arrival: string;
|
||||
dateFrom: string; // yyyyMMdd (outbound dateFrom)
|
||||
dateTo: string; // yyyyMMdd (outbound dateTo)
|
||||
timeFrom?: string; // HHmm
|
||||
timeTo?: string; // HHmm
|
||||
connections?: number; // 0 = C0 (direct-only), 1 = C1 (one connection)
|
||||
returnTrip?: {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
connections?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const AREA = "onlineboard";
|
||||
const ONLINEBOARD_AREA = "onlineboard";
|
||||
const SCHEDULE_AREA = "schedule";
|
||||
|
||||
function isDate(s: string | undefined): s is string {
|
||||
return typeof s === "string" && /^\d{8}$/.test(s);
|
||||
@@ -40,7 +63,99 @@ function isTimeRange(s: string | undefined): s is string {
|
||||
return typeof s === "string" && /^\d{8}$/.test(s);
|
||||
}
|
||||
|
||||
function isConnectionToken(s: string | undefined): boolean {
|
||||
// Latin C or Cyrillic С (U+0421)
|
||||
return typeof s === "string" && (s.startsWith("C") || s.startsWith("С")) && s.length >= 2;
|
||||
}
|
||||
|
||||
function parseConnectionToken(s: string): number | undefined {
|
||||
const numStr = s.slice(1);
|
||||
const num = Number(numStr);
|
||||
return Number.isNaN(num) ? undefined : num;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule leg serialization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScheduleLeg {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
connections?: number;
|
||||
}
|
||||
|
||||
function buildScheduleLegTokens(leg: ScheduleLeg): string[] {
|
||||
const tokens: string[] = [leg.departure, leg.arrival, leg.dateFrom, leg.dateTo];
|
||||
if (leg.timeFrom && leg.timeTo) {
|
||||
tokens.push(`${leg.timeFrom}${leg.timeTo}`);
|
||||
}
|
||||
if (leg.connections !== undefined) {
|
||||
tokens.push(`C${leg.connections}`);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to consume one schedule leg from `parts` starting at `offset`.
|
||||
* A leg starts with: {iata}-{iata}-{yyyyMMdd}-{yyyyMMdd} then optional time/conn tokens.
|
||||
* Returns the parsed leg and the index of the next unused part, or null if invalid.
|
||||
*/
|
||||
function parseScheduleLeg(
|
||||
parts: string[],
|
||||
offset: number,
|
||||
): { leg: ScheduleLeg; nextOffset: number } | null {
|
||||
// Minimum: dep, arr, dateFrom, dateTo = 4 tokens
|
||||
if (offset + 3 >= parts.length) return null;
|
||||
|
||||
const departure = parts[offset];
|
||||
const arrival = parts[offset + 1];
|
||||
const dateFrom = parts[offset + 2];
|
||||
const dateTo = parts[offset + 3];
|
||||
|
||||
if (!departure || !arrival || !isDate(dateFrom) || !isDate(dateTo)) return null;
|
||||
|
||||
let nextOffset = offset + 4;
|
||||
const leg: ScheduleLeg = { departure, arrival, dateFrom, dateTo };
|
||||
|
||||
// Consume optional timeRange
|
||||
const maybeTime = parts[nextOffset];
|
||||
if (maybeTime && isTimeRange(maybeTime) && !isConnectionToken(maybeTime)) {
|
||||
leg.timeFrom = maybeTime.slice(0, 4);
|
||||
leg.timeTo = maybeTime.slice(4, 8);
|
||||
nextOffset++;
|
||||
}
|
||||
|
||||
// Consume optional connections token
|
||||
const maybeConn = parts[nextOffset];
|
||||
if (maybeConn && isConnectionToken(maybeConn)) {
|
||||
const conn = parseConnectionToken(maybeConn);
|
||||
if (conn !== undefined) {
|
||||
leg.connections = conn;
|
||||
}
|
||||
nextOffset++;
|
||||
}
|
||||
|
||||
return { leg, nextOffset };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildDetailsRequestParam(r: DetailsRequest): string {
|
||||
if (r.area === SCHEDULE_AREA) {
|
||||
const outboundTokens = buildScheduleLegTokens(r);
|
||||
const allTokens = [SCHEDULE_AREA, "route", ...outboundTokens];
|
||||
if (r.returnTrip) {
|
||||
allTokens.push(...buildScheduleLegTokens(r.returnTrip));
|
||||
}
|
||||
return allTokens.join("-");
|
||||
}
|
||||
|
||||
switch (r.kind) {
|
||||
case "flight":
|
||||
return `${r.area}-flight-${r.flightNumber}-${r.date}`;
|
||||
@@ -60,7 +175,75 @@ 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 area = parts[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule area
|
||||
// ---------------------------------------------------------------------------
|
||||
if (area === SCHEDULE_AREA) {
|
||||
const kind = parts[1];
|
||||
if (kind !== "route") return null;
|
||||
|
||||
// parts[2..] are leg tokens starting from index 2
|
||||
const outboundResult = parseScheduleLeg(parts, 2);
|
||||
if (!outboundResult) return null;
|
||||
|
||||
const { leg: outbound, nextOffset } = outboundResult;
|
||||
|
||||
// Detect whether a second leg follows. A second leg starts with two IATA
|
||||
// codes followed by two dates. Check that parts[nextOffset] and
|
||||
// parts[nextOffset+1] are 3-letter IATA-ish codes and parts[nextOffset+2]
|
||||
// and [nextOffset+3] are dates. This disambiguates a return trip from
|
||||
// trailing garbage.
|
||||
let returnTrip: ScheduleLeg | undefined;
|
||||
if (nextOffset < parts.length) {
|
||||
const maybeSecond = parseScheduleLeg(parts, nextOffset);
|
||||
if (maybeSecond) {
|
||||
returnTrip = maybeSecond.leg;
|
||||
}
|
||||
}
|
||||
|
||||
const result: DetailsRequest = {
|
||||
area: "schedule",
|
||||
kind: "route",
|
||||
departure: outbound.departure,
|
||||
arrival: outbound.arrival,
|
||||
dateFrom: outbound.dateFrom,
|
||||
dateTo: outbound.dateTo,
|
||||
};
|
||||
|
||||
if (outbound.timeFrom !== undefined && outbound.timeTo !== undefined) {
|
||||
result.timeFrom = outbound.timeFrom;
|
||||
result.timeTo = outbound.timeTo;
|
||||
}
|
||||
if (outbound.connections !== undefined) {
|
||||
result.connections = outbound.connections;
|
||||
}
|
||||
if (returnTrip) {
|
||||
const rt: NonNullable<typeof result.returnTrip> = {
|
||||
departure: returnTrip.departure,
|
||||
arrival: returnTrip.arrival,
|
||||
dateFrom: returnTrip.dateFrom,
|
||||
dateTo: returnTrip.dateTo,
|
||||
};
|
||||
if (returnTrip.timeFrom !== undefined && returnTrip.timeTo !== undefined) {
|
||||
rt.timeFrom = returnTrip.timeFrom;
|
||||
rt.timeTo = returnTrip.timeTo;
|
||||
}
|
||||
if (returnTrip.connections !== undefined) {
|
||||
rt.connections = returnTrip.connections;
|
||||
}
|
||||
result.returnTrip = rt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Online-Board area
|
||||
// ---------------------------------------------------------------------------
|
||||
if (area !== ONLINEBOARD_AREA) return null;
|
||||
|
||||
const kind = parts[1];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user