superlocalmemory 2.6.5 → 2.7.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.
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SuperLocalMemory V2 - Tests for ProjectContextManager (v2.7)
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+ """
7
+
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Fixtures
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @pytest.fixture
19
+ def memory_db(tmp_path):
20
+ """Create a minimal memory.db with the required schema."""
21
+ db_path = tmp_path / "memory.db"
22
+ conn = sqlite3.connect(str(db_path))
23
+ cursor = conn.cursor()
24
+ cursor.execute('''
25
+ CREATE TABLE IF NOT EXISTS memories (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ content TEXT NOT NULL,
28
+ summary TEXT,
29
+ project_path TEXT,
30
+ project_name TEXT,
31
+ tags TEXT DEFAULT '[]',
32
+ category TEXT,
33
+ parent_id INTEGER,
34
+ tree_path TEXT DEFAULT '/',
35
+ depth INTEGER DEFAULT 0,
36
+ memory_type TEXT DEFAULT 'session',
37
+ importance INTEGER DEFAULT 5,
38
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
39
+ last_accessed TIMESTAMP,
40
+ access_count INTEGER DEFAULT 0,
41
+ content_hash TEXT,
42
+ cluster_id INTEGER,
43
+ profile TEXT DEFAULT 'default',
44
+ created_by TEXT,
45
+ source_protocol TEXT,
46
+ trust_score REAL DEFAULT 1.0
47
+ )
48
+ ''')
49
+ conn.commit()
50
+ conn.close()
51
+ return db_path
52
+
53
+
54
+ @pytest.fixture
55
+ def pcm(memory_db):
56
+ from src.learning.project_context_manager import ProjectContextManager
57
+ return ProjectContextManager(memory_db_path=memory_db)
58
+
59
+
60
+ def _insert_memories(db_path, memories):
61
+ conn = sqlite3.connect(str(db_path))
62
+ cursor = conn.cursor()
63
+ for m in memories:
64
+ cursor.execute('''
65
+ INSERT INTO memories (content, project_name, project_path,
66
+ profile, cluster_id, created_at)
67
+ VALUES (?, ?, ?, ?, ?, ?)
68
+ ''', (
69
+ m.get('content', 'test'),
70
+ m.get('project_name'),
71
+ m.get('project_path'),
72
+ m.get('profile', 'default'),
73
+ m.get('cluster_id'),
74
+ m.get('created_at', '2026-02-16 10:00:00'),
75
+ ))
76
+ conn.commit()
77
+ conn.close()
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Path Extraction
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class TestExtractProjectFromPath:
85
+ """Test the static _extract_project_from_path method."""
86
+
87
+ def test_projects_parent(self):
88
+ from src.learning.project_context_manager import ProjectContextManager
89
+ result = ProjectContextManager._extract_project_from_path(
90
+ "/Users/varun/projects/MY_PROJECT/src/main.py"
91
+ )
92
+ assert result == "MY_PROJECT"
93
+
94
+ def test_repos_parent(self):
95
+ from src.learning.project_context_manager import ProjectContextManager
96
+ result = ProjectContextManager._extract_project_from_path(
97
+ "/home/dev/repos/my-app/lib/util.js"
98
+ )
99
+ assert result == "my-app"
100
+
101
+ def test_documents_parent(self):
102
+ from src.learning.project_context_manager import ProjectContextManager
103
+ result = ProjectContextManager._extract_project_from_path(
104
+ "/Users/varun/Documents/AGENTIC_Official/SuperLocalMemoryV2-repo/src/learning/foo.py"
105
+ )
106
+ assert result == "SuperLocalMemoryV2-repo"
107
+
108
+ def test_workspace_services(self):
109
+ from src.learning.project_context_manager import ProjectContextManager
110
+ result = ProjectContextManager._extract_project_from_path(
111
+ "/workspace/services/auth-service/index.ts"
112
+ )
113
+ assert result == "auth-service"
114
+
115
+ def test_empty_path(self):
116
+ from src.learning.project_context_manager import ProjectContextManager
117
+ assert ProjectContextManager._extract_project_from_path("") is None
118
+
119
+ def test_none_path(self):
120
+ from src.learning.project_context_manager import ProjectContextManager
121
+ assert ProjectContextManager._extract_project_from_path(None) is None
122
+
123
+ def test_short_path(self):
124
+ from src.learning.project_context_manager import ProjectContextManager
125
+ assert ProjectContextManager._extract_project_from_path("/") is None
126
+
127
+ def test_github_parent(self):
128
+ from src.learning.project_context_manager import ProjectContextManager
129
+ result = ProjectContextManager._extract_project_from_path(
130
+ "/home/user/github/cool-project/README.md"
131
+ )
132
+ assert result == "cool-project"
133
+
134
+ def test_skip_dirs_not_returned(self):
135
+ """Directories like src, lib, node_modules should not be project names."""
136
+ from src.learning.project_context_manager import ProjectContextManager
137
+ result = ProjectContextManager._extract_project_from_path(
138
+ "/Users/dev/projects/myapp/src/lib/util.py"
139
+ )
140
+ assert result == "myapp"
141
+
142
+ def test_code_parent(self):
143
+ from src.learning.project_context_manager import ProjectContextManager
144
+ result = ProjectContextManager._extract_project_from_path(
145
+ "/Users/dev/code/awesome-tool/main.py"
146
+ )
147
+ assert result == "awesome-tool"
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Project Detection
152
+ # ---------------------------------------------------------------------------
153
+
154
+ class TestDetectCurrentProject:
155
+ def test_with_explicit_project_tags(self, pcm, memory_db):
156
+ """Signal 1: project_name tag should dominate."""
157
+ memories = [
158
+ {"content": "test", "project_name": "MyProject"},
159
+ {"content": "test", "project_name": "MyProject"},
160
+ {"content": "test", "project_name": "MyProject"},
161
+ ]
162
+ recent = [
163
+ {"project_name": "MyProject", "project_path": None, "cluster_id": None,
164
+ "profile": "default", "content": "test"}
165
+ for _ in range(3)
166
+ ]
167
+ result = pcm.detect_current_project(recent_memories=recent)
168
+ assert result == "MyProject"
169
+
170
+ def test_with_project_paths(self, pcm):
171
+ """Signal 2: project_path analysis."""
172
+ recent = [
173
+ {"project_name": None,
174
+ "project_path": "/Users/dev/projects/SLM/src/main.py",
175
+ "cluster_id": None, "profile": "default", "content": "test"}
176
+ for _ in range(5)
177
+ ]
178
+ result = pcm.detect_current_project(recent_memories=recent)
179
+ assert result == "SLM"
180
+
181
+ def test_with_profiles(self, pcm):
182
+ """Signal 3: active profile as weak signal."""
183
+ recent = [
184
+ {"project_name": None, "project_path": None,
185
+ "cluster_id": None, "profile": "work", "content": "test"}
186
+ ]
187
+ # Profile is weak (weight=1), may not win alone due to 40% threshold
188
+ # with just 1 memory. But adding it to existing signal helps.
189
+ recent_with_name = [
190
+ {"project_name": "work", "project_path": None,
191
+ "cluster_id": None, "profile": "work", "content": "test"}
192
+ ]
193
+ result = pcm.detect_current_project(recent_memories=recent_with_name)
194
+ assert result == "work"
195
+
196
+ def test_empty_memories(self, pcm):
197
+ result = pcm.detect_current_project(recent_memories=[])
198
+ assert result is None
199
+
200
+ def test_ambiguous_results_none(self, pcm):
201
+ """When no project clears 40% threshold, return None."""
202
+ recent = [
203
+ {"project_name": "A", "project_path": None, "cluster_id": None,
204
+ "profile": "default", "content": "test"},
205
+ {"project_name": "B", "project_path": None, "cluster_id": None,
206
+ "profile": "default", "content": "test"},
207
+ {"project_name": "C", "project_path": None, "cluster_id": None,
208
+ "profile": "default", "content": "test"},
209
+ ]
210
+ # Each gets 3 points (weight 3 for project_tag), total = 9
211
+ # 3/9 = 33% < 40% threshold, so None
212
+ result = pcm.detect_current_project(recent_memories=recent)
213
+ assert result is None
214
+
215
+ def test_mixed_signals(self, pcm):
216
+ """Both project_name and project_path pointing to same project."""
217
+ recent = [
218
+ {"project_name": "SLM", "project_path": "/projects/SLM/src/a.py",
219
+ "cluster_id": None, "profile": "default", "content": "test"},
220
+ {"project_name": "SLM", "project_path": "/projects/SLM/src/b.py",
221
+ "cluster_id": None, "profile": "default", "content": "test"},
222
+ ]
223
+ result = pcm.detect_current_project(recent_memories=recent)
224
+ assert result == "SLM"
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Project Boost
229
+ # ---------------------------------------------------------------------------
230
+
231
+ class TestGetProjectBoost:
232
+ def test_match_returns_1_0(self, pcm):
233
+ memory = {"project_name": "MyProject"}
234
+ assert pcm.get_project_boost(memory, "MyProject") == 1.0
235
+
236
+ def test_case_insensitive_match(self, pcm):
237
+ memory = {"project_name": "myproject"}
238
+ assert pcm.get_project_boost(memory, "MyProject") == 1.0
239
+
240
+ def test_mismatch_returns_0_3(self, pcm):
241
+ memory = {"project_name": "OtherProject"}
242
+ assert pcm.get_project_boost(memory, "MyProject") == 0.3
243
+
244
+ def test_no_current_project_returns_0_6(self, pcm):
245
+ memory = {"project_name": "Anything"}
246
+ assert pcm.get_project_boost(memory, None) == 0.6
247
+
248
+ def test_no_project_info_returns_0_6(self, pcm):
249
+ memory = {"content": "no project info"}
250
+ assert pcm.get_project_boost(memory, "MyProject") == 0.6
251
+
252
+ def test_path_match(self, pcm):
253
+ memory = {"project_path": "/projects/SLM/src/main.py"}
254
+ assert pcm.get_project_boost(memory, "SLM") == 1.0
255
+
256
+ def test_path_mismatch(self, pcm):
257
+ memory = {"project_path": "/projects/OTHER/src/main.py"}
258
+ assert pcm.get_project_boost(memory, "SLM") == 0.3
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # safe_get
263
+ # ---------------------------------------------------------------------------
264
+
265
+ class TestSafeGet:
266
+ def test_normal_value(self, pcm):
267
+ assert pcm._safe_get({"key": "value"}, "key") == "value"
268
+
269
+ def test_missing_key(self, pcm):
270
+ assert pcm._safe_get({"key": "value"}, "other") is None
271
+
272
+ def test_none_value(self, pcm):
273
+ assert pcm._safe_get({"key": None}, "key") is None
274
+
275
+ def test_empty_string(self, pcm):
276
+ assert pcm._safe_get({"key": ""}, "key") is None
277
+
278
+ def test_whitespace_string(self, pcm):
279
+ assert pcm._safe_get({"key": " "}, "key") is None
280
+
281
+ def test_integer_value(self, pcm):
282
+ assert pcm._safe_get({"key": 42}, "key") == 42
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Cache Invalidation
287
+ # ---------------------------------------------------------------------------
288
+
289
+ class TestCacheInvalidation:
290
+ def test_invalidate_cache(self, pcm):
291
+ # Force column cache to be populated
292
+ pcm._get_available_columns()
293
+ assert pcm._available_columns is not None
294
+
295
+ pcm.invalidate_cache()
296
+ assert pcm._available_columns is None
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SuperLocalMemory V2 - Tests for SourceQualityScorer (v2.7)
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+ """
7
+
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Fixtures
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def reset_singleton():
20
+ from src.learning.learning_db import LearningDB
21
+ LearningDB.reset_instance()
22
+ yield
23
+ LearningDB.reset_instance()
24
+
25
+
26
+ @pytest.fixture
27
+ def learning_db(tmp_path):
28
+ from src.learning.learning_db import LearningDB
29
+ db_path = tmp_path / "learning.db"
30
+ return LearningDB(db_path=db_path)
31
+
32
+
33
+ @pytest.fixture
34
+ def memory_db(tmp_path):
35
+ """Create a minimal memory.db with created_by column."""
36
+ db_path = tmp_path / "memory.db"
37
+ conn = sqlite3.connect(str(db_path))
38
+ cursor = conn.cursor()
39
+ cursor.execute('''
40
+ CREATE TABLE IF NOT EXISTS memories (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ content TEXT NOT NULL,
43
+ summary TEXT,
44
+ project_path TEXT,
45
+ project_name TEXT,
46
+ tags TEXT DEFAULT '[]',
47
+ category TEXT,
48
+ parent_id INTEGER,
49
+ tree_path TEXT DEFAULT '/',
50
+ depth INTEGER DEFAULT 0,
51
+ memory_type TEXT DEFAULT 'session',
52
+ importance INTEGER DEFAULT 5,
53
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
54
+ last_accessed TIMESTAMP,
55
+ access_count INTEGER DEFAULT 0,
56
+ content_hash TEXT,
57
+ cluster_id INTEGER,
58
+ profile TEXT DEFAULT 'default',
59
+ created_by TEXT,
60
+ source_protocol TEXT,
61
+ trust_score REAL DEFAULT 1.0
62
+ )
63
+ ''')
64
+ conn.commit()
65
+ conn.close()
66
+ return db_path
67
+
68
+
69
+ def _insert_memories(db_path, memories):
70
+ conn = sqlite3.connect(str(db_path))
71
+ cursor = conn.cursor()
72
+ for m in memories:
73
+ cursor.execute('''
74
+ INSERT INTO memories (content, created_by, source_protocol)
75
+ VALUES (?, ?, ?)
76
+ ''', (
77
+ m.get('content', 'test'),
78
+ m.get('created_by'),
79
+ m.get('source_protocol'),
80
+ ))
81
+ conn.commit()
82
+ conn.close()
83
+
84
+
85
+ @pytest.fixture
86
+ def scorer(memory_db, learning_db):
87
+ from src.learning.source_quality_scorer import SourceQualityScorer
88
+ return SourceQualityScorer(
89
+ memory_db_path=memory_db,
90
+ learning_db=learning_db,
91
+ )
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Beta-Binomial Calculation
96
+ # ---------------------------------------------------------------------------
97
+
98
+ class TestBetaBinomialScore:
99
+ def test_zero_data(self):
100
+ from src.learning.source_quality_scorer import SourceQualityScorer
101
+ # (1 + 0) / (2 + 0) = 0.5
102
+ assert abs(SourceQualityScorer._beta_binomial_score(0, 0) - 0.5) < 0.001
103
+
104
+ def test_perfect_score(self):
105
+ from src.learning.source_quality_scorer import SourceQualityScorer
106
+ # (1 + 10) / (2 + 10) = 11/12 ~ 0.917
107
+ score = SourceQualityScorer._beta_binomial_score(10, 10)
108
+ assert abs(score - 11.0 / 12.0) < 0.001
109
+
110
+ def test_poor_score(self):
111
+ from src.learning.source_quality_scorer import SourceQualityScorer
112
+ # (1 + 1) / (2 + 10) = 2/12 ~ 0.167
113
+ score = SourceQualityScorer._beta_binomial_score(1, 10)
114
+ assert abs(score - 2.0 / 12.0) < 0.001
115
+
116
+ def test_even_split(self):
117
+ from src.learning.source_quality_scorer import SourceQualityScorer
118
+ # (1 + 5) / (2 + 10) = 6/12 = 0.5
119
+ score = SourceQualityScorer._beta_binomial_score(5, 10)
120
+ assert abs(score - 0.5) < 0.001
121
+
122
+ def test_large_numbers_convergence(self):
123
+ from src.learning.source_quality_scorer import SourceQualityScorer
124
+ # (1 + 80) / (2 + 100) = 81/102 ~ 0.794
125
+ score = SourceQualityScorer._beta_binomial_score(80, 100)
126
+ assert abs(score - 81.0 / 102.0) < 0.001
127
+
128
+ def test_score_bounded(self):
129
+ from src.learning.source_quality_scorer import SourceQualityScorer
130
+ # Should always be in [0.0, 1.0]
131
+ for pos, total in [(0, 0), (100, 100), (0, 100), (100, 0)]:
132
+ score = SourceQualityScorer._beta_binomial_score(pos, total)
133
+ assert 0.0 <= score <= 1.0, f"pos={pos}, total={total}, score={score}"
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # compute_source_scores
138
+ # ---------------------------------------------------------------------------
139
+
140
+ class TestComputeSourceScores:
141
+ def test_empty_db(self, scorer):
142
+ scores = scorer.compute_source_scores()
143
+ assert scores == {}
144
+
145
+ def test_with_memories(self, scorer, memory_db, learning_db):
146
+ """Sources with memories should get computed scores."""
147
+ _insert_memories(memory_db, [
148
+ {"content": "test 1", "created_by": "mcp:claude"},
149
+ {"content": "test 2", "created_by": "mcp:claude"},
150
+ {"content": "test 3", "created_by": "mcp:cursor"},
151
+ {"content": "test 4", "created_by": "cli:terminal"},
152
+ ])
153
+ scores = scorer.compute_source_scores()
154
+ assert len(scores) >= 3
155
+ assert "mcp:claude" in scores
156
+ assert "mcp:cursor" in scores
157
+ assert "cli:terminal" in scores
158
+
159
+ def test_with_positive_feedback(self, scorer, memory_db, learning_db):
160
+ """Sources with positive feedback should get higher scores."""
161
+ _insert_memories(memory_db, [
162
+ {"content": "test 1", "created_by": "good_source"},
163
+ {"content": "test 2", "created_by": "good_source"},
164
+ {"content": "test 3", "created_by": "bad_source"},
165
+ {"content": "test 4", "created_by": "bad_source"},
166
+ ])
167
+
168
+ # Add positive feedback for good_source memories (id 1, 2)
169
+ learning_db.store_feedback(
170
+ query_hash="q1", memory_id=1,
171
+ signal_type="mcp_used", signal_value=1.0, channel="mcp",
172
+ )
173
+ learning_db.store_feedback(
174
+ query_hash="q2", memory_id=2,
175
+ signal_type="mcp_used", signal_value=1.0, channel="mcp",
176
+ )
177
+
178
+ scores = scorer.compute_source_scores()
179
+ assert scores["good_source"] > scores["bad_source"]
180
+
181
+ def test_stores_in_learning_db(self, scorer, memory_db, learning_db):
182
+ """Computed scores should be persisted in learning.db."""
183
+ _insert_memories(memory_db, [
184
+ {"content": "test", "created_by": "mcp:test"},
185
+ ])
186
+ scorer.compute_source_scores()
187
+
188
+ db_scores = learning_db.get_source_scores()
189
+ assert "mcp:test" in db_scores
190
+
191
+ def test_no_created_by_column(self, tmp_path, learning_db):
192
+ """Memory DB without created_by should group all as 'unknown'."""
193
+ db_path = tmp_path / "old_memory.db"
194
+ conn = sqlite3.connect(str(db_path))
195
+ cursor = conn.cursor()
196
+ cursor.execute('''
197
+ CREATE TABLE memories (
198
+ id INTEGER PRIMARY KEY, content TEXT
199
+ )
200
+ ''')
201
+ cursor.execute("INSERT INTO memories (content) VALUES ('test')")
202
+ conn.commit()
203
+ conn.close()
204
+
205
+ from src.learning.source_quality_scorer import SourceQualityScorer
206
+ s = SourceQualityScorer(memory_db_path=db_path, learning_db=learning_db)
207
+ scores = s.compute_source_scores()
208
+ assert "unknown" in scores
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # get_source_boost
213
+ # ---------------------------------------------------------------------------
214
+
215
+ class TestGetSourceBoost:
216
+ def test_known_source(self, scorer):
217
+ """Known source from cache should return its score."""
218
+ scorer._cached_scores = {"mcp:claude": 0.8, "cli:terminal": 0.4}
219
+
220
+ memory = {"created_by": "mcp:claude"}
221
+ assert scorer.get_source_boost(memory) == 0.8
222
+
223
+ def test_unknown_source_returns_default(self, scorer):
224
+ """Unknown source should return DEFAULT_QUALITY_SCORE (0.5)."""
225
+ from src.learning.source_quality_scorer import DEFAULT_QUALITY_SCORE
226
+ scorer._cached_scores = {"mcp:claude": 0.8}
227
+
228
+ memory = {"created_by": "unknown_tool"}
229
+ assert scorer.get_source_boost(memory) == DEFAULT_QUALITY_SCORE
230
+
231
+ def test_no_source_info(self, scorer):
232
+ """Memory with no created_by should return default."""
233
+ from src.learning.source_quality_scorer import DEFAULT_QUALITY_SCORE
234
+ memory = {"content": "no source info"}
235
+ assert scorer.get_source_boost(memory) == DEFAULT_QUALITY_SCORE
236
+
237
+ def test_explicit_scores_override_cache(self, scorer):
238
+ """Passing source_scores directly should override cache."""
239
+ scorer._cached_scores = {"mcp:claude": 0.8}
240
+ override = {"mcp:claude": 0.3}
241
+
242
+ memory = {"created_by": "mcp:claude"}
243
+ assert scorer.get_source_boost(memory, source_scores=override) == 0.3
244
+
245
+ def test_source_protocol_fallback(self, scorer):
246
+ """If created_by is None, fall back to source_protocol."""
247
+ scorer._cached_scores = {"mcp": 0.7}
248
+ memory = {"created_by": None, "source_protocol": "mcp"}
249
+ assert scorer.get_source_boost(memory) == 0.7
250
+
251
+ def test_user_source(self, scorer):
252
+ """created_by='user' is the default from provenance_tracker."""
253
+ scorer._cached_scores = {"user": 0.6}
254
+ memory = {"created_by": "user"}
255
+ assert scorer.get_source_boost(memory) == 0.6
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # extract_source_id
260
+ # ---------------------------------------------------------------------------
261
+
262
+ class TestExtractSourceId:
263
+ def test_created_by_primary(self):
264
+ from src.learning.source_quality_scorer import SourceQualityScorer
265
+ memory = {"created_by": "mcp:claude-desktop", "source_protocol": "mcp"}
266
+ assert SourceQualityScorer._extract_source_id(memory) == "mcp:claude-desktop"
267
+
268
+ def test_source_protocol_fallback(self):
269
+ from src.learning.source_quality_scorer import SourceQualityScorer
270
+ memory = {"created_by": None, "source_protocol": "cli"}
271
+ assert SourceQualityScorer._extract_source_id(memory) == "cli"
272
+
273
+ def test_user_default(self):
274
+ from src.learning.source_quality_scorer import SourceQualityScorer
275
+ memory = {"created_by": "user"}
276
+ assert SourceQualityScorer._extract_source_id(memory) == "user"
277
+
278
+ def test_no_source_returns_none(self):
279
+ from src.learning.source_quality_scorer import SourceQualityScorer
280
+ memory = {"content": "no source info"}
281
+ assert SourceQualityScorer._extract_source_id(memory) is None
282
+
283
+ def test_empty_created_by(self):
284
+ from src.learning.source_quality_scorer import SourceQualityScorer
285
+ memory = {"created_by": ""}
286
+ # Empty string should fall through to source_protocol
287
+ result = SourceQualityScorer._extract_source_id(memory)
288
+ assert result is None
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Refresh & Summary
293
+ # ---------------------------------------------------------------------------
294
+
295
+ class TestRefreshAndSummary:
296
+ def test_refresh(self, scorer, memory_db):
297
+ _insert_memories(memory_db, [
298
+ {"content": "test", "created_by": "mcp:test"},
299
+ ])
300
+ scores = scorer.refresh()
301
+ assert isinstance(scores, dict)
302
+
303
+ def test_get_source_summary_empty(self, scorer):
304
+ summary = scorer.get_source_summary()
305
+ assert "No source quality data" in summary
306
+
307
+ def test_get_source_summary_with_data(self, scorer, memory_db, learning_db):
308
+ _insert_memories(memory_db, [
309
+ {"content": "test", "created_by": "mcp:claude"},
310
+ ])
311
+ scorer.compute_source_scores()
312
+ summary = scorer.get_source_summary()
313
+ assert "mcp:claude" in summary
314
+
315
+ def test_get_all_scores_empty(self, scorer):
316
+ all_scores = scorer.get_all_scores()
317
+ assert all_scores == {}
318
+
319
+ def test_get_all_scores_with_data(self, scorer, memory_db, learning_db):
320
+ _insert_memories(memory_db, [
321
+ {"content": "test", "created_by": "mcp:test"},
322
+ ])
323
+ scorer.compute_source_scores()
324
+ all_scores = scorer.get_all_scores()
325
+ assert "mcp:test" in all_scores
326
+ assert "quality_score" in all_scores["mcp:test"]
327
+ assert "positive_signals" in all_scores["mcp:test"]
328
+ assert "total_memories" in all_scores["mcp:test"]
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # No Learning DB
333
+ # ---------------------------------------------------------------------------
334
+
335
+ class TestNoLearningDb:
336
+ def test_scorer_without_learning_db(self, memory_db):
337
+ from src.learning.source_quality_scorer import SourceQualityScorer
338
+ scorer = SourceQualityScorer(
339
+ memory_db_path=memory_db,
340
+ learning_db=None,
341
+ )
342
+ # Should not crash
343
+ scores = scorer.compute_source_scores()
344
+ assert isinstance(scores, dict)
345
+
346
+ def test_boost_without_cache(self, memory_db):
347
+ from src.learning.source_quality_scorer import (
348
+ SourceQualityScorer, DEFAULT_QUALITY_SCORE,
349
+ )
350
+ scorer = SourceQualityScorer(
351
+ memory_db_path=memory_db,
352
+ learning_db=None,
353
+ )
354
+ memory = {"created_by": "mcp:anything"}
355
+ assert scorer.get_source_boost(memory) == DEFAULT_QUALITY_SCORE