test(core): add FeedSource integration tests

Tests graph validation (dependency existence, cycle detection, topological
sort) and refresh behavior (context accumulation, item collection).

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-01-18 23:45:05 +00:00
parent 1d9de2851a
commit 286a933d1e
2 changed files with 422 additions and 134 deletions

View File

@@ -1,134 +0,0 @@
/**
* Example wiring of FeedSource graph.
* NOT for documentation - just to visualize the interface.
*/
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
import { contextKey, contextValue } from "./index"
// ============================================================================
// Context Keys - exported by each package
// ============================================================================
interface Location {
lat: number
lng: number
}
interface Weather {
temperature: number
condition: string
}
const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")
// ============================================================================
// Feed Items
// ============================================================================
interface WeatherFeedItem extends FeedItem<"weather", { temperature: number; condition: string }> {}
// ============================================================================
// Sources
// ============================================================================
// Location source - context only, no feed items
const locationSource: FeedSource = {
id: "location",
onContextUpdate(callback, _getContext) {
// Reactive: browser pushes location changes
const watchId = navigator.geolocation.watchPosition((pos) => {
callback({
[LocationKey]: {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
},
})
})
return () => navigator.geolocation.clearWatch(watchId)
},
async fetchContext(_context) {
// On-demand: manual refresh
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
return {
[LocationKey]: {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
},
}
},
}
// Weather source - depends on location, provides context + feed items
const weatherSource: FeedSource<WeatherFeedItem> = {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
// Fetch weather from API
const weather = await fetchWeatherFromApi(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
},
]
},
}
// ============================================================================
// Graph wiring (conceptual - FeedSourceGraph not yet implemented)
// ============================================================================
// const graph = new FeedSourceGraph([
// locationSource,
// weatherSource,
// ])
//
// // Graph validates:
// // - All dependencies exist
// // - No circular dependencies
// // - Topologically sorts sources
//
// // On refresh:
// // 1. fetchContext on location (no deps)
// // 2. fetchContext on weather (has location in context now)
// // 3. fetchItems on all sources
// // 4. Return combined feed items
//
// // On reactive update from location:
// // 1. Update context with new location
// // 2. Trigger weather.fetchContext (it depends on location)
// // 3. Trigger weather.fetchItems
// // 4. Notify subscribers
// ============================================================================
// Helpers (mock)
// ============================================================================
async function fetchWeatherFromApi(_location: Location): Promise<Weather> {
return { temperature: 20, condition: "sunny" }
}
export { locationSource, weatherSource }

View File

@@ -0,0 +1,422 @@
import { describe, expect, test } from "bun:test"
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
import { contextKey, contextValue } from "./index"
// =============================================================================
// CONTEXT KEYS
// =============================================================================
interface Location {
lat: number
lng: number
}
interface Weather {
temperature: number
condition: string
}
const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherFeedItem = FeedItem<"weather", { temperature: number; condition: string }>
type AlertFeedItem = FeedItem<"alert", { message: string }>
// =============================================================================
// TEST HELPERS
// =============================================================================
interface SimulatedLocationSource extends FeedSource {
simulateUpdate(location: Location): void
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
id: "location",
onContextUpdate(cb) {
callback = cb
return () => {
callback = null
}
},
async fetchContext() {
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
},
}
}
function createWeatherSource(
fetchWeather: (location: Location) => Promise<Weather> = async () => ({
temperature: 20,
condition: "sunny",
}),
): FeedSource<WeatherFeedItem> {
return {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
},
]
},
}
}
function createAlertSource(): FeedSource<AlertFeedItem> {
return {
id: "alert",
dependencies: ["weather"],
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
return [
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
},
]
}
return []
},
}
}
// =============================================================================
// GRAPH SIMULATION (until FeedController is updated)
// =============================================================================
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
dependents: Map<string, string[]>
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {
byId.set(source.id, source)
}
// Validate dependencies exist
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
if (!byId.has(dep)) {
throw new Error(`Source "${source.id}" depends on "${dep}" which is not registered`)
}
}
}
// Check for cycles and topologically sort
const visited = new Set<string>()
const visiting = new Set<string>()
const sorted: FeedSource[] = []
function visit(id: string, path: string[]): void {
if (visiting.has(id)) {
const cycle = [...path.slice(path.indexOf(id)), id].join(" → ")
throw new Error(`Circular dependency detected: ${cycle}`)
}
if (visited.has(id)) return
visiting.add(id)
const source = byId.get(id)!
for (const dep of source.dependencies ?? []) {
visit(dep, [...path, id])
}
visiting.delete(id)
visited.add(id)
sorted.push(source)
}
for (const source of sources) {
visit(source.id, [])
}
// Build reverse dependency map
const dependents = new Map<string, string[]>()
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
const list = dependents.get(dep) ?? []
list.push(source.id)
dependents.set(dep, list)
}
}
return { sources: byId, sorted, dependents }
}
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
if (source.fetchContext) {
const update = await source.fetchContext(context)
context = { ...context, ...update }
}
}
// Run fetchItems on all sources
const items: FeedItem[] = []
for (const source of graph.sorted) {
if (source.fetchItems) {
const sourceItems = await source.fetchItems(context)
items.push(...sourceItems)
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items }
}
// =============================================================================
// TESTS
// =============================================================================
describe("FeedSource", () => {
describe("interface", () => {
test("source with only context production", () => {
const source = createLocationSource()
expect(source.id).toBe("location")
expect(source.dependencies).toBeUndefined()
expect(source.fetchContext).toBeDefined()
expect(source.onContextUpdate).toBeDefined()
expect(source.fetchItems).toBeUndefined()
})
test("source with dependencies and both context and items", () => {
const source = createWeatherSource()
expect(source.id).toBe("weather")
expect(source.dependencies).toEqual(["location"])
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source with only item production", () => {
const source = createAlertSource()
expect(source.id).toBe("alert")
expect(source.dependencies).toEqual(["weather"])
expect(source.fetchContext).toBeUndefined()
expect(source.fetchItems).toBeDefined()
})
})
describe("graph validation", () => {
test("validates all dependencies exist", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
}
expect(() => buildGraph([orphan])).toThrow(
'Source "orphan" depends on "nonexistent" which is not registered',
)
})
test("detects circular dependencies", () => {
const a: FeedSource = { id: "a", dependencies: ["b"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
expect(() => buildGraph([a, b])).toThrow("Circular dependency detected: a → b → a")
})
test("detects longer cycles", () => {
const a: FeedSource = { id: "a", dependencies: ["c"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const c: FeedSource = { id: "c", dependencies: ["b"] }
expect(() => buildGraph([a, b, c])).toThrow("Circular dependency detected")
})
test("topologically sorts sources", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
// Register in wrong order
const graph = buildGraph([alert, weather, location])
expect(graph.sorted.map((s) => s.id)).toEqual(["location", "weather", "alert"])
})
test("builds reverse dependency map", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
expect(graph.dependents.get("location")).toEqual(["weather"])
expect(graph.dependents.get("weather")).toEqual(["alert"])
expect(graph.dependents.get("alert")).toBeUndefined()
})
})
describe("graph refresh", () => {
test("runs fetchContext in dependency order", async () => {
const order: string[] = []
const location: FeedSource = {
id: "location",
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
const graph = buildGraph([weather, location])
await refreshGraph(graph)
expect(order).toEqual(["location", "weather"])
})
test("accumulates context across sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph)
expect(contextValue(context, LocationKey)).toEqual({ lat: 51.5, lng: -0.1 })
expect(contextValue(context, WeatherKey)).toEqual({ temperature: 20, condition: "sunny" })
})
test("collects items from all sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("weather")
})
test("downstream source receives upstream context", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource(async () => ({
temperature: 15,
condition: "storm",
}))
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated (returns default 0,0)
const location: FeedSource = {
id: "location",
async fetchContext() {
// Simulate no location available
return {}
},
}
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph)
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
})
describe("reactive updates", () => {
test("onContextUpdate receives callback and returns cleanup", () => {
const location = createLocationSource()
let updateCount = 0
const cleanup = location.onContextUpdate!(
() => {
updateCount++
},
() => ({ time: new Date() }),
)
location.simulateUpdate({ lat: 1, lng: 1 })
expect(updateCount).toBe(1)
location.simulateUpdate({ lat: 2, lng: 2 })
expect(updateCount).toBe(2)
cleanup()
location.simulateUpdate({ lat: 3, lng: 3 })
expect(updateCount).toBe(2) // no more updates after cleanup
})
})
})