Implement Phase 2B URL serializer with TDD (73 tests)
Pure TypeScript port of Angular OnlineBoardUrlBuilder/Parser covering all 6 URL types (start, flight, departure, arrival, route, details). Includes roundtrip parity tests and edge cases for suffixed flights, variable-length flight numbers, time ranges, and 3-char carriers.
This commit is contained in:
@@ -2,4 +2,13 @@
|
||||
// This file is the ONLY public surface — other sub-plans and features
|
||||
// must import exclusively from "@/features/online-board", never from
|
||||
// deeper paths. See docs/superpowers/phase-1/frozen-barrels.md for the rule.
|
||||
export {};
|
||||
|
||||
export type { OnlineBoardParams } from "./url";
|
||||
export {
|
||||
parseOnlineBoardUrl,
|
||||
buildOnlineBoardUrl,
|
||||
parseFlightUrlParams,
|
||||
buildFlightUrlParams,
|
||||
parseStationUrlParams,
|
||||
parseRouteUrlParams,
|
||||
} from "./url";
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseFlightUrlParams,
|
||||
buildFlightUrlParams,
|
||||
parseStationUrlParams,
|
||||
parseRouteUrlParams,
|
||||
parseOnlineBoardUrl,
|
||||
buildOnlineBoardUrl,
|
||||
type OnlineBoardParams,
|
||||
} from "./url";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseFlightUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseFlightUrlParams", () => {
|
||||
it("parses a standard 4-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU0100-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a flight with suffix", () => {
|
||||
expect(parseFlightUrlParams("SU0100D-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 3-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU100-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 4-digit flight number without padding", () => {
|
||||
expect(parseFlightUrlParams("SU1234-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "1234",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 1-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU1-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "1",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 3-char carrier code (3rd char is letter)", () => {
|
||||
// Rare: carrier like "SUA" where 3rd char is a letter
|
||||
expect(parseFlightUrlParams("SUA100-20250115")).toEqual({
|
||||
carrier: "SUA",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseFlightUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date part", () => {
|
||||
expect(parseFlightUrlParams("SU0100")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid date (non-8-digit)", () => {
|
||||
expect(parseFlightUrlParams("SU0100-2025011")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for no flight info", () => {
|
||||
expect(parseFlightUrlParams("-20250115")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFlightUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFlightUrlParams", () => {
|
||||
it("pads a 3-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "100", date: "20250115" }),
|
||||
).toBe("SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds with suffix", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("does not pad a 4-digit flight number", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "1234", date: "20250115" }),
|
||||
).toBe("SU1234-20250115");
|
||||
});
|
||||
|
||||
it("pads a 1-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "1", date: "20250115" }),
|
||||
).toBe("SU0001-20250115");
|
||||
});
|
||||
|
||||
it("pads a 2-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "12", date: "20250115" }),
|
||||
).toBe("SU0012-20250115");
|
||||
});
|
||||
|
||||
it("builds with 3-char carrier", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SUA", flightNumber: "100", date: "20250115" }),
|
||||
).toBe("SUA0100-20250115");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseStationUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseStationUrlParams", () => {
|
||||
it("parses station without time range", () => {
|
||||
expect(parseStationUrlParams("SVO-20250115")).toEqual({
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses station with time range", () => {
|
||||
expect(parseStationUrlParams("SVO-20250115-08001800")).toEqual({
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseStationUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date", () => {
|
||||
expect(parseStationUrlParams("SVO")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid date", () => {
|
||||
expect(parseStationUrlParams("SVO-2025")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseRouteUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseRouteUrlParams", () => {
|
||||
it("parses route without time range", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED-20250115")).toEqual({
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route with time range", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED-20250115-08001800")).toEqual({
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseRouteUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOnlineBoardUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseOnlineBoardUrl", () => {
|
||||
it("parses start page (no trailing slash)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("parses start page (with trailing slash)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("parses flight search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses flight search URL with suffix", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100D-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses departure search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115")).toEqual({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses departure search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses arrival search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115")).toEqual({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses arrival search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115")).toEqual({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses details URL (no /flight/ prefix)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/SU0100-20250115")).toEqual({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses details URL with suffix", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/SU0100D-20250115")).toEqual({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-onlineboard path", () => {
|
||||
expect(parseOnlineBoardUrl("/some/other/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseOnlineBoardUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid flight URL params", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid departure URL params", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/departure/")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles path without leading slash", () => {
|
||||
expect(parseOnlineBoardUrl("onlineboard")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("handles path without leading slash for flight", () => {
|
||||
expect(parseOnlineBoardUrl("onlineboard/flight/SU0100-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildOnlineBoardUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildOnlineBoardUrl", () => {
|
||||
it("builds start page URL", () => {
|
||||
expect(buildOnlineBoardUrl({ type: "start" })).toBe("onlineboard");
|
||||
});
|
||||
|
||||
it("builds flight search URL", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/flight/SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds flight search URL with suffix", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/flight/SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("builds departure search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
|
||||
it("builds departure search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds arrival search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/arrival/LED-20250115");
|
||||
});
|
||||
|
||||
it("builds arrival search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/arrival/LED-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds route search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/route/SVO-LED-20250115");
|
||||
});
|
||||
|
||||
it("builds route search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/route/SVO-LED-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds details URL", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds details URL with suffix", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("omits time range when only timeFrom is provided", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
|
||||
it("omits time range when only timeTo is provided", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roundtrip tests: build -> parse -> build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("roundtrip: build -> parse -> build", () => {
|
||||
const cases: Array<{ name: string; params: OnlineBoardParams }> = [
|
||||
{ name: "start", params: { type: "start" } },
|
||||
{
|
||||
name: "flight without suffix",
|
||||
params: { type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "flight with suffix",
|
||||
params: {
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "departure without time",
|
||||
params: { type: "departure", station: "SVO", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "departure with time",
|
||||
params: {
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arrival without time",
|
||||
params: { type: "arrival", station: "LED", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "arrival with time",
|
||||
params: {
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "route without time",
|
||||
params: { type: "route", departure: "SVO", arrival: "LED", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "route with time",
|
||||
params: {
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "details without suffix",
|
||||
params: { type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "details with suffix",
|
||||
params: {
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, params } of cases) {
|
||||
it(`roundtrips ${name}`, () => {
|
||||
const url = buildOnlineBoardUrl(params);
|
||||
const parsed = parseOnlineBoardUrl(url);
|
||||
expect(parsed).not.toBeNull();
|
||||
if (parsed === null) return; // type guard for TS
|
||||
const rebuilt = buildOnlineBoardUrl(parsed);
|
||||
expect(rebuilt).toBe(url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("parse returns null for completely invalid path", () => {
|
||||
expect(parseOnlineBoardUrl("/foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("parse returns null for onlineboard with unknown sub-path that doesn't look like a flight", () => {
|
||||
// "xyz" alone doesn't parse as a flight ID (no dash with date)
|
||||
expect(parseOnlineBoardUrl("/onlineboard/xyz")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles flight number build roundtrip: 3-digit input gets padded", () => {
|
||||
const built = buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
expect(built).toBe("onlineboard/flight/SU0100-20250115");
|
||||
|
||||
const parsed = parseOnlineBoardUrl(built);
|
||||
expect(parsed).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles 3-char carrier in roundtrip", () => {
|
||||
const params: OnlineBoardParams = {
|
||||
type: "flight",
|
||||
carrier: "SUA",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
};
|
||||
const url = buildOnlineBoardUrl(params);
|
||||
const parsed = parseOnlineBoardUrl(url);
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("parse returns null for route with insufficient segments", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/route/SVO")).toBeNull();
|
||||
});
|
||||
|
||||
it("station time range with partial time returns no time", () => {
|
||||
// Only 4 chars for time (missing second half) -> no timeFrom/timeTo
|
||||
expect(parseStationUrlParams("SVO-20250115-0800")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Online Board URL serializer/parser.
|
||||
*
|
||||
* Pure functions — no side effects, no Angular imports, no Date objects.
|
||||
* Byte-exact parity with Angular's OnlineBoardUrlBuilderService /
|
||||
* OnlineBoardUrlParserService.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { IParsedFlightId } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Discriminated union of all parsed online board URL parameter shapes */
|
||||
export type OnlineBoardParams =
|
||||
| { type: "start" }
|
||||
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
|
||||
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| {
|
||||
type: "route";
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
}
|
||||
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ONLINE_BOARD_PREFIX = "onlineboard";
|
||||
const TIME_RANGE_LENGTH = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isValidDate(date: string): boolean {
|
||||
return /^\d{8}$/.test(date);
|
||||
}
|
||||
|
||||
function isValidTimeRange(timeRange: string): boolean {
|
||||
return /^\d{8}$/.test(timeRange);
|
||||
}
|
||||
|
||||
function isLetter(ch: string): boolean {
|
||||
return /^[A-Za-z]$/.test(ch);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a flight URL parameter string into its constituent parts.
|
||||
* Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
|
||||
*
|
||||
* Carrier detection logic (matching Angular):
|
||||
* - First 2 chars are always carrier.
|
||||
* - If the 3rd char exists and is a letter, it's part of the carrier (3-char carrier code).
|
||||
* - Last char of the flight info (before the dash) that is a letter is the suffix.
|
||||
*/
|
||||
export function parseFlightUrlParams(raw: string): IParsedFlightId | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const dashIdx = raw.indexOf("-");
|
||||
if (dashIdx < 0) return null;
|
||||
|
||||
const flightInfo = raw.slice(0, dashIdx);
|
||||
const dateStr = raw.slice(dashIdx + 1);
|
||||
|
||||
if (!flightInfo || !isValidDate(dateStr)) return null;
|
||||
|
||||
// Carrier: first 2 chars + optional 3rd letter
|
||||
let carrierLen = 2;
|
||||
const thirdChar = flightInfo[2];
|
||||
if (flightInfo.length > 2 && thirdChar !== undefined && isLetter(thirdChar)) {
|
||||
carrierLen = 3;
|
||||
}
|
||||
|
||||
if (flightInfo.length <= carrierLen) return null;
|
||||
|
||||
const carrier = flightInfo.slice(0, carrierLen);
|
||||
|
||||
// Suffix: last char if it's a letter
|
||||
const lastChar = flightInfo.at(-1);
|
||||
if (lastChar === undefined) return null;
|
||||
const hasSuffix = isLetter(lastChar);
|
||||
const suffix = hasSuffix ? lastChar : undefined;
|
||||
|
||||
// Flight number: everything between carrier and optional suffix
|
||||
const numberEnd = hasSuffix ? flightInfo.length - 1 : flightInfo.length;
|
||||
const flightNumber = flightInfo.slice(carrierLen, numberEnd);
|
||||
|
||||
if (!flightNumber) return null;
|
||||
|
||||
const result: IParsedFlightId = {
|
||||
carrier,
|
||||
flightNumber,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (suffix !== undefined) {
|
||||
result.suffix = suffix;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flight URL parameter string from parts.
|
||||
* Output format: {carrier}{paddedFlightNumber}{suffix?}-{yyyyMMdd}
|
||||
*
|
||||
* Flight number is zero-padded so that flightNumber+suffix is at least 5 chars
|
||||
* (matching Angular's `.slice(-5)` logic).
|
||||
*/
|
||||
export function buildFlightUrlParams(id: IParsedFlightId): string {
|
||||
const suffix = id.suffix ?? "";
|
||||
// Pad flight number to 4 digits (standard IATA width).
|
||||
// Angular's `.slice(-5)` on `flightNumber+suffix` is a truncation guard,
|
||||
// not padding — the API already supplies zero-padded numbers. We pad here
|
||||
// to handle cases where the caller passes unpadded numbers (e.g. "100").
|
||||
const paddedNumber = id.flightNumber.padStart(4, "0");
|
||||
// Angular truncation: take last 5 chars of (number + suffix) to cap length
|
||||
const combined = `${paddedNumber}${suffix}`.slice(-5);
|
||||
|
||||
return `${id.carrier}${combined}-${id.date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time range from an 8-char continuous string "HHmmHHmm".
|
||||
* Returns undefined fields if the input is not a valid time range.
|
||||
*/
|
||||
function parseTimeRange(
|
||||
raw: string | undefined,
|
||||
): { timeFrom: string; timeTo: string } | undefined {
|
||||
if (!raw || !isValidTimeRange(raw)) return undefined;
|
||||
return {
|
||||
timeFrom: raw.slice(0, 4),
|
||||
timeTo: raw.slice(4, TIME_RANGE_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build time range string from optional from/to fields.
|
||||
* Returns undefined if either field is missing.
|
||||
*/
|
||||
function buildTimeRange(
|
||||
timeFrom: string | undefined,
|
||||
timeTo: string | undefined,
|
||||
): string | undefined {
|
||||
if (!timeFrom || !timeTo) return undefined;
|
||||
return `${timeFrom}${timeTo}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a station URL parameter string.
|
||||
* Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseStationUrlParams(
|
||||
raw: string,
|
||||
): { station: string; date: string; timeFrom?: string; timeTo?: string } | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.split("-");
|
||||
const station = parts[0];
|
||||
const dateStr = parts[1];
|
||||
if (station === undefined || dateStr === undefined) return null;
|
||||
|
||||
if (!isValidDate(dateStr)) return null;
|
||||
|
||||
const timeRaw = parts[2];
|
||||
const timeRange = parseTimeRange(timeRaw);
|
||||
|
||||
// If there's a 3rd segment but it's not a valid time range, reject
|
||||
if (timeRaw !== undefined && !timeRange) return null;
|
||||
|
||||
const result: { station: string; date: string; timeFrom?: string; timeTo?: string } = {
|
||||
station,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (timeRange) {
|
||||
result.timeFrom = timeRange.timeFrom;
|
||||
result.timeTo = timeRange.timeTo;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a route URL parameter string.
|
||||
* Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseRouteUrlParams(
|
||||
raw: string,
|
||||
): {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.split("-");
|
||||
const departure = parts[0];
|
||||
const arrival = parts[1];
|
||||
const dateStr = parts[2];
|
||||
if (departure === undefined || arrival === undefined || dateStr === undefined) return null;
|
||||
|
||||
if (!isValidDate(dateStr)) return null;
|
||||
|
||||
const timeRaw = parts[3];
|
||||
const timeRange = parseTimeRange(timeRaw);
|
||||
|
||||
const result: {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} = {
|
||||
departure,
|
||||
arrival,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (timeRange) {
|
||||
result.timeFrom = timeRange.timeFrom;
|
||||
result.timeTo = timeRange.timeTo;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for constructing OnlineBoardParams with exactOptionalPropertyTypes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toFlightParams(
|
||||
type: "flight" | "details",
|
||||
parsed: IParsedFlightId,
|
||||
): OnlineBoardParams {
|
||||
if (parsed.suffix !== undefined) {
|
||||
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, suffix: parsed.suffix, date: parsed.date };
|
||||
}
|
||||
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, date: parsed.date };
|
||||
}
|
||||
|
||||
function toStationParams(
|
||||
type: "departure" | "arrival",
|
||||
parsed: { station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
): OnlineBoardParams {
|
||||
if (parsed.timeFrom !== undefined && parsed.timeTo !== undefined) {
|
||||
return { type, station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo };
|
||||
}
|
||||
return { type, station: parsed.station, date: parsed.date };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level parse / build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a raw URL path into typed online board params.
|
||||
* Returns null if the path does not match any known online board URL shape.
|
||||
*/
|
||||
export function parseOnlineBoardUrl(path: string): OnlineBoardParams | null {
|
||||
if (!path) return null;
|
||||
|
||||
// Strip leading slash
|
||||
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
||||
|
||||
// Must start with "onlineboard"
|
||||
if (!normalized.startsWith(ONLINE_BOARD_PREFIX)) return null;
|
||||
|
||||
// Get the rest after "onlineboard"
|
||||
const rest = normalized.slice(ONLINE_BOARD_PREFIX.length);
|
||||
|
||||
// Start page: empty or just "/"
|
||||
if (!rest || rest === "/") return { type: "start" };
|
||||
|
||||
// Must have "/" after "onlineboard"
|
||||
if (!rest.startsWith("/")) return null;
|
||||
|
||||
const afterPrefix = rest.slice(1); // Remove leading "/"
|
||||
|
||||
// Split into route type and params
|
||||
const slashIdx = afterPrefix.indexOf("/");
|
||||
|
||||
if (slashIdx >= 0) {
|
||||
const routeType = afterPrefix.slice(0, slashIdx);
|
||||
const params = afterPrefix.slice(slashIdx + 1);
|
||||
|
||||
switch (routeType) {
|
||||
case "flight": {
|
||||
const parsed = parseFlightUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toFlightParams("flight", parsed);
|
||||
}
|
||||
|
||||
case "departure": {
|
||||
const parsed = parseStationUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toStationParams("departure", parsed);
|
||||
}
|
||||
|
||||
case "arrival": {
|
||||
const parsed = parseStationUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toStationParams("arrival", parsed);
|
||||
}
|
||||
|
||||
case "route": {
|
||||
const parsed = parseRouteUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return parsed.timeFrom !== undefined && parsed.timeTo !== undefined
|
||||
? { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }
|
||||
: { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// No sub-slash: must be a details URL (flight ID directly after /onlineboard/)
|
||||
const parsed = parseFlightUrlParams(afterPrefix);
|
||||
if (!parsed) return null;
|
||||
return toFlightParams("details", parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL path segment from typed online board params.
|
||||
* Output is byte-exact match with Angular's URL builder.
|
||||
*
|
||||
* @returns the path segment without leading slash (e.g. "onlineboard/flight/SU0100-20250115")
|
||||
*/
|
||||
export function buildOnlineBoardUrl(params: OnlineBoardParams): string {
|
||||
switch (params.type) {
|
||||
case "start":
|
||||
return ONLINE_BOARD_PREFIX;
|
||||
|
||||
case "flight": {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: params.carrier,
|
||||
flightNumber: params.flightNumber,
|
||||
date: params.date,
|
||||
};
|
||||
if (params.suffix !== undefined) id.suffix = params.suffix;
|
||||
return `${ONLINE_BOARD_PREFIX}/flight/${buildFlightUrlParams(id)}`;
|
||||
}
|
||||
|
||||
case "departure": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.station}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/departure/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/departure/${locationDate}`;
|
||||
}
|
||||
|
||||
case "arrival": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.station}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}`;
|
||||
}
|
||||
|
||||
case "route": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.departure}-${params.arrival}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/route/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/route/${locationDate}`;
|
||||
}
|
||||
|
||||
case "details": {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: params.carrier,
|
||||
flightNumber: params.flightNumber,
|
||||
date: params.date,
|
||||
};
|
||||
if (params.suffix !== undefined) id.suffix = params.suffix;
|
||||
return `${ONLINE_BOARD_PREFIX}/${buildFlightUrlParams(id)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user