main
py 442 lines 13.1 KB
Raw
1 """Tests for scripts/score_repos.py scoring pipeline."""
2
3 from __future__ import annotations
4
5 import json
6 from datetime import UTC, datetime, timedelta
7
8 import pytest
9
10 from scripts.score_repos import (
11 compute_relevance_score,
12 find_latest_raw_json,
13 get_scoring_config,
14 load_config,
15 main,
16 score_age,
17 score_language,
18 score_repos,
19 score_stars,
20 score_stars_gained,
21 score_topics,
22 )
23
24 # --- Fixtures ---
25
26
27 @pytest.fixture
28 def scoring_config():
29 """Default scoring config matching squadscope.topic.yml."""
30 return {
31 "min_stars": 20,
32 "min_stars_gained": 10,
33 "max_age_days": 365,
34 "min_relevance_score": 40,
35 "language_boost": {"Python": 1.2, "Jupyter Notebook": 1.1},
36 "topic_relevance": [
37 "machine-learning",
38 "deep-learning",
39 "artificial-intelligence",
40 "neural-network",
41 "llm",
42 "transformers",
43 ],
44 }
45
46
47 @pytest.fixture
48 def sample_repo():
49 """A typical high-quality AI/ML repo record."""
50 return {
51 "name": "awesome-ml",
52 "owner": "researcher",
53 "full_name": "researcher/awesome-ml",
54 "description": "A machine learning framework",
55 "language": "Python",
56 "stars": 500,
57 "forks": 50,
58 "created_at": (datetime.now(UTC) - timedelta(days=30)).isoformat(),
59 "topics": ["machine-learning", "deep-learning", "python"],
60 "license": "MIT",
61 "url": "https://github.com/researcher/awesome-ml",
62 "stars_gained": 100,
63 }
64
65
66 @pytest.fixture
67 def config_file(tmp_path):
68 """Create a temporary config YAML file."""
69 config = {
70 "topic": {
71 "id": "ai-ml",
72 "name": "AI & ML",
73 "description": "Test topic",
74 },
75 "queries": {"primary": ["topic:machine-learning"]},
76 "scoring": {
77 "min_stars": 20,
78 "min_stars_gained": 10,
79 "max_age_days": 365,
80 "min_relevance_score": 40,
81 "language_boost": {"Python": 1.2},
82 "topic_relevance": ["machine-learning", "deep-learning"],
83 },
84 }
85 path = tmp_path / "test_config.yml"
86 import yaml
87
88 path.write_text(yaml.dump(config), encoding="utf-8")
89 return path
90
91
92 # --- Test score_stars ---
93
94
95 class TestScoreStars:
96 def test_zero_stars(self):
97 assert score_stars(0) == 0.0
98
99 def test_negative_stars(self):
100 assert score_stars(-5) == 0.0
101
102 def test_low_stars(self):
103 score = score_stars(10)
104 assert 0 < score < 25
105
106 def test_high_stars(self):
107 score = score_stars(10000)
108 assert score == 25.0
109
110 def test_very_high_stars_capped(self):
111 score = score_stars(1_000_000)
112 assert score == 25.0
113
114 def test_diminishing_returns(self):
115 s10 = score_stars(10)
116 s100 = score_stars(100)
117 s1000 = score_stars(1000)
118 # Log scale means equal absolute gains per 10x, but relative gains shrink
119 assert s100 > s10
120 assert s1000 > s100
121 # Verify sublinear: doubling stars doesn't double score
122 assert score_stars(200) < score_stars(100) * 2
123
124
125 # --- Test score_stars_gained ---
126
127
128 class TestScoreStarsGained:
129 def test_zero_gained(self):
130 assert score_stars_gained(0) == 0.0
131
132 def test_negative_gained(self):
133 assert score_stars_gained(-10) == 0.0
134
135 def test_moderate_gained(self):
136 score = score_stars_gained(50)
137 assert 0 < score < 25
138
139 def test_high_gained_capped(self):
140 score = score_stars_gained(10000)
141 assert score == 25.0
142
143
144 # --- Test score_language ---
145
146
147 class TestScoreLanguage:
148 def test_no_language(self):
149 assert score_language(None, {"Python": 1.2}) == 7.5
150
151 def test_no_boost_config(self):
152 assert score_language("Python", {}) == 7.5
153
154 def test_matching_language_boost(self):
155 score = score_language("Python", {"Python": 1.2})
156 assert score == pytest.approx(9.0)
157
158 def test_non_matching_language(self):
159 score = score_language("Rust", {"Python": 1.2})
160 assert score == 7.5
161
162 def test_high_boost_capped(self):
163 score = score_language("Python", {"Python": 3.0})
164 assert score == 15.0
165
166
167 # --- Test score_topics ---
168
169
170 class TestScoreTopics:
171 def test_no_repo_topics(self):
172 assert score_topics([], ["machine-learning"]) == 0.0
173
174 def test_no_relevance_list(self):
175 assert score_topics(["python"], []) == 0.0
176
177 def test_single_match(self):
178 score = score_topics(["machine-learning"], ["machine-learning", "deep-learning", "llm"])
179 assert score == pytest.approx(25.0 / 3)
180
181 def test_full_match(self):
182 topics = ["machine-learning", "deep-learning", "llm"]
183 relevance = ["machine-learning", "deep-learning", "llm", "transformers"]
184 score = score_topics(topics, relevance)
185 assert score == 25.0 # 3 matches, capped at 3
186
187 def test_case_insensitive(self):
188 score = score_topics(["Machine-Learning"], ["machine-learning", "deep-learning", "llm"])
189 assert score > 0
190
191 def test_no_overlap(self):
192 assert score_topics(["rust", "wasm"], ["machine-learning", "deep-learning", "llm"]) == 0.0
193
194
195 # --- Test score_age ---
196
197
198 class TestScoreAge:
199 def test_no_created_at(self):
200 assert score_age(None, 365) == 5.0
201
202 def test_invalid_date(self):
203 assert score_age("not-a-date", 365) == 5.0
204
205 def test_brand_new_repo(self):
206 now_iso = datetime.now(UTC).isoformat()
207 score = score_age(now_iso, 365)
208 assert score == pytest.approx(10.0, abs=0.1)
209
210 def test_old_repo_at_max_age(self):
211 old = (datetime.now(UTC) - timedelta(days=365)).isoformat()
212 score = score_age(old, 365)
213 assert score == pytest.approx(5.0, abs=0.1)
214
215 def test_very_old_repo_penalized(self):
216 ancient = (datetime.now(UTC) - timedelta(days=730)).isoformat()
217 score = score_age(ancient, 365)
218 assert score < 5.0
219
220 def test_extremely_old_repo_zero(self):
221 ancient = (datetime.now(UTC) - timedelta(days=1000)).isoformat()
222 score = score_age(ancient, 365)
223 assert score == pytest.approx(0.0, abs=0.5)
224
225
226 # --- Test compute_relevance_score ---
227
228
229 class TestComputeRelevanceScore:
230 def test_high_quality_repo(self, sample_repo, scoring_config):
231 score = compute_relevance_score(sample_repo, scoring_config)
232 assert 60 <= score <= 100
233
234 def test_low_quality_repo(self, scoring_config):
235 repo = {
236 "name": "old-thing",
237 "stars": 5,
238 "stars_gained": 0,
239 "language": "Shell",
240 "topics": [],
241 "created_at": (datetime.now(UTC) - timedelta(days=800)).isoformat(),
242 }
243 score = compute_relevance_score(repo, scoring_config)
244 assert score < 40
245
246 def test_empty_repo(self, scoring_config):
247 score = compute_relevance_score({}, scoring_config)
248 assert 0 <= score <= 100
249
250 def test_score_bounded(self, scoring_config):
251 repo = {
252 "stars": 1_000_000,
253 "stars_gained": 100_000,
254 "language": "Python",
255 "topics": ["machine-learning", "deep-learning", "llm", "transformers"],
256 "created_at": datetime.now(UTC).isoformat(),
257 }
258 score = compute_relevance_score(repo, scoring_config)
259 assert score <= 100.0
260
261
262 # --- Test score_repos ---
263
264
265 class TestScoreRepos:
266 def test_filters_below_threshold(self, scoring_config):
267 repos = [
268 {
269 "name": "good",
270 "stars": 500,
271 "stars_gained": 100,
272 "language": "Python",
273 "topics": ["machine-learning", "deep-learning"],
274 "created_at": datetime.now(UTC).isoformat(),
275 },
276 {
277 "name": "bad",
278 "stars": 2,
279 "stars_gained": 0,
280 "language": "Shell",
281 "topics": [],
282 "created_at": (datetime.now(UTC) - timedelta(days=800)).isoformat(),
283 },
284 ]
285 scored = score_repos(repos, scoring_config)
286 names = [r["name"] for r in scored]
287 assert "good" in names
288 assert "bad" not in names
289
290 def test_sorted_descending(self, scoring_config):
291 repos = [
292 {
293 "name": "medium",
294 "stars": 100,
295 "stars_gained": 20,
296 "language": "Python",
297 "topics": ["machine-learning"],
298 "created_at": datetime.now(UTC).isoformat(),
299 },
300 {
301 "name": "high",
302 "stars": 5000,
303 "stars_gained": 500,
304 "language": "Python",
305 "topics": ["machine-learning", "deep-learning", "llm"],
306 "created_at": datetime.now(UTC).isoformat(),
307 },
308 ]
309 scored = score_repos(repos, scoring_config)
310 assert len(scored) >= 1
311 if len(scored) >= 2:
312 assert scored[0]["relevance_score"] >= scored[1]["relevance_score"]
313
314 def test_adds_relevance_score_field(self, scoring_config):
315 repos = [
316 {
317 "name": "test",
318 "stars": 500,
319 "stars_gained": 50,
320 "language": "Python",
321 "topics": ["machine-learning"],
322 "created_at": datetime.now(UTC).isoformat(),
323 },
324 ]
325 scored = score_repos(repos, scoring_config)
326 assert len(scored) > 0
327 assert "relevance_score" in scored[0]
328 assert isinstance(scored[0]["relevance_score"], float)
329
330 def test_empty_input(self, scoring_config):
331 assert score_repos([], scoring_config) == []
332
333
334 # --- Test load_config ---
335
336
337 class TestLoadConfig:
338 def test_load_valid_config(self, config_file):
339 config = load_config(config_file)
340 assert "scoring" in config
341 assert config["scoring"]["min_stars"] == 20
342
343 def test_missing_file(self):
344 with pytest.raises(FileNotFoundError):
345 load_config("nonexistent.yml")
346
347
348 # --- Test get_scoring_config ---
349
350
351 class TestGetScoringConfig:
352 def test_with_scoring_section(self):
353 config = {"scoring": {"min_stars": 50, "language_boost": {"Go": 1.3}}}
354 sc = get_scoring_config(config)
355 assert sc["min_stars"] == 50
356 assert sc["language_boost"] == {"Go": 1.3}
357
358 def test_without_scoring_section(self):
359 sc = get_scoring_config({})
360 assert sc["min_stars"] == 20
361 assert sc["min_relevance_score"] == 40
362
363
364 # --- Test find_latest_raw_json ---
365
366
367 class TestFindLatestRawJson:
368 def test_no_directory(self):
369 assert find_latest_raw_json("nonexistent-topic-xyz") is None
370
371 def test_finds_latest(self, tmp_path, monkeypatch):
372 topic_dir = tmp_path / "raw" / "test-topic"
373 topic_dir.mkdir(parents=True)
374 (topic_dir / "2026-W20.json").write_text("[]")
375 (topic_dir / "2026-W21.json").write_text("[]")
376
377 monkeypatch.setattr("scripts.score_repos.raw_dir", lambda tid: topic_dir)
378 result = find_latest_raw_json("test-topic")
379 assert result is not None
380 assert "W21" in result.name
381
382
383 # --- Test CLI (main) ---
384
385
386 class TestMain:
387 def test_with_input_file(self, tmp_path, config_file):
388 repos = [
389 {
390 "name": "repo1",
391 "stars": 500,
392 "stars_gained": 100,
393 "language": "Python",
394 "topics": ["machine-learning", "deep-learning"],
395 "created_at": datetime.now(UTC).isoformat(),
396 },
397 ]
398 input_file = tmp_path / "input.json"
399 input_file.write_text(json.dumps(repos))
400 output_file = tmp_path / "output.json"
401
402 result = main(
403 ["--config", str(config_file), "--input", str(input_file), "--output", str(output_file)]
404 )
405 assert result == 0
406 scored = json.loads(output_file.read_text())
407 assert len(scored) == 1
408 assert "relevance_score" in scored[0]
409
410 def test_missing_input_file(self, config_file):
411 result = main(["--config", str(config_file), "--input", "no_such_file.json"])
412 assert result == 1
413
414 def test_invalid_json_content(self, tmp_path, config_file):
415 input_file = tmp_path / "bad.json"
416 input_file.write_text('{"not": "a list"}')
417 result = main(["--config", str(config_file), "--input", str(input_file)])
418 assert result == 1
419
420 def test_stdout_output(self, tmp_path, config_file, capsys):
421 repos = [
422 {
423 "name": "repo1",
424 "stars": 1000,
425 "stars_gained": 200,
426 "language": "Python",
427 "topics": ["machine-learning", "llm"],
428 "created_at": datetime.now(UTC).isoformat(),
429 },
430 ]
431 input_file = tmp_path / "input.json"
432 input_file.write_text(json.dumps(repos))
433
434 result = main(["--config", str(config_file), "--input", str(input_file)])
435 assert result == 0
436 output = capsys.readouterr().out
437 parsed = json.loads(output)
438 assert len(parsed) >= 1
439
440 def test_no_input_no_raw_files(self, config_file):
441 result = main(["--config", str(config_file), "--topic", "nonexistent-xyz-topic"])
442 assert result == 1