From 286a933d1e9c163dc058e003407f92ff74a1002c Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 18 Jan 2026 23:45:05 +0000 Subject: [PATCH] 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 --- .../aris-core/src/feed-source.example.txt | 134 ------ packages/aris-core/src/feed-source.test.ts | 422 ++++++++++++++++++ 2 files changed, 422 insertions(+), 134 deletions(-) delete mode 100644 packages/aris-core/src/feed-source.example.txt create mode 100644 packages/aris-core/src/feed-source.test.ts diff --git a/packages/aris-core/src/feed-source.example.txt b/packages/aris-core/src/feed-source.example.txt deleted file mode 100644 index a291d64..0000000 --- a/packages/aris-core/src/feed-source.example.txt +++ /dev/null @@ -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 = contextKey("location") -const WeatherKey: ContextKey = 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((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 = { - 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 { - return { temperature: 20, condition: "sunny" } -} - -export { locationSource, weatherSource } diff --git a/packages/aris-core/src/feed-source.test.ts b/packages/aris-core/src/feed-source.test.ts new file mode 100644 index 0000000..3f81e0f --- /dev/null +++ b/packages/aris-core/src/feed-source.test.ts @@ -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 = 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", + + 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"], + + 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 { + 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 + 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) { + 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 + }) + }) +})