@affectively/aeon-flux 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +438 -0
  2. package/examples/basic/aeon.config.ts +39 -0
  3. package/examples/basic/components/Cursor.tsx +88 -0
  4. package/examples/basic/components/OfflineIndicator.tsx +93 -0
  5. package/examples/basic/components/PresenceBar.tsx +68 -0
  6. package/examples/basic/package.json +20 -0
  7. package/examples/basic/pages/index.tsx +73 -0
  8. package/package.json +90 -0
  9. package/packages/benchmarks/src/benchmark.test.ts +644 -0
  10. package/packages/cli/package.json +43 -0
  11. package/packages/cli/src/commands/build.test.ts +649 -0
  12. package/packages/cli/src/commands/build.ts +853 -0
  13. package/packages/cli/src/commands/dev.ts +463 -0
  14. package/packages/cli/src/commands/init.ts +395 -0
  15. package/packages/cli/src/commands/start.ts +289 -0
  16. package/packages/cli/src/index.ts +102 -0
  17. package/packages/directives/src/use-aeon.ts +266 -0
  18. package/packages/react/package.json +34 -0
  19. package/packages/react/src/Link.tsx +355 -0
  20. package/packages/react/src/hooks/useAeonNavigation.ts +204 -0
  21. package/packages/react/src/hooks/usePilotNavigation.ts +253 -0
  22. package/packages/react/src/hooks/useServiceWorker.ts +276 -0
  23. package/packages/react/src/hooks.ts +192 -0
  24. package/packages/react/src/index.ts +89 -0
  25. package/packages/react/src/provider.tsx +428 -0
  26. package/packages/runtime/package.json +70 -0
  27. package/packages/runtime/schema.sql +40 -0
  28. package/packages/runtime/src/api-routes.ts +453 -0
  29. package/packages/runtime/src/benchmark.ts +145 -0
  30. package/packages/runtime/src/cache.ts +287 -0
  31. package/packages/runtime/src/durable-object.ts +847 -0
  32. package/packages/runtime/src/index.ts +235 -0
  33. package/packages/runtime/src/navigation.test.ts +432 -0
  34. package/packages/runtime/src/navigation.ts +412 -0
  35. package/packages/runtime/src/nextjs-adapter.ts +254 -0
  36. package/packages/runtime/src/predictor.ts +368 -0
  37. package/packages/runtime/src/registry.ts +339 -0
  38. package/packages/runtime/src/router/context-extractor.ts +394 -0
  39. package/packages/runtime/src/router/esi-control-react.tsx +1172 -0
  40. package/packages/runtime/src/router/esi-control.ts +488 -0
  41. package/packages/runtime/src/router/esi-react.tsx +600 -0
  42. package/packages/runtime/src/router/esi.ts +595 -0
  43. package/packages/runtime/src/router/heuristic-adapter.test.ts +272 -0
  44. package/packages/runtime/src/router/heuristic-adapter.ts +544 -0
  45. package/packages/runtime/src/router/index.ts +158 -0
  46. package/packages/runtime/src/router/speculation.ts +442 -0
  47. package/packages/runtime/src/router/types.ts +514 -0
  48. package/packages/runtime/src/router.test.ts +466 -0
  49. package/packages/runtime/src/router.ts +285 -0
  50. package/packages/runtime/src/server.ts +446 -0
  51. package/packages/runtime/src/service-worker.ts +418 -0
  52. package/packages/runtime/src/speculation.test.ts +360 -0
  53. package/packages/runtime/src/speculation.ts +456 -0
  54. package/packages/runtime/src/storage.test.ts +1201 -0
  55. package/packages/runtime/src/storage.ts +1031 -0
  56. package/packages/runtime/src/tree-compiler.ts +252 -0
  57. package/packages/runtime/src/types.ts +444 -0
  58. package/packages/runtime/src/worker.ts +300 -0
  59. package/packages/runtime/tsconfig.json +19 -0
  60. package/packages/runtime/wrangler.toml +41 -0
  61. package/packages/runtime-wasm/Cargo.lock +436 -0
  62. package/packages/runtime-wasm/Cargo.toml +29 -0
  63. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +328 -0
  64. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1267 -0
  65. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  66. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +73 -0
  67. package/packages/runtime-wasm/pkg/package.json +21 -0
  68. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  69. package/packages/runtime-wasm/src/lib.rs +189 -0
  70. package/packages/runtime-wasm/src/render.rs +629 -0
  71. package/packages/runtime-wasm/src/router.rs +298 -0
  72. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @affectively/aeon-pages-runtime
3
+ *
4
+ * Lightweight runtime for Aeon Pages - the CMS IS the website.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { createAeonServer } from '@affectively/aeon-pages-runtime/server';
9
+ *
10
+ * const server = await createAeonServer({
11
+ * config: {
12
+ * pagesDir: './pages',
13
+ * runtime: 'bun',
14
+ * aeon: {
15
+ * sync: { mode: 'distributed' },
16
+ * presence: { enabled: true },
17
+ * },
18
+ * },
19
+ * });
20
+ *
21
+ * console.log(`Aeon Pages running on port ${server.port}`);
22
+ * ```
23
+ */
24
+
25
+ // Core exports
26
+ export { createAeonServer } from './server';
27
+ export { AeonRouter } from './router.js';
28
+ export { AeonRouteRegistry } from './registry';
29
+
30
+ // Navigation engine (cutting-edge navigation)
31
+ export {
32
+ AeonNavigationEngine,
33
+ getNavigator,
34
+ setNavigator,
35
+ } from './navigation';
36
+ export type {
37
+ NavigationOptions,
38
+ PrefetchOptions,
39
+ NavigationState,
40
+ } from './navigation';
41
+
42
+ // Navigation cache (total preload strategy)
43
+ export {
44
+ NavigationCache,
45
+ getNavigationCache,
46
+ setNavigationCache,
47
+ } from './cache';
48
+ export type {
49
+ CachedSession,
50
+ CacheStats,
51
+ NavigationCacheOptions,
52
+ } from './cache';
53
+
54
+ // Navigation predictor (ML-based)
55
+ export {
56
+ NavigationPredictor,
57
+ getPredictor,
58
+ setPredictor,
59
+ } from './predictor';
60
+ export type {
61
+ PredictedRoute,
62
+ NavigationRecord,
63
+ CommunityPattern,
64
+ PredictorConfig,
65
+ } from './predictor';
66
+
67
+ // Speculative pre-rendering (zero-latency navigation)
68
+ export {
69
+ SpeculativeRenderer,
70
+ getSpeculativeRenderer,
71
+ setSpeculativeRenderer,
72
+ initSpeculativeRendering,
73
+ } from './speculation';
74
+ export type {
75
+ PreRenderedPage,
76
+ SpeculativeRendererConfig,
77
+ } from './speculation';
78
+
79
+ // Storage adapters
80
+ export {
81
+ createStorageAdapter,
82
+ FileStorageAdapter,
83
+ D1StorageAdapter,
84
+ DurableObjectStorageAdapter,
85
+ HybridStorageAdapter,
86
+ DashStorageAdapter,
87
+ } from './storage';
88
+ export type { StorageAdapter } from './storage';
89
+
90
+ // Cloudflare Durable Object classes (for deployment)
91
+ export { AeonPageSession, AeonRoutesRegistry } from './durable-object';
92
+
93
+ // API Routes - server-side request handling
94
+ export {
95
+ ApiRouter,
96
+ createApiRouter,
97
+ // Response helpers
98
+ json,
99
+ redirect,
100
+ error,
101
+ notFound,
102
+ badRequest,
103
+ unauthorized,
104
+ forbidden,
105
+ // Middleware
106
+ composeMiddleware,
107
+ cors,
108
+ requireAuth,
109
+ rateLimit,
110
+ } from './api-routes';
111
+ export type { Middleware } from './api-routes';
112
+
113
+ // Worker factory
114
+ export { createAeonWorker } from './worker';
115
+ export type { AeonWorkerOptions } from './worker';
116
+
117
+ // Next.js adapter - run Next.js API routes on Cloudflare Workers
118
+ export {
119
+ adaptRequest,
120
+ adaptHandler,
121
+ adaptRouteModule,
122
+ NextResponse,
123
+ } from './nextjs-adapter';
124
+ export type {
125
+ NextRequest,
126
+ NextRouteHandler,
127
+ NextRouteModule,
128
+ } from './nextjs-adapter';
129
+
130
+ // Type exports
131
+ export type {
132
+ // Config types
133
+ AeonConfig,
134
+ AeonOptions,
135
+ SyncOptions,
136
+ VersioningOptions,
137
+ PresenceOptions,
138
+ OfflineOptions,
139
+ ComponentOptions,
140
+ OutputOptions,
141
+ // Route types
142
+ RouteDefinition,
143
+ RouteMatch,
144
+ RouteMetadata,
145
+ RouteOperation,
146
+ // Component types
147
+ SerializedComponent,
148
+ PageSession,
149
+ PresenceInfo,
150
+ PresenceUser,
151
+ AeonCapability,
152
+ // API Route types
153
+ HttpMethod,
154
+ AeonEnv,
155
+ AeonContext,
156
+ ExecutionContext,
157
+ ApiRouteHandler,
158
+ ApiRouteModule,
159
+ ApiRoute,
160
+ ApiRouteMatch,
161
+ ApiRouteSegment,
162
+ ServerRouteModule,
163
+ ServerLoaderResult,
164
+ ServerActionResult,
165
+ // Cloudflare binding types
166
+ D1Database,
167
+ D1PreparedStatement,
168
+ D1Result,
169
+ D1ExecResult,
170
+ KVNamespace,
171
+ DurableObjectNamespace,
172
+ DurableObjectId,
173
+ DurableObjectStub,
174
+ Ai,
175
+ } from './types';
176
+
177
+ // Personalized Router (hyperpersonalized routing)
178
+ export {
179
+ DEFAULT_ROUTER_CONFIG,
180
+ DEFAULT_ESI_CONFIG,
181
+ HeuristicAdapter,
182
+ EdgeWorkersESIProcessor,
183
+ extractUserContext,
184
+ createContextMiddleware,
185
+ setContextCookies,
186
+ addSpeculationHeaders,
187
+ esiInfer,
188
+ esiEmbed,
189
+ esiEmotion,
190
+ esiVision,
191
+ esiWithContext,
192
+ } from './router/index';
193
+ export type {
194
+ // User context
195
+ EmotionState,
196
+ Viewport,
197
+ ConnectionType,
198
+ UserTier,
199
+ UserContext,
200
+ // Route decision
201
+ ThemeMode,
202
+ LayoutDensity,
203
+ LayoutType,
204
+ SkeletonHints,
205
+ RouteDecision,
206
+ // Component tree
207
+ ComponentNode,
208
+ ComponentTree,
209
+ ComponentTreeSchema,
210
+ // Router adapter
211
+ RouterAdapter,
212
+ // Adapter config
213
+ HeuristicAdapterConfig,
214
+ TierFeatures,
215
+ SignalProcessor,
216
+ ContextExtractorOptions,
217
+ // Configuration
218
+ AIRouterConfig,
219
+ SpeculationConfig,
220
+ PersonalizationConfig,
221
+ RouterConfig,
222
+ RouterConfigWithESI,
223
+ // ESI
224
+ ESIModel,
225
+ ESIContentType,
226
+ ESIParams,
227
+ ESIContent,
228
+ ESIDirective,
229
+ ESIResult,
230
+ ESIProcessor,
231
+ ESIConfig,
232
+ } from './router/index';
233
+
234
+ // Version
235
+ export const VERSION = '0.2.0';
@@ -0,0 +1,432 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import {
3
+ NavigationCache,
4
+ getNavigationCache,
5
+ setNavigationCache,
6
+ type CachedSession,
7
+ } from './cache';
8
+ import {
9
+ AeonNavigationEngine,
10
+ getNavigator,
11
+ setNavigator,
12
+ } from './navigation';
13
+ import {
14
+ NavigationPredictor,
15
+ getPredictor,
16
+ setPredictor,
17
+ type NavigationRecord,
18
+ } from './predictor';
19
+ import { AeonRouter } from './router';
20
+
21
+ describe('NavigationCache', () => {
22
+ let cache: NavigationCache;
23
+
24
+ beforeEach(() => {
25
+ cache = new NavigationCache({ maxSize: 10, defaultTtl: 1000 });
26
+ });
27
+
28
+ const createSession = (id: string): CachedSession => ({
29
+ sessionId: id,
30
+ route: `/${id}`,
31
+ tree: { type: 'div', children: [] },
32
+ data: { title: id },
33
+ schemaVersion: '1.0.0',
34
+ cachedAt: Date.now(),
35
+ });
36
+
37
+ test('stores and retrieves sessions', () => {
38
+ const session = createSession('test');
39
+ cache.set(session);
40
+ const retrieved = cache.get('test');
41
+ expect(retrieved).not.toBeNull();
42
+ expect(retrieved?.sessionId).toBe('test');
43
+ });
44
+
45
+ test('returns null for non-existent sessions', () => {
46
+ expect(cache.get('nonexistent')).toBeNull();
47
+ });
48
+
49
+ test('tracks hit/miss rate', () => {
50
+ const session = createSession('test');
51
+ cache.set(session);
52
+
53
+ cache.get('test'); // Hit
54
+ cache.get('test'); // Hit
55
+ cache.get('nonexistent'); // Miss
56
+
57
+ const stats = cache.getStats();
58
+ expect(stats.hitRate).toBeCloseTo(0.67, 1);
59
+ });
60
+
61
+ test('evicts LRU items when at capacity', () => {
62
+ // Fill cache to capacity
63
+ for (let i = 0; i < 10; i++) {
64
+ cache.set(createSession(`session-${i}`));
65
+ }
66
+
67
+ // Access some sessions to update LRU order
68
+ cache.get('session-5');
69
+ cache.get('session-9');
70
+
71
+ // Add new session, should evict oldest (session-0)
72
+ cache.set(createSession('new-session'));
73
+
74
+ expect(cache.get('session-0')).toBeNull();
75
+ expect(cache.get('session-5')).not.toBeNull();
76
+ expect(cache.get('new-session')).not.toBeNull();
77
+ });
78
+
79
+ test('expires sessions after TTL', async () => {
80
+ const cache = new NavigationCache({ defaultTtl: 50 });
81
+ cache.set(createSession('expiring'));
82
+
83
+ expect(cache.get('expiring')).not.toBeNull();
84
+
85
+ // Wait for expiration
86
+ await new Promise((r) => setTimeout(r, 60));
87
+
88
+ expect(cache.get('expiring')).toBeNull();
89
+ });
90
+
91
+ test('prefetches sessions with fetcher', async () => {
92
+ const fetcher = mock(() => Promise.resolve(createSession('fetched')));
93
+
94
+ const result = await cache.prefetch('fetched', fetcher);
95
+
96
+ expect(result.sessionId).toBe('fetched');
97
+ expect(fetcher).toHaveBeenCalledTimes(1);
98
+
99
+ // Second call should use cache
100
+ await cache.prefetch('fetched', fetcher);
101
+ expect(fetcher).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ test('prefetches many sessions in parallel', async () => {
105
+ const fetcher = mock((id: string) =>
106
+ Promise.resolve(createSession(id))
107
+ );
108
+
109
+ const results = await cache.prefetchMany(
110
+ ['a', 'b', 'c'],
111
+ fetcher
112
+ );
113
+
114
+ expect(results).toHaveLength(3);
115
+ expect(fetcher).toHaveBeenCalledTimes(3);
116
+ });
117
+
118
+ test('preloads all sessions with progress', async () => {
119
+ const manifest = [
120
+ { sessionId: 's1', route: '/s1' },
121
+ { sessionId: 's2', route: '/s2' },
122
+ { sessionId: 's3', route: '/s3' },
123
+ ];
124
+
125
+ const fetcher = mock((id: string) =>
126
+ Promise.resolve(createSession(id))
127
+ );
128
+
129
+ const progress: number[] = [];
130
+ await cache.preloadAll(manifest, fetcher, {
131
+ onProgress: (loaded, total) => progress.push(loaded),
132
+ });
133
+
134
+ expect(progress).toContain(3); // Final count
135
+ expect(fetcher).toHaveBeenCalledTimes(3);
136
+ });
137
+
138
+ test('invalidates specific sessions', () => {
139
+ cache.set(createSession('test'));
140
+ expect(cache.has('test')).toBe(true);
141
+
142
+ cache.invalidate('test');
143
+ expect(cache.has('test')).toBe(false);
144
+ });
145
+
146
+ test('clears all sessions', () => {
147
+ cache.set(createSession('a'));
148
+ cache.set(createSession('b'));
149
+
150
+ cache.clear();
151
+
152
+ expect(cache.get('a')).toBeNull();
153
+ expect(cache.get('b')).toBeNull();
154
+ expect(cache.getStats().size).toBe(0);
155
+ });
156
+
157
+ test('exports and imports sessions', () => {
158
+ cache.set(createSession('a'));
159
+ cache.set(createSession('b'));
160
+
161
+ const exported = cache.export();
162
+ expect(exported).toHaveLength(2);
163
+
164
+ const newCache = new NavigationCache();
165
+ newCache.import(exported);
166
+
167
+ expect(newCache.get('a')).not.toBeNull();
168
+ expect(newCache.get('b')).not.toBeNull();
169
+ });
170
+ });
171
+
172
+ describe('NavigationPredictor', () => {
173
+ let predictor: NavigationPredictor;
174
+
175
+ beforeEach(() => {
176
+ predictor = new NavigationPredictor();
177
+ });
178
+
179
+ const record = (from: string, to: string): NavigationRecord => ({
180
+ from,
181
+ to,
182
+ timestamp: Date.now(),
183
+ duration: 5000,
184
+ source: 'click',
185
+ });
186
+
187
+ test('records navigation and predicts based on history', () => {
188
+ // Build history: / -> /about (3x), / -> /blog (1x)
189
+ predictor.record(record('/', '/about'));
190
+ predictor.record(record('/', '/about'));
191
+ predictor.record(record('/', '/about'));
192
+ predictor.record(record('/', '/blog'));
193
+
194
+ const predictions = predictor.predict('/');
195
+
196
+ expect(predictions.length).toBeGreaterThan(0);
197
+ expect(predictions[0].route).toBe('/about'); // Higher probability
198
+ expect(predictions[0].probability).toBeGreaterThan(0.5);
199
+ });
200
+
201
+ test('applies decay to old records', () => {
202
+ predictor.record(record('/', '/old'));
203
+
204
+ // Record many new navigations to trigger decay
205
+ for (let i = 0; i < 100; i++) {
206
+ predictor.record(record('/other', '/new'));
207
+ }
208
+
209
+ const predictions = predictor.predict('/');
210
+ // Old prediction should have decayed significantly
211
+ const oldPred = predictions.find((p) => p.route === '/old');
212
+ expect(oldPred?.probability ?? 0).toBeLessThan(0.5);
213
+ });
214
+
215
+ test('returns empty predictions for unknown routes', () => {
216
+ const predictions = predictor.predict('/unknown');
217
+ expect(predictions).toEqual([]);
218
+ });
219
+
220
+ test('limits predictions to maxPredictions', () => {
221
+ const predictor = new NavigationPredictor({ maxPredictions: 2 });
222
+
223
+ predictor.record(record('/', '/a'));
224
+ predictor.record(record('/', '/b'));
225
+ predictor.record(record('/', '/c'));
226
+ predictor.record(record('/', '/d'));
227
+
228
+ const predictions = predictor.predict('/');
229
+ expect(predictions.length).toBeLessThanOrEqual(2);
230
+ });
231
+
232
+ test('filters out low probability predictions', () => {
233
+ const predictor = new NavigationPredictor({ minProbability: 0.3 });
234
+
235
+ // Create very uneven distribution
236
+ for (let i = 0; i < 100; i++) {
237
+ predictor.record(record('/', '/main'));
238
+ }
239
+ predictor.record(record('/', '/rare'));
240
+
241
+ const predictions = predictor.predict('/');
242
+
243
+ // /rare should be filtered out due to low probability
244
+ const rarePred = predictions.find((p) => p.route === '/rare');
245
+ expect(rarePred).toBeUndefined();
246
+ });
247
+
248
+ test('exports and imports data', () => {
249
+ predictor.record(record('/', '/about'));
250
+ predictor.record(record('/', '/blog'));
251
+
252
+ const exported = predictor.export();
253
+
254
+ const newPredictor = new NavigationPredictor();
255
+ newPredictor.import(exported);
256
+
257
+ const predictions = newPredictor.predict('/');
258
+ expect(predictions.length).toBeGreaterThan(0);
259
+ });
260
+
261
+ test('provides statistics', () => {
262
+ predictor.record(record('/', '/about'));
263
+ predictor.record(record('/', '/blog'));
264
+ predictor.record(record('/about', '/contact'));
265
+
266
+ const stats = predictor.getStats();
267
+
268
+ expect(stats.totalRecords).toBe(3);
269
+ expect(stats.uniqueRoutes).toBe(2); // / and /about
270
+ expect(stats.transitionPairs).toBe(3);
271
+ });
272
+
273
+ test('clears all data', () => {
274
+ predictor.record(record('/', '/about'));
275
+ predictor.clear();
276
+
277
+ expect(predictor.predict('/').length).toBe(0);
278
+ expect(predictor.getStats().totalRecords).toBe(0);
279
+ });
280
+
281
+ test('merges community patterns', () => {
282
+ const patterns = new Map([
283
+ [
284
+ '/',
285
+ {
286
+ route: '/',
287
+ popularity: 100,
288
+ avgTimeSpent: 5000,
289
+ nextRoutes: [
290
+ { route: '/popular', count: 80 },
291
+ { route: '/other', count: 20 },
292
+ ],
293
+ },
294
+ ],
295
+ ]);
296
+
297
+ predictor.updateCommunityPatterns(patterns);
298
+
299
+ const predictions = predictor.predict('/');
300
+
301
+ // Community prediction should be included
302
+ const popularPred = predictions.find((p) => p.route === '/popular');
303
+ expect(popularPred).toBeDefined();
304
+ });
305
+ });
306
+
307
+ describe('AeonNavigationEngine', () => {
308
+ let navigator: AeonNavigationEngine;
309
+ let mockRouter: AeonRouter;
310
+
311
+ beforeEach(() => {
312
+ mockRouter = new AeonRouter({ routesDir: './test-pages' });
313
+
314
+ navigator = new AeonNavigationEngine({
315
+ router: mockRouter,
316
+ initialRoute: '/',
317
+ sessionFetcher: async (id) => ({
318
+ sessionId: id,
319
+ route: `/${id}`,
320
+ tree: { type: 'div' },
321
+ data: {},
322
+ schemaVersion: '1.0.0',
323
+ cachedAt: Date.now(),
324
+ }),
325
+ });
326
+ });
327
+
328
+ test('initializes with current route', () => {
329
+ const state = navigator.getState();
330
+ expect(state.current).toBe('/');
331
+ expect(state.isNavigating).toBe(false);
332
+ });
333
+
334
+ test('prefetches routes', async () => {
335
+ // Mock the router's match method to return a valid match
336
+ const originalMatch = mockRouter.match.bind(mockRouter);
337
+ mockRouter.match = (path: string) => {
338
+ if (path === '/about') {
339
+ return {
340
+ sessionId: 'about',
341
+ componentId: 'About',
342
+ pattern: '/about',
343
+ params: {},
344
+ };
345
+ }
346
+ return originalMatch(path);
347
+ };
348
+
349
+ await navigator.prefetch('/about');
350
+
351
+ expect(navigator.isPreloaded('/about')).toBe(true);
352
+ });
353
+
354
+ test('subscribes to navigation changes', async () => {
355
+ const states: any[] = [];
356
+ const unsubscribe = navigator.subscribe((state) => states.push(state));
357
+
358
+ // Trigger a state change
359
+ (navigator as any).state.isNavigating = true;
360
+ (navigator as any).notifyListeners();
361
+
362
+ expect(states.length).toBeGreaterThan(0);
363
+ unsubscribe();
364
+ });
365
+
366
+ test('tracks navigation history', () => {
367
+ const state = navigator.getState();
368
+ expect(state.history).toContain('/');
369
+ });
370
+
371
+ test('provides cache statistics', () => {
372
+ const stats = navigator.getCacheStats();
373
+
374
+ expect(stats).toHaveProperty('size');
375
+ expect(stats).toHaveProperty('hitRate');
376
+ expect(stats).toHaveProperty('totalBytes');
377
+ });
378
+
379
+ test('predicts next routes based on history', () => {
380
+ // Record some navigation history
381
+ (navigator as any).recordNavigation('/', '/about');
382
+ (navigator as any).recordNavigation('/', '/about');
383
+ (navigator as any).recordNavigation('/', '/blog');
384
+
385
+ const predictions = navigator.predict('/');
386
+
387
+ expect(predictions.length).toBeGreaterThan(0);
388
+ expect(predictions[0].route).toBe('/about');
389
+ });
390
+ });
391
+
392
+ describe('Global singletons', () => {
393
+ test('getNavigationCache returns singleton', () => {
394
+ const cache1 = getNavigationCache();
395
+ const cache2 = getNavigationCache();
396
+ expect(cache1).toBe(cache2);
397
+ });
398
+
399
+ test('setNavigationCache replaces singleton', () => {
400
+ const originalCache = getNavigationCache();
401
+ const newCache = new NavigationCache();
402
+
403
+ setNavigationCache(newCache);
404
+ expect(getNavigationCache()).toBe(newCache);
405
+
406
+ // Restore original
407
+ setNavigationCache(originalCache);
408
+ });
409
+
410
+ test('getPredictor returns singleton', () => {
411
+ const pred1 = getPredictor();
412
+ const pred2 = getPredictor();
413
+ expect(pred1).toBe(pred2);
414
+ });
415
+
416
+ test('setPredictor replaces singleton', () => {
417
+ const originalPredictor = getPredictor();
418
+ const newPredictor = new NavigationPredictor();
419
+
420
+ setPredictor(newPredictor);
421
+ expect(getPredictor()).toBe(newPredictor);
422
+
423
+ // Restore original
424
+ setPredictor(originalPredictor);
425
+ });
426
+
427
+ test('getNavigator returns singleton', () => {
428
+ const nav1 = getNavigator();
429
+ const nav2 = getNavigator();
430
+ expect(nav1).toBe(nav2);
431
+ });
432
+ });