Files
aris/packages/aris-source-location/src/location-source.test.ts
kenneth 699155e0d8 feat: add actions to FeedSource interface
Add listActions() and executeAction() to FeedSource for write
operations back to external services. Actions use arktype schemas
for input validation via StandardSchemaV1.

- ActionDefinition type with optional input schema
- FeedEngine routes actions with existence and ID validation
- Source IDs use reverse-domain format (aris.location, aris.tfl)
- LocationSource: update-location action with schema validation
- TflSource: set-lines-of-interest action with lineId validation
- No-op implementations for sources without actions

Co-authored-by: Ona <no-reply@ona.com>
2026-02-15 12:53:10 +00:00

187 lines
5.0 KiB
TypeScript

import { describe, expect, mock, test } from "bun:test"
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location {
return {
lat: 37.7749,
lng: -122.4194,
accuracy: 10,
timestamp: new Date(),
...overrides,
}
}
describe("LocationSource", () => {
describe("FeedSource interface", () => {
test("has correct id", () => {
const source = new LocationSource()
expect(source.id).toBe("aris.location")
})
test("fetchItems always returns empty array", async () => {
const source = new LocationSource()
source.pushLocation(createLocation())
const items = await source.fetchItems()
expect(items).toEqual([])
})
test("fetchContext returns null when no location", async () => {
const source = new LocationSource()
const context = await source.fetchContext()
expect(context).toBeNull()
})
test("fetchContext returns location when available", async () => {
const source = new LocationSource()
const location = createLocation()
source.pushLocation(location)
const context = await source.fetchContext()
expect(context).toEqual({ [LocationKey]: location })
})
})
describe("pushLocation", () => {
test("updates lastLocation", () => {
const source = new LocationSource()
expect(source.lastLocation).toBeNull()
const location = createLocation()
source.pushLocation(location)
expect(source.lastLocation).toEqual(location)
})
test("notifies listeners", () => {
const source = new LocationSource()
const listener = mock()
source.onContextUpdate(listener)
const location = createLocation()
source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
})
})
describe("history", () => {
test("default historySize is 1", () => {
const source = new LocationSource()
source.pushLocation(createLocation({ lat: 1 }))
source.pushLocation(createLocation({ lat: 2 }))
expect(source.locationHistory).toHaveLength(1)
expect(source.lastLocation?.lat).toBe(2)
})
test("respects configured historySize", () => {
const source = new LocationSource({ historySize: 3 })
const loc1 = createLocation({ lat: 1 })
const loc2 = createLocation({ lat: 2 })
const loc3 = createLocation({ lat: 3 })
source.pushLocation(loc1)
source.pushLocation(loc2)
source.pushLocation(loc3)
expect(source.locationHistory).toEqual([loc1, loc2, loc3])
})
test("evicts oldest when exceeding historySize", () => {
const source = new LocationSource({ historySize: 2 })
const loc1 = createLocation({ lat: 1 })
const loc2 = createLocation({ lat: 2 })
const loc3 = createLocation({ lat: 3 })
source.pushLocation(loc1)
source.pushLocation(loc2)
source.pushLocation(loc3)
expect(source.locationHistory).toEqual([loc2, loc3])
})
test("locationHistory is readonly", () => {
const source = new LocationSource({ historySize: 3 })
source.pushLocation(createLocation())
const history = source.locationHistory
expect(Array.isArray(history)).toBe(true)
})
})
describe("onContextUpdate", () => {
test("returns cleanup function", () => {
const source = new LocationSource()
const listener = mock()
const cleanup = source.onContextUpdate(listener)
source.pushLocation(createLocation({ lat: 1 }))
expect(listener).toHaveBeenCalledTimes(1)
cleanup()
source.pushLocation(createLocation({ lat: 2 }))
expect(listener).toHaveBeenCalledTimes(1)
})
test("supports multiple listeners", () => {
const source = new LocationSource()
const listener1 = mock()
const listener2 = mock()
source.onContextUpdate(listener1)
source.onContextUpdate(listener2)
source.pushLocation(createLocation())
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
})
describe("actions", () => {
test("listActions returns update-location action", async () => {
const source = new LocationSource()
const actions = await source.listActions()
expect(actions["update-location"]).toBeDefined()
expect(actions["update-location"]!.id).toBe("update-location")
expect(actions["update-location"]!.input).toBeDefined()
})
test("executeAction update-location pushes location", async () => {
const source = new LocationSource()
expect(source.lastLocation).toBeNull()
const location = createLocation({ lat: 40.7128, lng: -74.006 })
await source.executeAction("update-location", location)
expect(source.lastLocation).toEqual(location)
})
test("executeAction throws on invalid input", async () => {
const source = new LocationSource()
await expect(
source.executeAction("update-location", { lat: "not a number" }),
).rejects.toThrow()
})
test("executeAction throws for unknown action", async () => {
const source = new LocationSource()
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
})
})
})