/ tests / memory.test.ts
memory.test.ts
  1  import { describe, it, beforeEach, afterEach } from 'node:test'
  2  import assert from 'node:assert/strict'
  3  import fs from 'fs'
  4  import path from 'path'
  5  import {
  6    normalizeLinkedMemoryIds,
  7    normalizeMemoryLookupLimits,
  8    resolveLookupRequest,
  9    traverseLinkedMemoryGraph,
 10    type MemoryLookupLimits,
 11    type LinkedMemoryNode,
 12  } from '../src/lib/server/memory/memory-graph'
 13  
 14  // Use a test-specific database path
 15  const TEST_DB_DIR = path.join(process.cwd(), 'data', 'test-memory')
 16  const TEST_IMAGES_DIR = path.join(TEST_DB_DIR, 'memory-images')
 17  
 18  function setupTestDb() {
 19    // Clean up any existing test database
 20    if (fs.existsSync(TEST_DB_DIR)) {
 21      fs.rmSync(TEST_DB_DIR, { recursive: true, force: true })
 22    }
 23    fs.mkdirSync(TEST_DB_DIR, { recursive: true })
 24    fs.mkdirSync(TEST_IMAGES_DIR, { recursive: true })
 25  }
 26  
 27  function cleanupTestDb() {
 28    if (fs.existsSync(TEST_DB_DIR)) {
 29      fs.rmSync(TEST_DB_DIR, { recursive: true, force: true })
 30    }
 31  }
 32  
 33  describe('Memory System', () => {
 34    beforeEach(() => {
 35      setupTestDb()
 36    })
 37  
 38    afterEach(() => {
 39      cleanupTestDb()
 40    })
 41  
 42    describe('normalizeLinkedMemoryIds', () => {
 43      it('filters out empty strings and self-references', () => {
 44        assert.deepEqual(normalizeLinkedMemoryIds(['a', '', 'b', '  ', 'a', 'c'], 'self'), ['a', 'b', 'c'])
 45      })
 46  
 47      it('returns empty array for non-array input', () => {
 48        assert.deepEqual(normalizeLinkedMemoryIds(null), [])
 49        assert.deepEqual(normalizeLinkedMemoryIds(undefined), [])
 50        assert.deepEqual(normalizeLinkedMemoryIds('not-an-array'), [])
 51      })
 52  
 53      it('deduplicates ids', () => {
 54        assert.deepEqual(normalizeLinkedMemoryIds(['a', 'b', 'a', 'a', 'c'], undefined), ['a', 'b', 'c'])
 55      })
 56    })
 57  
 58    describe('normalizeMemoryLookupLimits', () => {
 59      it('returns defaults for empty settings', () => {
 60        const limits = normalizeMemoryLookupLimits({})
 61        assert.equal(limits.maxDepth, 3)
 62        assert.equal(limits.maxPerLookup, 20)
 63        assert.equal(limits.maxLinkedExpansion, 60)
 64      })
 65  
 66      it('clamps values to valid ranges', () => {
 67        const limits = normalizeMemoryLookupLimits({
 68          memoryReferenceDepth: 100,
 69          maxMemoriesPerLookup: 1000,
 70          maxLinkedMemoriesExpanded: 5000,
 71        })
 72        assert.equal(limits.maxDepth, 12) // max
 73        assert.equal(limits.maxPerLookup, 200) // max
 74        assert.equal(limits.maxLinkedExpansion, 1000) // max
 75      })
 76  
 77      it('clamps zeros to minimums', () => {
 78        const limits = normalizeMemoryLookupLimits({
 79          memoryReferenceDepth: 0,
 80          maxMemoriesPerLookup: 0,
 81          maxLinkedMemoriesExpanded: 0,
 82        })
 83        assert.equal(limits.maxDepth, 0)
 84        assert.equal(limits.maxPerLookup, 1) // min
 85        assert.equal(limits.maxLinkedExpansion, 0)
 86      })
 87  
 88      it('uses legacy setting names as fallback', () => {
 89        const limits = normalizeMemoryLookupLimits({
 90          memoryMaxDepth: 5,
 91          memoryMaxPerLookup: 50,
 92        })
 93        assert.equal(limits.maxDepth, 5)
 94        assert.equal(limits.maxPerLookup, 50)
 95      })
 96    })
 97  
 98    describe('resolveLookupRequest', () => {
 99      const defaults: MemoryLookupLimits = {
100        maxDepth: 3,
101        maxPerLookup: 20,
102        maxLinkedExpansion: 60,
103      }
104  
105      it('uses defaults when request is empty', () => {
106        const result = resolveLookupRequest(defaults, {})
107        assert.deepEqual(result, defaults)
108      })
109  
110      it('overrides with request values', () => {
111        const result = resolveLookupRequest(defaults, { depth: 2, limit: 10, linkedLimit: 30 })
112        assert.equal(result.maxDepth, 2)
113        assert.equal(result.maxPerLookup, 10)
114        assert.equal(result.maxLinkedExpansion, 30)
115      })
116  
117      it('caps at defaults maxima', () => {
118        const result = resolveLookupRequest(defaults, { depth: 100, limit: 1000, linkedLimit: 5000 })
119        assert.equal(result.maxDepth, 3) // capped at default
120        assert.equal(result.maxPerLookup, 20) // capped at default
121        assert.equal(result.maxLinkedExpansion, 60) // capped at default
122      })
123    })
124  
125    describe('traverseLinkedMemoryGraph', () => {
126      const fetchByIds = (ids: string[]): LinkedMemoryNode[] => {
127        return ids.map((id) => ({
128          id,
129          linkedMemoryIds: id === 'a' ? ['b', 'c'] : id === 'b' ? ['d'] : [],
130        }))
131      }
132  
133      it('returns empty for empty seeds', () => {
134        const result = traverseLinkedMemoryGraph([], { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds)
135        assert.deepEqual(result.entries, [])
136        assert.equal(result.truncated, false)
137        assert.equal(result.expandedLinkedCount, 0)
138      })
139  
140      it('traverses linked nodes by depth', () => {
141        // a -> [b, c], b -> [d]
142        const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c'] }]
143        const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 2, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds)
144        const ids = result.entries.map((n) => n.id)
145        assert.ok(ids.includes('a'))
146        assert.ok(ids.includes('b'))
147        assert.ok(ids.includes('c'))
148        assert.ok(ids.includes('d')) // depth 2
149        assert.equal(result.expandedLinkedCount, 3) // b, c, d
150      })
151  
152      it('respects maxDepth', () => {
153        // a -> [b], b -> [c], c -> [d] (if fetch returned that)
154        const limitedFetch = (ids: string[]): LinkedMemoryNode[] => {
155          const map: Record<string, string[]> = { a: ['b'], b: ['c'], c: ['d'], d: [] }
156          return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] }))
157        }
158        const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }]
159        const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 1, maxPerLookup: 20, maxLinkedExpansion: 60 }, limitedFetch)
160        const ids = result.entries.map((n) => n.id)
161        assert.ok(ids.includes('a'))
162        assert.ok(ids.includes('b'))
163        assert.ok(!ids.includes('c')) // depth 1 stops before c
164      })
165  
166      it('respects maxPerLookup', () => {
167        const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e'] }]
168        const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 3, maxLinkedExpansion: 60 }, fetchByIds)
169        assert.equal(result.entries.length, 3)
170        assert.equal(result.truncated, true)
171      })
172  
173      it('respects maxLinkedExpansion', () => {
174        const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e', 'f'] }]
175        const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 2 }, fetchByIds)
176        assert.equal(result.expandedLinkedCount, 2)
177        assert.equal(result.truncated, true)
178      })
179  
180      it('handles circular links gracefully', () => {
181        // a -> [b], b -> [a] (circular)
182        const circularFetch = (ids: string[]): LinkedMemoryNode[] => {
183          const map: Record<string, string[]> = { a: ['b'], b: ['a'] }
184          return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] }))
185        }
186        const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }]
187        const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 10, maxPerLookup: 100, maxLinkedExpansion: 100 }, circularFetch)
188        assert.equal(result.entries.length, 2) // just a and b
189        assert.equal(result.truncated, false)
190      })
191    })
192  })
193  
194  describe('Memory Database', () => {
195    beforeEach(() => {
196      setupTestDb()
197    })
198  
199    afterEach(() => {
200      cleanupTestDb()
201    })
202  
203    // Note: These tests require mocking the DB path or using a test-specific path
204    // The actual getMemoryDb() uses a singleton, so these are integration-style tests
205    
206    describe('Reference normalization', () => {
207      it('converts legacy filePaths to references', () => {
208        const legacyPaths = [
209          { path: '/src/lib/x.ts', contextSnippet: 'buggy function', kind: 'file' as const, timestamp: Date.now() },
210          { path: '/src', kind: 'folder' as const, timestamp: Date.now() },
211        ]
212        // This would be tested via the actual memory-db.ts normalizeReferences helper
213        // For now, verify the structure is expected
214        assert.equal(legacyPaths[0].path, '/src/lib/x.ts')
215        assert.equal(legacyPaths[0].kind, 'file')
216      })
217    })
218  
219    describe('Image storage', () => {
220      it('rejects images over 10MB', async () => {
221        // This would require creating a large temp file
222        // Skipped for unit test - integration test needed
223      })
224  
225      it('compresses images to 1024px max dimension', async () => {
226        // This would require sharp and a real image file
227        // Skipped for unit test - integration test needed
228      })
229    })
230  })