import { describe, expect, test } from "bun:test" import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index" import { UnknownActionError, contextKey, contextValue } from "./index" // No-op action methods for test sources const noActions = { async listActions(): Promise> { return {} }, async executeAction(actionId: string): Promise { throw new UnknownActionError(actionId) }, } // ============================================================================= // CONTEXT KEYS // ============================================================================= interface Location { lat: number lng: number } interface Weather { temperature: number condition: string } const LocationKey: ContextKey = contextKey("location") const WeatherKey: ContextKey = 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) => void) | null = null let currentLocation: Location = { lat: 0, lng: 0 } return { id: "location", ...noActions, 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 = async () => ({ temperature: 20, condition: "sunny", }), ): FeedSource { return { id: "weather", dependencies: ["location"], ...noActions, async fetchContext(context) { const location = contextValue(context, LocationKey) if (!location) return null 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 { return { id: "alert", dependencies: ["weather"], ...noActions, async fetchContext() { return null }, 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 sorted: FeedSource[] dependents: Map } function buildGraph(sources: FeedSource[]): SourceGraph { const byId = new Map() 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() const visiting = new Set() 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() 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) { const update = await source.fetchContext(context) if (update) { 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).toBeDefined() expect(source.fetchItems).toBeDefined() }) test("source without context returns null from fetchContext", async () => { const source = createAlertSource() const result = await source.fetchContext({ time: new Date() }) expect(result).toBeNull() }) }) describe("graph validation", () => { test("validates all dependencies exist", () => { const orphan: FeedSource = { id: "orphan", dependencies: ["nonexistent"], ...noActions, async fetchContext() { return null }, } expect(() => buildGraph([orphan])).toThrow( 'Source "orphan" depends on "nonexistent" which is not registered', ) }) test("detects circular dependencies", () => { const a: FeedSource = { id: "a", dependencies: ["b"], ...noActions, async fetchContext() { return null }, } const b: FeedSource = { id: "b", dependencies: ["a"], ...noActions, async fetchContext() { return null }, } expect(() => buildGraph([a, b])).toThrow("Circular dependency detected: a → b → a") }) test("detects longer cycles", () => { const a: FeedSource = { id: "a", dependencies: ["c"], ...noActions, async fetchContext() { return null }, } const b: FeedSource = { id: "b", dependencies: ["a"], ...noActions, async fetchContext() { return null }, } const c: FeedSource = { id: "c", dependencies: ["b"], ...noActions, async fetchContext() { return null }, } 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", ...noActions, async fetchContext() { order.push("location") return { [LocationKey]: { lat: 51.5, lng: -0.1 } } }, } const weather: FeedSource = { id: "weather", dependencies: ["location"], ...noActions, 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 const location: FeedSource = { id: "location", ...noActions, async fetchContext() { // Simulate no location available return null }, } 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 }) }) })