File size: 23,943 Bytes
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e59583d
 
 
 
 
 
 
 
 
 
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
ef87114
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdde22e
eb1c19a
 
cdde22e
 
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdde22e
eb1c19a
 
cdde22e
 
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdde22e
eb1c19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
"""Tests for memory factory module.

Tests component creation via the factory functions.
"""

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest

from headroom.memory.config import (
    EmbedderBackend,
    MemoryConfig,
    StoreBackend,
    TextBackend,
    VectorBackend,
)
from headroom.memory.factory import (
    _create_cache,
    _create_embedder,
    _create_store,
    _create_text_index,
    _create_vector_index,
    create_memory_system,
)

# Check if hnswlib is available (most factory tests require it)
try:
    from headroom.memory.adapters.hnsw import _check_hnswlib_available

    HNSW_AVAILABLE = _check_hnswlib_available()
except ImportError:
    HNSW_AVAILABLE = False

pytestmark = pytest.mark.skipif(not HNSW_AVAILABLE, reason="hnswlib not available")

# =============================================================================
# Test MemoryConfig
# =============================================================================


class TestMemoryConfig:
    """Tests for MemoryConfig dataclass."""

    def test_default_config(self):
        """Default config should have sensible defaults."""
        config = MemoryConfig()

        assert config.store_backend == StoreBackend.SQLITE
        assert config.vector_backend == VectorBackend.AUTO
        assert config.text_backend == TextBackend.FTS5
        assert config.embedder_backend == EmbedderBackend.LOCAL
        assert config.cache_enabled is True
        assert config.cache_max_size == 1000
        assert config.auto_bubble is True
        assert config.bubble_threshold == 0.7

    def test_config_with_custom_path(self):
        """Config should accept custom db path."""
        config = MemoryConfig(db_path=Path("/custom/path.db"))
        assert config.db_path == Path("/custom/path.db")

    def test_config_converts_string_path(self):
        """String paths should be converted to Path objects."""
        config = MemoryConfig(db_path="/custom/path.db")  # type: ignore[arg-type]
        assert isinstance(config.db_path, Path)
        assert config.db_path == Path("/custom/path.db")

    def test_config_validates_vector_dimension(self):
        """Vector dimension must be positive."""
        with pytest.raises(ValueError, match="vector_dimension must be positive"):
            MemoryConfig(vector_dimension=0)

        with pytest.raises(ValueError, match="vector_dimension must be positive"):
            MemoryConfig(vector_dimension=-1)

    def test_config_validates_hnsw_params(self):
        """HNSW parameters must be positive."""
        with pytest.raises(ValueError, match="hnsw_ef_construction must be positive"):
            MemoryConfig(hnsw_ef_construction=0)

        with pytest.raises(ValueError, match="hnsw_m must be positive"):
            MemoryConfig(hnsw_m=0)

        with pytest.raises(ValueError, match="hnsw_ef_search must be positive"):
            MemoryConfig(hnsw_ef_search=0)

    def test_config_validates_cache_size(self):
        """Cache max size must be positive."""
        with pytest.raises(ValueError, match="cache_max_size must be positive"):
            MemoryConfig(cache_max_size=0)

    def test_config_requires_openai_key_for_openai_backend(self):
        """OpenAI backend requires API key."""
        with pytest.raises(ValueError, match="openai_api_key is required"):
            MemoryConfig(embedder_backend=EmbedderBackend.OPENAI)

    def test_config_accepts_openai_key(self):
        """Config should accept OpenAI API key."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.OPENAI, openai_api_key="sk-test-key")
        assert config.openai_api_key == "sk-test-key"

    def test_config_custom_hnsw_params(self):
        """Config should accept custom HNSW parameters."""
        config = MemoryConfig(
            vector_dimension=768, hnsw_ef_construction=400, hnsw_m=32, hnsw_ef_search=100
        )
        assert config.vector_dimension == 768
        assert config.hnsw_ef_construction == 400
        assert config.hnsw_m == 32
        assert config.hnsw_ef_search == 100

    def test_config_cache_disabled(self):
        """Config should allow disabling cache."""
        config = MemoryConfig(cache_enabled=False)
        assert config.cache_enabled is False

    def test_config_ollama_base_url(self):
        """Config should accept Ollama base URL."""
        config = MemoryConfig(
            embedder_backend=EmbedderBackend.OLLAMA, ollama_base_url="http://remote:11434"
        )
        assert config.ollama_base_url == "http://remote:11434"


# =============================================================================
# Test _create_store
# =============================================================================


class TestCreateStore:
    """Tests for _create_store factory function."""

    def test_creates_sqlite_store(self):
        """Should create SQLiteMemoryStore for SQLITE backend."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(
                store_backend=StoreBackend.SQLITE, db_path=Path(tmpdir) / "test.db"
            )
            store = _create_store(config)

            from headroom.memory.adapters.sqlite import SQLiteMemoryStore

            assert isinstance(store, SQLiteMemoryStore)

    def test_sqlite_store_with_custom_path(self):
        """SQLite store should use config db_path."""
        with tempfile.TemporaryDirectory() as tmpdir:
            custom_path = Path(tmpdir) / "custom_memory.db"
            config = MemoryConfig(db_path=custom_path)
            store = _create_store(config)

            from headroom.memory.adapters.sqlite import SQLiteMemoryStore

            assert isinstance(store, SQLiteMemoryStore)

    def test_unknown_store_backend_raises(self):
        """Unknown store backend should raise ValueError."""
        config = MemoryConfig()
        # Manually set an invalid backend to bypass __post_init__
        config.store_backend = "invalid"  # type: ignore[assignment]

        with pytest.raises(ValueError, match="Unknown store backend"):
            _create_store(config)


# =============================================================================
# Test _create_embedder
# =============================================================================


class TestCreateEmbedder:
    """Tests for _create_embedder factory function."""

    def test_creates_local_embedder(self):
        """Should create LocalEmbedder for LOCAL backend."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.LOCAL)
        embedder = _create_embedder(config)

        from headroom.memory.adapters.embedders import LocalEmbedder

        assert isinstance(embedder, LocalEmbedder)

    def test_local_embedder_with_custom_model(self):
        """LocalEmbedder should use config embedder_model."""
        config = MemoryConfig(
            embedder_backend=EmbedderBackend.LOCAL, embedder_model="paraphrase-MiniLM-L6-v2"
        )
        embedder = _create_embedder(config)

        from headroom.memory.adapters.embedders import LocalEmbedder

        assert isinstance(embedder, LocalEmbedder)
        assert embedder.model_name == "paraphrase-MiniLM-L6-v2"

    def test_creates_openai_embedder(self):
        """Should create OpenAIEmbedder for OPENAI backend."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.OPENAI, openai_api_key="sk-test-key")
        embedder = _create_embedder(config)

        from headroom.memory.adapters.embedders import OpenAIEmbedder

        assert isinstance(embedder, OpenAIEmbedder)

    def test_openai_embedder_requires_key(self):
        """OpenAI embedder should raise if no API key."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.LOCAL)
        # Manually override to test the factory check
        config.embedder_backend = EmbedderBackend.OPENAI
        config.openai_api_key = None

        with pytest.raises(ValueError, match="openai_api_key is required"):
            _create_embedder(config)

    def test_creates_ollama_embedder(self):
        """Should create OllamaEmbedder for OLLAMA backend."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.OLLAMA)
        embedder = _create_embedder(config)

        from headroom.memory.adapters.embedders import OllamaEmbedder

        assert isinstance(embedder, OllamaEmbedder)

    def test_ollama_embedder_with_custom_url(self):
        """OllamaEmbedder should use config base URL and model."""
        config = MemoryConfig(
            embedder_backend=EmbedderBackend.OLLAMA,
            ollama_base_url="http://custom:11434",
            embedder_model="custom-model",
        )
        embedder = _create_embedder(config)

        from headroom.memory.adapters.embedders import OllamaEmbedder

        assert isinstance(embedder, OllamaEmbedder)
        assert embedder.model_name == "custom-model"

    def test_unknown_embedder_backend_raises(self):
        """Unknown embedder backend should raise ValueError."""
        config = MemoryConfig()
        # Manually set an invalid backend
        config.embedder_backend = "invalid"  # type: ignore[assignment]

        with pytest.raises(ValueError, match="Unknown embedder backend"):
            _create_embedder(config)


# =============================================================================
# Test _create_vector_index
# =============================================================================


class TestCreateVectorIndex:
    """Tests for _create_vector_index factory function."""

    def test_creates_hnsw_index(self):
        """Should create HNSWVectorIndex for HNSW backend."""
        config = MemoryConfig(vector_backend=VectorBackend.HNSW)
        index = _create_vector_index(config)

        from headroom.memory.adapters.hnsw import HNSWVectorIndex

        assert isinstance(index, HNSWVectorIndex)

    def test_hnsw_index_with_custom_params(self):
        """HNSW index should use config parameters."""
        config = MemoryConfig(
            vector_backend=VectorBackend.HNSW,
            vector_dimension=768,
            hnsw_ef_construction=400,
            hnsw_m=32,
            hnsw_ef_search=100,
        )
        index = _create_vector_index(config)

        from headroom.memory.adapters.hnsw import HNSWVectorIndex

        assert isinstance(index, HNSWVectorIndex)
        assert index._dimension == 768
        assert index._ef_construction == 400
        assert index._m == 32
        assert index._ef_search == 100

    def test_unknown_vector_backend_raises(self):
        """Unknown vector backend should raise ValueError."""
        config = MemoryConfig()
        # Manually set an invalid backend
        config.vector_backend = "invalid"  # type: ignore[assignment]

        with pytest.raises(ValueError, match="Unknown vector backend"):
            _create_vector_index(config)


# =============================================================================
# Test _create_text_index
# =============================================================================


class TestCreateTextIndex:
    """Tests for _create_text_index factory function."""

    def test_creates_fts5_index(self):
        """Should create FTS5TextIndex for FTS5 backend."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(text_backend=TextBackend.FTS5, db_path=Path(tmpdir) / "test.db")
            index = _create_text_index(config)

            from headroom.memory.adapters.fts5 import FTS5TextIndex

            assert isinstance(index, FTS5TextIndex)

    def test_fts5_index_uses_db_path(self):
        """FTS5 index should use config db_path."""
        with tempfile.TemporaryDirectory() as tmpdir:
            custom_path = Path(tmpdir) / "custom_memory.db"
            config = MemoryConfig(db_path=custom_path)
            index = _create_text_index(config)

            from headroom.memory.adapters.fts5 import FTS5TextIndex

            assert isinstance(index, FTS5TextIndex)

    def test_unknown_text_backend_raises(self):
        """Unknown text backend should raise ValueError."""
        config = MemoryConfig()
        # Manually set an invalid backend
        config.text_backend = "invalid"  # type: ignore[assignment]

        with pytest.raises(ValueError, match="Unknown text backend"):
            _create_text_index(config)


# =============================================================================
# Test _create_cache
# =============================================================================


class TestCreateCache:
    """Tests for _create_cache factory function."""

    def test_creates_lru_cache(self):
        """Should create LRUMemoryCache."""
        config = MemoryConfig(cache_max_size=500)
        cache = _create_cache(config)

        from headroom.memory.adapters.cache import LRUMemoryCache

        assert isinstance(cache, LRUMemoryCache)
        assert cache.max_size == 500

    def test_cache_with_default_size(self):
        """Cache should use default size from config."""
        config = MemoryConfig()  # Default cache_max_size=1000
        cache = _create_cache(config)

        from headroom.memory.adapters.cache import LRUMemoryCache

        assert isinstance(cache, LRUMemoryCache)
        assert cache.max_size == 1000

    def test_cache_with_custom_size(self):
        """Cache should use custom max_size from config."""
        config = MemoryConfig(cache_max_size=5000)
        cache = _create_cache(config)

        assert cache.max_size == 5000


# =============================================================================
# Test create_memory_system
# =============================================================================


class TestCreateMemorySystem:
    """Tests for create_memory_system factory function."""

    @pytest.mark.asyncio
    async def test_creates_all_components_with_default_config(self):
        """Should create all components with default config."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(db_path=Path(tmpdir) / "test.db")
            store, vector, text, embedder, cache = await create_memory_system(config)

            from headroom.memory.adapters.cache import LRUMemoryCache
            from headroom.memory.adapters.embedders import LocalEmbedder
            from headroom.memory.adapters.fts5 import FTS5TextIndex
            from headroom.memory.adapters.hnsw import HNSWVectorIndex
            from headroom.memory.adapters.sqlite import SQLiteMemoryStore
            from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex

            assert isinstance(store, SQLiteMemoryStore)
            # Factory auto-selects best available: SQLiteVectorIndex (preferred) or HNSW (fallback)
            assert isinstance(vector, (SQLiteVectorIndex, HNSWVectorIndex))
            assert isinstance(text, FTS5TextIndex)
            assert isinstance(embedder, LocalEmbedder)
            assert isinstance(cache, LRUMemoryCache)

    @pytest.mark.asyncio
    async def test_creates_components_without_cache(self):
        """Should return None for cache when disabled."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(db_path=Path(tmpdir) / "test.db", cache_enabled=False)
            store, vector, text, embedder, cache = await create_memory_system(config)

            assert cache is None

    @pytest.mark.asyncio
    async def test_creates_components_with_none_config(self):
        """Should use default config when None is passed."""
        # This will use default path, which may create file in current dir
        # Just verify it doesn't raise
        store, vector, text, embedder, cache = await create_memory_system(None)

        from headroom.memory.adapters.cache import LRUMemoryCache
        from headroom.memory.adapters.embedders import LocalEmbedder
        from headroom.memory.adapters.fts5 import FTS5TextIndex
        from headroom.memory.adapters.hnsw import HNSWVectorIndex
        from headroom.memory.adapters.sqlite import SQLiteMemoryStore
        from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex

        assert isinstance(store, SQLiteMemoryStore)
        # Factory auto-selects best available: SQLiteVectorIndex (preferred) or HNSW (fallback)
        assert isinstance(vector, (SQLiteVectorIndex, HNSWVectorIndex))
        assert isinstance(text, FTS5TextIndex)
        assert isinstance(embedder, LocalEmbedder)
        assert isinstance(cache, LRUMemoryCache)

    @pytest.mark.asyncio
    async def test_creates_ollama_embedder(self):
        """Should create OllamaEmbedder when specified."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(
                db_path=Path(tmpdir) / "test.db", embedder_backend=EmbedderBackend.OLLAMA
            )
            store, vector, text, embedder, cache = await create_memory_system(config)

            from headroom.memory.adapters.embedders import OllamaEmbedder

            assert isinstance(embedder, OllamaEmbedder)

    @pytest.mark.asyncio
    async def test_creates_openai_embedder(self):
        """Should create OpenAIEmbedder when specified."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(
                db_path=Path(tmpdir) / "test.db",
                embedder_backend=EmbedderBackend.OPENAI,
                openai_api_key="sk-test-key",
            )
            store, vector, text, embedder, cache = await create_memory_system(config)

            from headroom.memory.adapters.embedders import OpenAIEmbedder

            assert isinstance(embedder, OpenAIEmbedder)

    @pytest.mark.asyncio
    async def test_custom_hnsw_params_propagate(self):
        """Custom HNSW params should propagate to vector index."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(
                db_path=Path(tmpdir) / "test.db",
                vector_backend=VectorBackend.HNSW,  # Explicitly use HNSW to test params
                vector_dimension=512,
                hnsw_ef_construction=300,
                hnsw_m=24,
                hnsw_ef_search=75,
            )
            store, vector, text, embedder, cache = await create_memory_system(config)

            assert vector._dimension == 512
            assert vector._ef_construction == 300
            assert vector._m == 24
            assert vector._ef_search == 75

    @pytest.mark.asyncio
    async def test_returns_tuple_of_five(self):
        """Should return exactly 5 components."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(db_path=Path(tmpdir) / "test.db")
            result = await create_memory_system(config)

            assert isinstance(result, tuple)
            assert len(result) == 5


# =============================================================================
# Test Backend Enums
# =============================================================================


class TestBackendEnums:
    """Tests for backend enum values."""

    def test_store_backend_values(self):
        """StoreBackend should have expected values."""
        assert StoreBackend.SQLITE.value == "sqlite"

    def test_vector_backend_values(self):
        """VectorBackend should have expected values."""
        assert VectorBackend.HNSW.value == "hnsw"

    def test_text_backend_values(self):
        """TextBackend should have expected values."""
        assert TextBackend.FTS5.value == "fts5"

    def test_embedder_backend_values(self):
        """EmbedderBackend should have expected values."""
        assert EmbedderBackend.LOCAL.value == "local"
        assert EmbedderBackend.OPENAI.value == "openai"
        assert EmbedderBackend.OLLAMA.value == "ollama"


# =============================================================================
# Integration Tests
# =============================================================================


class TestFactoryIntegration:
    """Integration tests for factory module."""

    @pytest.mark.asyncio
    async def test_created_components_are_usable(self):
        """Created components should be functional."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config = MemoryConfig(db_path=Path(tmpdir) / "test.db")
            store, vector, text, embedder, cache = await create_memory_system(config)

            # Test store has expected interface (SQLiteMemoryStore uses 'save' not 'add')
            assert hasattr(store, "save")
            assert hasattr(store, "get")
            assert hasattr(store, "delete")
            assert hasattr(store, "query")

            # Test vector index has expected interface (HNSWVectorIndex uses 'index')
            assert hasattr(vector, "index")
            assert hasattr(vector, "index_batch")
            assert hasattr(vector, "search")
            assert hasattr(vector, "remove")

            # Test text index has expected interface (FTS5TextIndex uses 'index')
            assert hasattr(text, "index")
            assert hasattr(text, "index_batch")
            assert hasattr(text, "search")

            # Test embedder has expected interface
            assert hasattr(embedder, "embed")
            assert hasattr(embedder, "embed_batch")
            assert hasattr(embedder, "dimension")

            # Test cache has expected interface
            assert cache is not None
            assert hasattr(cache, "get")
            assert hasattr(cache, "put")
            assert hasattr(cache, "invalidate")

    @pytest.mark.asyncio
    async def test_embedder_dimension_property(self):
        """Created embedder should report correct dimension."""
        config = MemoryConfig(embedder_backend=EmbedderBackend.LOCAL)
        embedder = _create_embedder(config)

        # LocalEmbedder reports dimension before model is loaded
        assert embedder.dimension == 384  # all-MiniLM-L6-v2 default

    @pytest.mark.asyncio
    async def test_cache_operations(self):
        """Created cache should support basic operations."""
        config = MemoryConfig(cache_max_size=10)
        cache = _create_cache(config)

        # Cache should be empty initially
        assert cache.size == 0
        assert cache.max_size == 10

        # Test stats
        stats = cache.stats()
        assert stats["size"] == 0
        assert stats["max_size"] == 10
        assert stats["utilization"] == 0.0

    def test_config_with_all_custom_options(self):
        """Config should accept all custom options together."""
        config = MemoryConfig(
            store_backend=StoreBackend.SQLITE,
            db_path=Path("/custom/path.db"),
            vector_backend=VectorBackend.HNSW,
            vector_dimension=1024,
            hnsw_ef_construction=500,
            hnsw_m=48,
            hnsw_ef_search=200,
            text_backend=TextBackend.FTS5,
            embedder_backend=EmbedderBackend.OLLAMA,
            embedder_model="custom-model",
            ollama_base_url="http://custom:11434",
            cache_enabled=True,
            cache_max_size=5000,
            auto_bubble=False,
            bubble_threshold=0.5,
        )

        assert config.store_backend == StoreBackend.SQLITE
        assert config.db_path == Path("/custom/path.db")
        assert config.vector_dimension == 1024
        assert config.hnsw_ef_construction == 500
        assert config.hnsw_m == 48
        assert config.hnsw_ef_search == 200
        assert config.embedder_backend == EmbedderBackend.OLLAMA
        assert config.embedder_model == "custom-model"
        assert config.ollama_base_url == "http://custom:11434"
        assert config.cache_enabled is True
        assert config.cache_max_size == 5000
        assert config.auto_bubble is False
        assert config.bubble_threshold == 0.5