| 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 |