mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
Compare commits
2 Commits
feat/feed-
...
feat/sourc
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ee44b470 | |||
| 1893c516f3 |
@@ -8,14 +8,14 @@ bun install
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### @aris/data-source-tfl
|
### @aris/source-tfl
|
||||||
|
|
||||||
TfL (Transport for London) data source for tube, overground, and Elizabeth line alerts.
|
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/aris-data-source-tfl
|
cd packages/aris-source-tfl
|
||||||
bun run test
|
bun run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
21
bun.lock
21
bun.lock
@@ -17,14 +17,6 @@
|
|||||||
"name": "@aris/core",
|
"name": "@aris/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
},
|
},
|
||||||
"packages/aris-data-source-tfl": {
|
|
||||||
"name": "@aris/data-source-tfl",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"arktype": "^2.1.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aris-data-source-weatherkit": {
|
"packages/aris-data-source-weatherkit": {
|
||||||
"name": "@aris/data-source-weatherkit",
|
"name": "@aris/data-source-weatherkit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -40,6 +32,15 @@
|
|||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/aris-source-tfl": {
|
||||||
|
"name": "@aris/source-tfl",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-location": "workspace:*",
|
||||||
|
"arktype": "^2.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/aris-source-weatherkit": {
|
"packages/aris-source-weatherkit": {
|
||||||
"name": "@aris/source-weatherkit",
|
"name": "@aris/source-weatherkit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -53,12 +54,12 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||||
|
|
||||||
"@aris/data-source-tfl": ["@aris/data-source-tfl@workspace:packages/aris-data-source-tfl"],
|
|
||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
||||||
|
|
||||||
|
"@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"],
|
||||||
|
|
||||||
"@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"],
|
"@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"],
|
||||||
|
|
||||||
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export { TflDataSource } from "./data-source.ts"
|
|
||||||
export { TflApi, type ITflApi, type TflLineStatus } from "./tfl-api.ts"
|
|
||||||
export type {
|
|
||||||
TflAlertData,
|
|
||||||
TflAlertFeedItem,
|
|
||||||
TflAlertSeverity,
|
|
||||||
TflDataSourceConfig,
|
|
||||||
TflDataSourceOptions,
|
|
||||||
TflLineId,
|
|
||||||
StationLocation,
|
|
||||||
} from "./types.ts"
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
|
|
||||||
import type { StationLocation, TflLineId } from "./types.ts"
|
|
||||||
|
|
||||||
import fixtures from "../fixtures/tfl-responses.json"
|
|
||||||
import { TflDataSource } from "./data-source.ts"
|
|
||||||
|
|
||||||
// Mock API that returns fixture data
|
|
||||||
class FixtureTflApi implements ITflApi {
|
|
||||||
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
|
||||||
const statuses: TflLineStatus[] = []
|
|
||||||
|
|
||||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
const severityCode = status.statusSeverity as number
|
|
||||||
const severity = this.mapSeverity(severityCode)
|
|
||||||
if (severity) {
|
|
||||||
statuses.push({
|
|
||||||
lineId: line.id as TflLineId,
|
|
||||||
lineName: line.name as string,
|
|
||||||
severity,
|
|
||||||
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
const stationMap = new Map<string, StationLocation>()
|
|
||||||
|
|
||||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
|
||||||
for (const stop of stops as Record<string, unknown>[]) {
|
|
||||||
const id = stop.naptanId as string
|
|
||||||
const existing = stationMap.get(id)
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.lines.includes(lineId as TflLineId)) {
|
|
||||||
existing.lines.push(lineId as TflLineId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stationMap.set(id, {
|
|
||||||
id,
|
|
||||||
name: stop.commonName as string,
|
|
||||||
lat: stop.lat as number,
|
|
||||||
lng: stop.lon as number,
|
|
||||||
lines: [lineId as TflLineId],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(stationMap.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapSeverity(code: number): "minor-delays" | "major-delays" | "closure" | null {
|
|
||||||
const map: Record<number, "minor-delays" | "major-delays" | "closure" | null> = {
|
|
||||||
1: "closure",
|
|
||||||
2: "closure",
|
|
||||||
3: "closure",
|
|
||||||
4: "closure",
|
|
||||||
5: "closure",
|
|
||||||
6: "major-delays",
|
|
||||||
7: "major-delays",
|
|
||||||
8: "major-delays",
|
|
||||||
9: "minor-delays",
|
|
||||||
10: null,
|
|
||||||
}
|
|
||||||
return map[code] ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createContext = (location?: { lat: number; lng: number }): Context => ({
|
|
||||||
time: new Date("2026-01-15T12:00:00Z"),
|
|
||||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("TfL Feed Items (using fixture data)", () => {
|
|
||||||
const api = new FixtureTflApi()
|
|
||||||
|
|
||||||
test("query returns feed items array", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
expect(Array.isArray(items)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items have correct base structure", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.id).toBe("string")
|
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
|
||||||
expect(item.type).toBe("tfl-alert")
|
|
||||||
expect(typeof item.priority).toBe("number")
|
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items have correct data structure", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.data.line).toBe("string")
|
|
||||||
expect(typeof item.data.lineName).toBe("string")
|
|
||||||
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
|
||||||
expect(typeof item.data.description).toBe("string")
|
|
||||||
expect(
|
|
||||||
item.data.closestStationDistance === null ||
|
|
||||||
typeof item.data.closestStationDistance === "number",
|
|
||||||
).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed item ids are unique", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
const ids = items.map((item) => item.id)
|
|
||||||
const uniqueIds = new Set(ids)
|
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feed items are sorted by priority descending", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
|
||||||
const prev = items[i - 1]!
|
|
||||||
const curr = items[i]!
|
|
||||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("priority values match severity levels", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
const severityPriority: Record<string, number> = {
|
|
||||||
closure: 100,
|
|
||||||
"major-delays": 80,
|
|
||||||
"minor-delays": 60,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closestStationDistance is number when location provided", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
|
||||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closestStationDistance is null when no location provided", async () => {
|
|
||||||
const dataSource = new TflDataSource(api)
|
|
||||||
const items = await dataSource.query(createContext(), {})
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.data.closestStationDistance).toBeNull()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("TfL Fixture Data Shape", () => {
|
|
||||||
test("fixtures have expected structure", () => {
|
|
||||||
expect(typeof fixtures.fetchedAt).toBe("string")
|
|
||||||
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
|
||||||
expect(typeof fixtures.stopPoints).toBe("object")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("line statuses have required fields", () => {
|
|
||||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
expect(typeof line.id).toBe("string")
|
|
||||||
expect(typeof line.name).toBe("string")
|
|
||||||
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
|
||||||
|
|
||||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
|
||||||
expect(typeof status.statusSeverity).toBe("number")
|
|
||||||
expect(typeof status.statusSeverityDescription).toBe("string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("stop points have required fields", () => {
|
|
||||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
|
||||||
expect(typeof lineId).toBe("string")
|
|
||||||
expect(Array.isArray(stops)).toBe(true)
|
|
||||||
|
|
||||||
for (const stop of stops as Record<string, unknown>[]) {
|
|
||||||
expect(typeof stop.naptanId).toBe("string")
|
|
||||||
expect(typeof stop.commonName).toBe("string")
|
|
||||||
expect(typeof stop.lat).toBe("number")
|
|
||||||
expect(typeof stop.lon).toBe("number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
import type { TflLineId } from "./tfl-api.ts"
|
|
||||||
|
|
||||||
export type { TflLineId } from "./tfl-api.ts"
|
|
||||||
|
|
||||||
export type TflAlertSeverity = "minor-delays" | "major-delays" | "closure"
|
|
||||||
|
|
||||||
export interface TflAlertData extends Record<string, unknown> {
|
|
||||||
line: TflLineId
|
|
||||||
lineName: string
|
|
||||||
severity: TflAlertSeverity
|
|
||||||
description: string
|
|
||||||
closestStationDistance: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
|
||||||
|
|
||||||
export interface TflDataSourceConfig {
|
|
||||||
lines?: TflLineId[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TflDataSourceOptions {
|
|
||||||
apiKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StationLocation {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
lines: TflLineId[]
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@aris/data-source-tfl",
|
"name": "@aris/source-tfl",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-location": "workspace:*",
|
||||||
"arktype": "^2.1.0"
|
"arktype": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
11
packages/aris-source-tfl/src/index.ts
Normal file
11
packages/aris-source-tfl/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { TflSource } from "./tfl-source.ts"
|
||||||
|
export { TflApi } from "./tfl-api.ts"
|
||||||
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
|
export type {
|
||||||
|
StationLocation,
|
||||||
|
TflAlertData,
|
||||||
|
TflAlertFeedItem,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflLineStatus,
|
||||||
|
TflSourceOptions,
|
||||||
|
} from "./types.ts"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
import type { StationLocation, TflAlertSeverity } from "./types.ts"
|
import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts"
|
||||||
|
|
||||||
const TFL_API_BASE = "https://api.tfl.gov.uk"
|
const TFL_API_BASE = "https://api.tfl.gov.uk"
|
||||||
|
|
||||||
@@ -50,19 +50,7 @@ const SEVERITY_MAP: Record<number, TflAlertSeverity | null> = {
|
|||||||
20: null, // Service Closed
|
20: null, // Service Closed
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TflLineStatus {
|
export class TflApi {
|
||||||
lineId: TflLineId
|
|
||||||
lineName: string
|
|
||||||
severity: TflAlertSeverity
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITflApi {
|
|
||||||
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
|
||||||
fetchStations(): Promise<StationLocation[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TflApi implements ITflApi {
|
|
||||||
private apiKey: string
|
private apiKey: string
|
||||||
private stationsCache: StationLocation[] | null = null
|
private stationsCache: StationLocation[] | null = null
|
||||||
|
|
||||||
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
|
import { LocationKey, type Location } from "@aris/source-location"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ITflApi,
|
||||||
|
StationLocation,
|
||||||
|
TflAlertSeverity,
|
||||||
|
TflLineId,
|
||||||
|
TflLineStatus,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
import fixtures from "../fixtures/tfl-responses.json"
|
||||||
|
import { TflSource } from "./tfl-source.ts"
|
||||||
|
|
||||||
|
// Mock API that returns fixture data
|
||||||
|
class FixtureTflApi implements ITflApi {
|
||||||
|
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||||
|
const statuses: TflLineStatus[] = []
|
||||||
|
|
||||||
|
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
const severityCode = status.statusSeverity as number
|
||||||
|
const severity = this.mapSeverity(severityCode)
|
||||||
|
if (severity) {
|
||||||
|
statuses.push({
|
||||||
|
lineId: line.id as TflLineId,
|
||||||
|
lineName: line.name as string,
|
||||||
|
severity,
|
||||||
|
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
const stationMap = new Map<string, StationLocation>()
|
||||||
|
|
||||||
|
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||||
|
for (const stop of stops as Record<string, unknown>[]) {
|
||||||
|
const id = stop.naptanId as string
|
||||||
|
const existing = stationMap.get(id)
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.lines.includes(lineId as TflLineId)) {
|
||||||
|
existing.lines.push(lineId as TflLineId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stationMap.set(id, {
|
||||||
|
id,
|
||||||
|
name: stop.commonName as string,
|
||||||
|
lat: stop.lat as number,
|
||||||
|
lng: stop.lon as number,
|
||||||
|
lines: [lineId as TflLineId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(stationMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSeverity(code: number): TflAlertSeverity | null {
|
||||||
|
const map: Record<number, TflAlertSeverity | null> = {
|
||||||
|
1: "closure",
|
||||||
|
2: "closure",
|
||||||
|
3: "closure",
|
||||||
|
4: "closure",
|
||||||
|
5: "closure",
|
||||||
|
6: "major-delays",
|
||||||
|
7: "major-delays",
|
||||||
|
8: "major-delays",
|
||||||
|
9: "minor-delays",
|
||||||
|
10: null,
|
||||||
|
}
|
||||||
|
return map[code] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(location?: Location): Context {
|
||||||
|
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
|
||||||
|
if (location) {
|
||||||
|
ctx[LocationKey] = location
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TflSource", () => {
|
||||||
|
const api = new FixtureTflApi()
|
||||||
|
|
||||||
|
describe("interface", () => {
|
||||||
|
test("has correct id", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.id).toBe("tfl")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("depends on location", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.dependencies).toEqual(["location"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("implements fetchItems", () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
expect(source.fetchItems).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws if neither client nor apiKey provided", () => {
|
||||||
|
expect(() => new TflSource({})).toThrow("Either client or apiKey must be provided")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fetchItems", () => {
|
||||||
|
test("returns feed items array", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
expect(Array.isArray(items)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct base structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.id).toBe("string")
|
||||||
|
expect(item.id).toMatch(/^tfl-alert-/)
|
||||||
|
expect(item.type).toBe("tfl-alert")
|
||||||
|
expect(typeof item.priority).toBe("number")
|
||||||
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items have correct data structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.data.line).toBe("string")
|
||||||
|
expect(typeof item.data.lineName).toBe("string")
|
||||||
|
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
||||||
|
expect(typeof item.data.description).toBe("string")
|
||||||
|
expect(
|
||||||
|
item.data.closestStationDistance === null ||
|
||||||
|
typeof item.data.closestStationDistance === "number",
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed item ids are unique", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
const ids = items.map((item) => item.id)
|
||||||
|
const uniqueIds = new Set(ids)
|
||||||
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("feed items are sorted by priority descending", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const prev = items[i - 1]!
|
||||||
|
const curr = items[i]!
|
||||||
|
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("priority values match severity levels", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
const severityPriority: Record<string, number> = {
|
||||||
|
closure: 1.0,
|
||||||
|
"major-delays": 0.8,
|
||||||
|
"minor-delays": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(typeof item.data.closestStationDistance).toBe("number")
|
||||||
|
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closestStationDistance is null when no location provided", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.data.closestStationDistance).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("TfL Fixture Data Shape", () => {
|
||||||
|
test("fixtures have expected structure", () => {
|
||||||
|
expect(typeof fixtures.fetchedAt).toBe("string")
|
||||||
|
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
||||||
|
expect(typeof fixtures.stopPoints).toBe("object")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("line statuses have required fields", () => {
|
||||||
|
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
expect(typeof line.id).toBe("string")
|
||||||
|
expect(typeof line.name).toBe("string")
|
||||||
|
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
||||||
|
|
||||||
|
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||||
|
expect(typeof status.statusSeverity).toBe("number")
|
||||||
|
expect(typeof status.statusSeverityDescription).toBe("string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stop points have required fields", () => {
|
||||||
|
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||||
|
expect(typeof lineId).toBe("string")
|
||||||
|
expect(Array.isArray(stops)).toBe(true)
|
||||||
|
|
||||||
|
for (const stop of stops as Record<string, unknown>[]) {
|
||||||
|
expect(typeof stop.naptanId).toBe("string")
|
||||||
|
expect(typeof stop.commonName).toBe("string")
|
||||||
|
expect(typeof stop.lat).toBe("number")
|
||||||
|
expect(typeof stop.lon).toBe("number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,21 +1,104 @@
|
|||||||
import type { Context, DataSource } from "@aris/core"
|
import type { Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
import { contextValue } from "@aris/core"
|
||||||
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ITflApi,
|
||||||
StationLocation,
|
StationLocation,
|
||||||
TflAlertData,
|
TflAlertData,
|
||||||
TflAlertFeedItem,
|
TflAlertFeedItem,
|
||||||
TflAlertSeverity,
|
TflAlertSeverity,
|
||||||
TflDataSourceConfig,
|
|
||||||
TflDataSourceOptions,
|
|
||||||
TflLineId,
|
TflLineId,
|
||||||
|
TflSourceOptions,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, type ITflApi } from "./tfl-api.ts"
|
import { TflApi } from "./tfl-api.ts"
|
||||||
|
|
||||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||||
closure: 100,
|
closure: 1.0,
|
||||||
"major-delays": 80,
|
"major-delays": 0.8,
|
||||||
"minor-delays": 60,
|
"minor-delays": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FeedSource that provides TfL (Transport for London) service alerts.
|
||||||
|
*
|
||||||
|
* Depends on location source for proximity-based sorting. Produces feed items
|
||||||
|
* for tube, overground, and Elizabeth line disruptions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const tflSource = new TflSource({
|
||||||
|
* apiKey: process.env.TFL_API_KEY!,
|
||||||
|
* lines: ["northern", "victoria", "jubilee"],
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* const engine = new FeedEngine()
|
||||||
|
* .register(locationSource)
|
||||||
|
* .register(tflSource)
|
||||||
|
*
|
||||||
|
* const { items } = await engine.refresh()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||||
|
readonly id = "tfl"
|
||||||
|
readonly dependencies = ["location"]
|
||||||
|
|
||||||
|
private readonly client: ITflApi
|
||||||
|
private readonly lines?: TflLineId[]
|
||||||
|
|
||||||
|
constructor(options: TflSourceOptions) {
|
||||||
|
if (!options.client && !options.apiKey) {
|
||||||
|
throw new Error("Either client or apiKey must be provided")
|
||||||
|
}
|
||||||
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||||
|
this.lines = options.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
||||||
|
const [statuses, stations] = await Promise.all([
|
||||||
|
this.client.fetchLineStatuses(this.lines),
|
||||||
|
this.client.fetchStations(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const location = contextValue(context, LocationKey)
|
||||||
|
|
||||||
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
|
const closestStationDistance = location
|
||||||
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const data: TflAlertData = {
|
||||||
|
line: status.lineId,
|
||||||
|
lineName: status.lineName,
|
||||||
|
severity: status.severity,
|
||||||
|
description: status.description,
|
||||||
|
closestStationDistance,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
|
type: "tfl-alert",
|
||||||
|
priority: SEVERITY_PRIORITY[status.severity],
|
||||||
|
timestamp: context.time,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by severity (desc), then by proximity (asc) if location available
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (b.priority !== a.priority) {
|
||||||
|
return b.priority - a.priority
|
||||||
|
}
|
||||||
|
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||||
|
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
@@ -51,65 +134,3 @@ function findClosestStationDistance(
|
|||||||
|
|
||||||
return minDistance
|
return minDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TflDataSource implements DataSource<TflAlertFeedItem, TflDataSourceConfig> {
|
|
||||||
readonly type = "tfl-alert"
|
|
||||||
private api: ITflApi
|
|
||||||
|
|
||||||
constructor(options: TflDataSourceOptions)
|
|
||||||
constructor(api: ITflApi)
|
|
||||||
constructor(optionsOrApi: TflDataSourceOptions | ITflApi) {
|
|
||||||
if ("fetchLineStatuses" in optionsOrApi) {
|
|
||||||
this.api = optionsOrApi
|
|
||||||
} else {
|
|
||||||
this.api = new TflApi(optionsOrApi.apiKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> {
|
|
||||||
const [statuses, stations] = await Promise.all([
|
|
||||||
this.api.fetchLineStatuses(config.lines),
|
|
||||||
this.api.fetchStations(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
|
||||||
const closestStationDistance = context.location
|
|
||||||
? findClosestStationDistance(
|
|
||||||
status.lineId,
|
|
||||||
stations,
|
|
||||||
context.location.lat,
|
|
||||||
context.location.lng,
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const data: TflAlertData = {
|
|
||||||
line: status.lineId,
|
|
||||||
lineName: status.lineName,
|
|
||||||
severity: status.severity,
|
|
||||||
description: status.description,
|
|
||||||
closestStationDistance,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
|
||||||
type: this.type,
|
|
||||||
priority: SEVERITY_PRIORITY[status.severity],
|
|
||||||
timestamp: context.time,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by severity (desc), then by proximity (asc) if location available
|
|
||||||
items.sort((a, b) => {
|
|
||||||
if (b.priority !== a.priority) {
|
|
||||||
return b.priority - a.priority
|
|
||||||
}
|
|
||||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
|
||||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
packages/aris-source-tfl/src/types.ts
Normal file
50
packages/aris-source-tfl/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
|
import type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
|
|
||||||
|
export const TflAlertSeverity = {
|
||||||
|
MinorDelays: "minor-delays",
|
||||||
|
MajorDelays: "major-delays",
|
||||||
|
Closure: "closure",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TflAlertSeverity = (typeof TflAlertSeverity)[keyof typeof TflAlertSeverity]
|
||||||
|
|
||||||
|
export interface TflAlertData extends Record<string, unknown> {
|
||||||
|
line: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
closestStationDistance: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||||
|
|
||||||
|
export interface TflSourceOptions {
|
||||||
|
apiKey?: string
|
||||||
|
client?: ITflApi
|
||||||
|
/** Lines to monitor. Defaults to all lines. */
|
||||||
|
lines?: TflLineId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationLocation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
lines: TflLineId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITflApi {
|
||||||
|
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
||||||
|
fetchStations(): Promise<StationLocation[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TflLineStatus {
|
||||||
|
lineId: TflLineId
|
||||||
|
lineName: string
|
||||||
|
severity: TflAlertSeverity
|
||||||
|
description: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user