mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
Compare commits
2 Commits
feat/defau
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4097470656 | |||
| f549859a44 |
@@ -10,5 +10,7 @@ export {
|
|||||||
type TflAlertSeverity,
|
type TflAlertSeverity,
|
||||||
type TflLineStatus,
|
type TflLineStatus,
|
||||||
type TflSourceOptions,
|
type TflSourceOptions,
|
||||||
|
type TflStatusData,
|
||||||
|
type TflStatusFeedItem,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
export { renderTflAlert } from "./renderer.tsx"
|
export { renderTflStatus } from "./renderer.tsx"
|
||||||
|
|||||||
@@ -2,102 +2,140 @@
|
|||||||
import { render } from "@nym.sh/jrx"
|
import { render } from "@nym.sh/jrx"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { TflAlertFeedItem } from "./types.ts"
|
import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
|
||||||
|
|
||||||
import { renderTflAlert } from "./renderer.tsx"
|
import { renderTflStatus } from "./renderer.tsx"
|
||||||
|
|
||||||
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
|
function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
|
||||||
return {
|
return {
|
||||||
id: "tfl-alert-northern-minor-delays",
|
line: "northern",
|
||||||
type: "tfl-alert",
|
lineName: "Northern",
|
||||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
severity: "minor-delays",
|
||||||
data: {
|
description: "Minor delays due to signal failure",
|
||||||
line: "northern",
|
closestStationDistance: null,
|
||||||
lineName: "Northern",
|
...overrides,
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays due to signal failure",
|
|
||||||
closestStationDistance: null,
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("renderTflAlert", () => {
|
function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
|
||||||
test("renders a FeedCard with title and description", () => {
|
return {
|
||||||
const node = renderTflAlert(makeItem())
|
id: "tfl-status",
|
||||||
|
sourceId: "aelis.tfl",
|
||||||
|
type: "tfl-status",
|
||||||
|
timestamp: new Date("2026-01-15T12:00:00Z"),
|
||||||
|
data: { alerts },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect all SansSerifText elements from a rendered spec, filtering out Fragments. */
|
||||||
|
function collectTextElements(spec: ReturnType<typeof render>) {
|
||||||
|
return Object.values(spec.elements).filter((el) => el.type === "SansSerifText")
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderTflStatus", () => {
|
||||||
|
test("renders a single FeedCard", () => {
|
||||||
|
const node = renderTflStatus(makeItem([makeAlert()]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const root = spec.elements[spec.root]!
|
||||||
expect(root.type).toBe("FeedCard")
|
expect(root.type).toBe("FeedCard")
|
||||||
expect(root.children!.length).toBeGreaterThanOrEqual(2)
|
})
|
||||||
|
|
||||||
const title = spec.elements[root.children![0]!]!
|
test("renders one alert with title and description", () => {
|
||||||
expect(title.type).toBe("SansSerifText")
|
const node = renderTflStatus(makeItem([makeAlert()]))
|
||||||
expect(title.props.content).toBe("Northern · Minor delays")
|
const spec = render(node)
|
||||||
|
|
||||||
const body = spec.elements[root.children![1]!]!
|
const texts = collectTextElements(spec)
|
||||||
expect(body.type).toBe("SansSerifText")
|
const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
|
||||||
expect(body.props.content).toBe("Minor delays due to signal failure")
|
const bodyText = texts.find((el) => el.props.content === "Minor delays due to signal failure")
|
||||||
|
|
||||||
|
expect(titleText).toBeDefined()
|
||||||
|
expect(bodyText).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders multiple alerts stacked in one card", () => {
|
||||||
|
const alerts = [
|
||||||
|
makeAlert({ line: "northern", lineName: "Northern", severity: "minor-delays" }),
|
||||||
|
makeAlert({
|
||||||
|
line: "central",
|
||||||
|
lineName: "Central",
|
||||||
|
severity: "closure",
|
||||||
|
description: "Closed due to strike",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const node = renderTflStatus(makeItem(alerts))
|
||||||
|
const spec = render(node)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("FeedCard")
|
||||||
|
|
||||||
|
const texts = collectTextElements(spec)
|
||||||
|
const northernTitle = texts.find((el) => el.props.content === "Northern · Minor delays")
|
||||||
|
const centralTitle = texts.find((el) => el.props.content === "Central · Closed")
|
||||||
|
const centralBody = texts.find((el) => el.props.content === "Closed due to strike")
|
||||||
|
|
||||||
|
expect(northernTitle).toBeDefined()
|
||||||
|
expect(centralTitle).toBeDefined()
|
||||||
|
expect(centralBody).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows nearest station distance when available", () => {
|
test("shows nearest station distance when available", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
expect(root.children).toHaveLength(3)
|
const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
|
||||||
|
expect(caption).toBeDefined()
|
||||||
const caption = spec.elements[root.children![2]!]!
|
|
||||||
expect(caption.type).toBe("SansSerifText")
|
|
||||||
expect(caption.props.content).toBe("Nearest station: 350m away")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats distance in km when >= 1km", () => {
|
test("formats distance in km when >= 1km", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const caption = spec.elements[root.children![2]!]!
|
const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
|
||||||
expect(caption.props.content).toBe("Nearest station: 2.5km away")
|
expect(caption).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats near-1km boundary as km not meters", () => {
|
test("formats near-1km boundary as km not meters", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const caption = spec.elements[root.children![2]!]!
|
const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
|
||||||
expect(caption.props.content).toBe("Nearest station: 1.0km away")
|
expect(caption).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits station distance when null", () => {
|
test("omits station distance when null", () => {
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
|
const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
// Title + body only, no caption (empty fragment doesn't produce a child)
|
const distanceTexts = texts.filter((el) =>
|
||||||
const children = root.children!.filter((key) => {
|
(el.props.content as string).startsWith("Nearest station:"),
|
||||||
const el = spec.elements[key]
|
)
|
||||||
return el && el.type !== "Fragment"
|
expect(distanceTexts).toHaveLength(0)
|
||||||
})
|
|
||||||
expect(children).toHaveLength(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders closure severity label", () => {
|
test("renders closure severity label", () => {
|
||||||
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
|
const node = renderTflStatus(
|
||||||
|
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
|
||||||
|
)
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const title = spec.elements[root.children![0]!]!
|
const title = texts.find((el) => el.props.content === "Central · Closed")
|
||||||
expect(title.props.content).toBe("Central · Closed")
|
expect(title).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders major delays severity label", () => {
|
test("renders major delays severity label", () => {
|
||||||
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
|
const node = renderTflStatus(
|
||||||
|
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
|
||||||
|
)
|
||||||
const spec = render(node)
|
const spec = render(node)
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
const texts = collectTextElements(spec)
|
||||||
const title = spec.elements[root.children![0]!]!
|
const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
|
||||||
expect(title.props.content).toBe("Jubilee · Major delays")
|
expect(title).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FeedItemRenderer } from "@aelis/core"
|
|||||||
|
|
||||||
import { FeedCard, SansSerifText } from "@aelis/components"
|
import { FeedCard, SansSerifText } from "@aelis/components"
|
||||||
|
|
||||||
import type { TflAlertData } from "./types.ts"
|
import type { TflAlertData, TflStatusData } from "./types.ts"
|
||||||
|
|
||||||
import { TflAlertSeverity } from "./types.ts"
|
import { TflAlertSeverity } from "./types.ts"
|
||||||
|
|
||||||
@@ -21,20 +21,26 @@ function formatDistance(km: number): string {
|
|||||||
return `${(meters / 1000).toFixed(1)}km away`
|
return `${(meters / 1000).toFixed(1)}km away`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
|
function renderAlertRow(alert: TflAlertData) {
|
||||||
const { lineName, severity, description, closestStationDistance } = item.data
|
const severityLabel = SEVERITY_LABEL[alert.severity]
|
||||||
const severityLabel = SEVERITY_LABEL[severity]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedCard>
|
<>
|
||||||
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
|
<SansSerifText
|
||||||
<SansSerifText content={description} style="text-sm" />
|
content={`${alert.lineName} · ${severityLabel}`}
|
||||||
{closestStationDistance !== null ? (
|
style="text-base font-semibold"
|
||||||
|
/>
|
||||||
|
<SansSerifText content={alert.description} style="text-sm" />
|
||||||
|
{alert.closestStationDistance !== null ? (
|
||||||
<SansSerifText
|
<SansSerifText
|
||||||
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
|
content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
|
||||||
style="text-xs text-stone-500"
|
style="text-xs text-stone-500"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</FeedCard>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => {
|
||||||
|
return <FeedCard>{item.data.alerts.map((alert) => renderAlertRow(alert))}</FeedCard>
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,13 +138,15 @@ describe("TflSource", () => {
|
|||||||
test("changes which lines are fetched", async () => {
|
test("changes which lines are fetched", async () => {
|
||||||
const source = new TflSource({ client: lineFilteringApi })
|
const source = new TflSource({ client: lineFilteringApi })
|
||||||
const before = await source.fetchItems(createContext())
|
const before = await source.fetchItems(createContext())
|
||||||
expect(before.length).toBe(2)
|
expect(before).toHaveLength(1)
|
||||||
|
expect(before[0]!.data.alerts).toHaveLength(2)
|
||||||
|
|
||||||
source.setLinesOfInterest(["northern"])
|
source.setLinesOfInterest(["northern"])
|
||||||
const after = await source.fetchItems(createContext())
|
const after = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(after.length).toBe(1)
|
expect(after).toHaveLength(1)
|
||||||
expect(after[0]!.data.line).toBe("northern")
|
expect(after[0]!.data.alerts).toHaveLength(1)
|
||||||
|
expect(after[0]!.data.alerts[0]!.line).toBe("northern")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
||||||
@@ -153,23 +155,52 @@ describe("TflSource", () => {
|
|||||||
lines: ["northern"],
|
lines: ["northern"],
|
||||||
})
|
})
|
||||||
const filtered = await source.fetchItems(createContext())
|
const filtered = await source.fetchItems(createContext())
|
||||||
expect(filtered.length).toBe(1)
|
expect(filtered[0]!.data.alerts).toHaveLength(1)
|
||||||
|
|
||||||
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
||||||
const all = await source.fetchItems(createContext())
|
const all = await source.fetchItems(createContext())
|
||||||
|
|
||||||
expect(all.length).toBe(2)
|
expect(all[0]!.data.alerts).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("fetchItems", () => {
|
describe("fetchItems", () => {
|
||||||
test("returns feed items array", async () => {
|
test("returns at most one feed item", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(Array.isArray(items)).toBe(true)
|
expect(items).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed items have correct base structure", async () => {
|
test("returns empty array when no disruptions", async () => {
|
||||||
|
const emptyApi: ITflApi = {
|
||||||
|
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const source = new TflSource({ client: emptyApi })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
expect(items).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("combined item has correct base structure", async () => {
|
||||||
|
const source = new TflSource({ client: api })
|
||||||
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.id).toBe("tfl-status")
|
||||||
|
expect(item.type).toBe("tfl-status")
|
||||||
|
expect(item.sourceId).toBe("aelis.tfl")
|
||||||
|
expect(item.signals).toBeDefined()
|
||||||
|
expect(typeof item.signals!.urgency).toBe("number")
|
||||||
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
|
expect(Array.isArray(item.data.alerts)).toBe(true)
|
||||||
|
expect(item.data.alerts.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("alerts have correct data structure", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const location: Location = {
|
const location: Location = {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
@@ -178,72 +209,140 @@ describe("TflSource", () => {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
const alerts = items[0]!.data.alerts
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of alerts) {
|
||||||
expect(typeof item.id).toBe("string")
|
expect(typeof alert.line).toBe("string")
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
expect(typeof alert.lineName).toBe("string")
|
||||||
expect(item.type).toBe("tfl-alert")
|
expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
|
||||||
expect(item.signals).toBeDefined()
|
expect(typeof alert.description).toBe("string")
|
||||||
expect(typeof item.signals!.urgency).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(
|
expect(
|
||||||
item.data.closestStationDistance === null ||
|
alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
|
||||||
typeof item.data.closestStationDistance === "number",
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed item ids are unique", async () => {
|
test("signals use highest severity urgency", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const mixedApi: ITflApi = {
|
||||||
|
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
lineId: "northern",
|
||||||
|
lineName: "Northern",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Minor delays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: "central",
|
||||||
|
lineName: "Central",
|
||||||
|
severity: "closure",
|
||||||
|
description: "Closed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: "jubilee",
|
||||||
|
lineName: "Jubilee",
|
||||||
|
severity: "major-delays",
|
||||||
|
description: "Major delays",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const source = new TflSource({ client: mixedApi })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const ids = items.map((item) => item.id)
|
expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
|
||||||
const uniqueIds = new Set(ids)
|
expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed items are sorted by urgency descending", async () => {
|
test("signals use single alert severity when only one disruption", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const singleApi: ITflApi = {
|
||||||
|
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
lineId: "northern",
|
||||||
|
lineName: "Northern",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Minor delays",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const source = new TflSource({ client: singleApi })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
|
||||||
const prev = items[i - 1]!
|
expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
|
||||||
const curr = items[i]!
|
|
||||||
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("urgency values match severity levels", async () => {
|
test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const distanceApi: ITflApi = {
|
||||||
const items = await source.fetchItems(createContext())
|
async fetchLineStatuses(): Promise<TflLineStatus[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
lineId: "northern",
|
||||||
|
lineName: "Northern",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Delays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: "central",
|
||||||
|
lineName: "Central",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Delays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: "jubilee",
|
||||||
|
lineName: "Jubilee",
|
||||||
|
severity: "minor-delays",
|
||||||
|
description: "Delays",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
async fetchStations(): Promise<StationLocation[]> {
|
||||||
|
return [
|
||||||
|
{ id: "s1", name: "Station A", lat: 51.51, lng: -0.13, lines: ["central"] },
|
||||||
|
{ id: "s2", name: "Station B", lat: 51.52, lng: -0.14, lines: ["northern"] },
|
||||||
|
// No stations for jubilee — its distance will be null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const source = new TflSource({ client: distanceApi })
|
||||||
|
const location: Location = {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
const alerts = items[0]!.data.alerts
|
||||||
|
|
||||||
const severityUrgency: Record<string, number> = {
|
// Alerts with distances should come before nulls
|
||||||
closure: 1.0,
|
const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
|
||||||
"major-delays": 0.8,
|
const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
|
||||||
"minor-delays": 0.6,
|
|
||||||
|
// All distance alerts come first
|
||||||
|
const firstNullIndex = alerts.findIndex((a) => a.closestStationDistance === null)
|
||||||
|
if (firstNullIndex !== -1) {
|
||||||
|
for (let i = 0; i < firstNullIndex; i++) {
|
||||||
|
expect(alerts[i]!.closestStationDistance).not.toBeNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
// Distance alerts are in ascending order
|
||||||
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
for (let i = 1; i < withDistance.length; i++) {
|
||||||
|
expect(withDistance[i]!.closestStationDistance!).toBeGreaterThanOrEqual(
|
||||||
|
withDistance[i - 1]!.closestStationDistance!,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(withoutDistance.length).toBe(1)
|
||||||
|
expect(withoutDistance[0]!.line).toBe("jubilee")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closestStationDistance is number when location provided", async () => {
|
test("closestStationDistance is number when location provided", async () => {
|
||||||
@@ -256,9 +355,9 @@ describe("TflSource", () => {
|
|||||||
}
|
}
|
||||||
const items = await source.fetchItems(createContext(location))
|
const items = await source.fetchItems(createContext(location))
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of items[0]!.data.alerts) {
|
||||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
expect(typeof alert.closestStationDistance).toBe("number")
|
||||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
expect(alert.closestStationDistance!).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,8 +365,8 @@ describe("TflSource", () => {
|
|||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (const item of items) {
|
for (const alert of items[0]!.data.alerts) {
|
||||||
expect(item.data.closestStationDistance).toBeNull()
|
expect(alert.closestStationDistance).toBeNull()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -309,8 +408,9 @@ describe("TflSource", () => {
|
|||||||
await source.executeAction("set-lines-of-interest", ["northern"])
|
await source.executeAction("set-lines-of-interest", ["northern"])
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
expect(items.length).toBe(1)
|
expect(items).toHaveLength(1)
|
||||||
expect(items[0]!.data.line).toBe("northern")
|
expect(items[0]!.data.alerts).toHaveLength(1)
|
||||||
|
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("executeAction throws on invalid input", async () => {
|
test("executeAction throws on invalid input", async () => {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import type {
|
|||||||
ITflApi,
|
ITflApi,
|
||||||
StationLocation,
|
StationLocation,
|
||||||
TflAlertData,
|
TflAlertData,
|
||||||
TflAlertFeedItem,
|
|
||||||
TflAlertSeverity,
|
TflAlertSeverity,
|
||||||
TflLineId,
|
TflLineId,
|
||||||
TflSourceOptions,
|
TflSourceOptions,
|
||||||
|
TflStatusFeedItem,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, lineId } from "./tfl-api.ts"
|
import { TflApi, lineId } from "./tfl-api.ts"
|
||||||
@@ -51,7 +51,7 @@ const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
|||||||
* const { items } = await engine.refresh()
|
* const { items } = await engine.refresh()
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
export class TflSource implements FeedSource<TflStatusFeedItem> {
|
||||||
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
||||||
"bakerloo",
|
"bakerloo",
|
||||||
"central",
|
"central",
|
||||||
@@ -123,56 +123,58 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
this.lines = lines
|
this.lines = lines
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
|
||||||
const [statuses, stations] = await Promise.all([
|
const [statuses, stations] = await Promise.all([
|
||||||
this.client.fetchLineStatuses(this.lines),
|
this.client.fetchLineStatuses(this.lines),
|
||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const location = context.get(LocationKey)
|
const location = context.get(LocationKey)
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
const alerts: TflAlertData[] = statuses.map((status) => ({
|
||||||
const closestStationDistance = location
|
line: status.lineId,
|
||||||
|
lineName: status.lineName,
|
||||||
|
severity: status.severity,
|
||||||
|
description: status.description,
|
||||||
|
closestStationDistance: location
|
||||||
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||||
: null
|
: null,
|
||||||
|
}))
|
||||||
|
|
||||||
const data: TflAlertData = {
|
// Sort by closest station distance ascending, nulls last
|
||||||
line: status.lineId,
|
alerts.sort((a, b) => {
|
||||||
lineName: status.lineName,
|
if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
|
||||||
severity: status.severity,
|
if (a.closestStationDistance === null) return 1
|
||||||
description: status.description,
|
if (b.closestStationDistance === null) return -1
|
||||||
closestStationDistance,
|
return a.closestStationDistance - b.closestStationDistance
|
||||||
}
|
})
|
||||||
|
|
||||||
const signals: FeedItemSignals = {
|
// Signals from the highest-severity alert
|
||||||
urgency: SEVERITY_URGENCY[status.severity],
|
const highestSeverity = alerts.reduce<TflAlertSeverity>(
|
||||||
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
(worst, alert) =>
|
||||||
}
|
SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
|
||||||
|
alerts[0]!.severity,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
const signals: FeedItemSignals = {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
urgency: SEVERITY_URGENCY[highestSeverity],
|
||||||
|
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "tfl-status",
|
||||||
sourceId: this.id,
|
sourceId: this.id,
|
||||||
type: TflFeedItemType.Alert,
|
type: TflFeedItemType.Status,
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data: { alerts },
|
||||||
signals,
|
signals,
|
||||||
}
|
},
|
||||||
})
|
]
|
||||||
|
|
||||||
// Sort by urgency (desc), then by proximity (asc) if location available
|
|
||||||
items.sort((a, b) => {
|
|
||||||
const aUrgency = a.signals?.urgency ?? 0
|
|
||||||
const bUrgency = b.signals?.urgency ?? 0
|
|
||||||
if (bUrgency !== aUrgency) {
|
|
||||||
return bUrgency - aUrgency
|
|
||||||
}
|
|
||||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
|
||||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,19 @@ export interface TflAlertData extends Record<string, unknown> {
|
|||||||
|
|
||||||
export const TflFeedItemType = {
|
export const TflFeedItemType = {
|
||||||
Alert: "tfl-alert",
|
Alert: "tfl-alert",
|
||||||
|
Status: "tfl-status",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
||||||
|
|
||||||
|
export interface TflStatusData extends Record<string, unknown> {
|
||||||
|
alerts: TflAlertData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TflStatusFeedItem = FeedItem<typeof TflFeedItemType.Status, TflStatusData>
|
||||||
|
|
||||||
export interface TflSourceOptions {
|
export interface TflSourceOptions {
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
client?: ITflApi
|
client?: ITflApi
|
||||||
|
|||||||
Reference in New Issue
Block a user