| 1 | """Tests for scripts/quality_gate.py""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | |
| 7 | import scripts.quality_gate as quality_gate |
| 8 | |
| 9 | |
| 10 | def make_scored_repos(count: int, score: float = 65.0) -> list[dict]: |
| 11 | """Generate a list of scored repo dicts.""" |
| 12 | return [{"name": f"org/repo-{i}", "relevance_score": score} for i in range(count)] |
| 13 | |
| 14 | |
| 15 | class TestGetQualityConfig: |
| 16 | def test_defaults_when_empty(self): |
| 17 | config = quality_gate.get_quality_config({}) |
| 18 | assert config["min_repos_per_week"] == 5 |
| 19 | assert config["max_repos_per_week"] == 30 |
| 20 | assert config["min_quality_score"] == 60 |
| 21 | |
| 22 | def test_overrides(self): |
| 23 | config = quality_gate.get_quality_config({"quality": {"min_repos_per_week": 10}}) |
| 24 | assert config["min_repos_per_week"] == 10 |
| 25 | assert config["max_repos_per_week"] == 30 |
| 26 | |
| 27 | |
| 28 | class TestCheckQuality: |
| 29 | def test_ok_status(self): |
| 30 | repos = make_scored_repos(10, score=50.0) |
| 31 | result = quality_gate.check_quality( |
| 32 | repos, |
| 33 | {"min_repos_per_week": 5, "max_repos_per_week": 30}, |
| 34 | {"min_relevance_score": 40}, |
| 35 | ) |
| 36 | assert result["status"] == "ok" |
| 37 | assert result["repos_passing"] == 10 |
| 38 | assert result["repos_scored"] == 10 |
| 39 | assert result["warnings"] == [] |
| 40 | |
| 41 | def test_below_threshold(self): |
| 42 | repos = make_scored_repos(3, score=50.0) |
| 43 | result = quality_gate.check_quality( |
| 44 | repos, |
| 45 | {"min_repos_per_week": 5, "max_repos_per_week": 30}, |
| 46 | {"min_relevance_score": 40}, |
| 47 | ) |
| 48 | assert result["status"] == "below_threshold" |
| 49 | assert result["repos_passing"] == 3 |
| 50 | assert len(result["warnings"]) == 1 |
| 51 | assert "threshold is 5" in result["warnings"][0] |
| 52 | |
| 53 | def test_above_maximum(self): |
| 54 | repos = make_scored_repos(35, score=50.0) |
| 55 | result = quality_gate.check_quality( |
| 56 | repos, |
| 57 | {"min_repos_per_week": 5, "max_repos_per_week": 30}, |
| 58 | {"min_relevance_score": 40}, |
| 59 | ) |
| 60 | assert result["status"] == "above_maximum" |
| 61 | assert result["repos_passing"] == 35 |
| 62 | assert len(result["warnings"]) == 1 |
| 63 | assert "noise" in result["warnings"][0].lower() |
| 64 | |
| 65 | def test_repos_below_score_not_counted(self): |
| 66 | repos = make_scored_repos(10, score=30.0) |
| 67 | result = quality_gate.check_quality( |
| 68 | repos, |
| 69 | {"min_repos_per_week": 5, "max_repos_per_week": 30}, |
| 70 | {"min_relevance_score": 40}, |
| 71 | ) |
| 72 | assert result["repos_passing"] == 0 |
| 73 | assert result["status"] == "below_threshold" |
| 74 | |
| 75 | def test_empty_repos(self): |
| 76 | result = quality_gate.check_quality( |
| 77 | [], |
| 78 | {"min_repos_per_week": 5, "max_repos_per_week": 30}, |
| 79 | {"min_relevance_score": 40}, |
| 80 | ) |
| 81 | assert result["status"] == "below_threshold" |
| 82 | assert result["repos_passing"] == 0 |
| 83 | assert result["repos_scored"] == 0 |
| 84 | |
| 85 | |
| 86 | class TestEmitWarnings: |
| 87 | def test_prints_annotations(self, capsys): |
| 88 | quality_gate.emit_warnings(["Something is wrong", "Another issue"]) |
| 89 | captured = capsys.readouterr() |
| 90 | assert "::warning::Something is wrong" in captured.out |
| 91 | assert "::warning::Another issue" in captured.out |
| 92 | |
| 93 | def test_no_output_when_empty(self, capsys): |
| 94 | quality_gate.emit_warnings([]) |
| 95 | captured = capsys.readouterr() |
| 96 | assert captured.out == "" |
| 97 | |
| 98 | |
| 99 | class TestWriteMetric: |
| 100 | def test_writes_json(self, tmp_path, monkeypatch): |
| 101 | monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t) |
| 102 | metric = { |
| 103 | "repos_scored": 10, |
| 104 | "repos_passing": 8, |
| 105 | "threshold": 5, |
| 106 | "status": "ok", |
| 107 | "warnings": [], |
| 108 | } |
| 109 | path = quality_gate.write_metric("ai-ml", metric, "2026-W21") |
| 110 | assert path.exists() |
| 111 | data = json.loads(path.read_text()) |
| 112 | assert data["week"] == "2026-W21" |
| 113 | assert data["topic"] == "ai-ml" |
| 114 | assert data["repos_passing"] == 8 |
| 115 | assert data["status"] == "ok" |
| 116 | assert "warnings" not in data |
| 117 | |
| 118 | |
| 119 | class TestLoadScoredRepos: |
| 120 | def test_loads_valid_json(self, tmp_path): |
| 121 | path = tmp_path / "scored.json" |
| 122 | repos = make_scored_repos(5) |
| 123 | path.write_text(json.dumps(repos)) |
| 124 | result = quality_gate.load_scored_repos(path) |
| 125 | assert len(result) == 5 |
| 126 | |
| 127 | def test_missing_file(self, tmp_path): |
| 128 | result = quality_gate.load_scored_repos(tmp_path / "missing.json") |
| 129 | assert result == [] |
| 130 | |
| 131 | def test_invalid_json(self, tmp_path): |
| 132 | path = tmp_path / "bad.json" |
| 133 | path.write_text("not json") |
| 134 | result = quality_gate.load_scored_repos(path) |
| 135 | assert result == [] |
| 136 | |
| 137 | def test_non_list_json(self, tmp_path): |
| 138 | path = tmp_path / "obj.json" |
| 139 | path.write_text(json.dumps({"repos": []})) |
| 140 | result = quality_gate.load_scored_repos(path) |
| 141 | assert result == [] |
| 142 | |
| 143 | |
| 144 | class TestMain: |
| 145 | def test_with_input_file(self, tmp_path, monkeypatch): |
| 146 | monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t) |
| 147 | scored_path = tmp_path / "scored.json" |
| 148 | scored_path.write_text(json.dumps(make_scored_repos(10, score=50.0))) |
| 149 | |
| 150 | config_path = tmp_path / "config.yml" |
| 151 | config_path.write_text( |
| 152 | "topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n" |
| 153 | "quality:\n min_repos_per_week: 5\n max_repos_per_week: 30\n" |
| 154 | ) |
| 155 | |
| 156 | result = quality_gate.main(["--input", str(scored_path), "--config", str(config_path)]) |
| 157 | assert result == 0 |
| 158 | |
| 159 | def test_missing_input_no_crash(self, tmp_path, monkeypatch): |
| 160 | monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t) |
| 161 | config_path = tmp_path / "config.yml" |
| 162 | config_path.write_text("topic:\n id: test\n") |
| 163 | |
| 164 | result = quality_gate.main( |
| 165 | ["--input", str(tmp_path / "nope.json"), "--config", str(config_path)] |
| 166 | ) |
| 167 | assert result == 0 |
| 168 | |
| 169 | def test_always_exits_zero(self, tmp_path, monkeypatch): |
| 170 | monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t) |
| 171 | scored_path = tmp_path / "scored.json" |
| 172 | scored_path.write_text(json.dumps(make_scored_repos(2, score=50.0))) |
| 173 | |
| 174 | config_path = tmp_path / "config.yml" |
| 175 | config_path.write_text( |
| 176 | "topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n" |
| 177 | "quality:\n min_repos_per_week: 10\n max_repos_per_week: 30\n" |
| 178 | ) |
| 179 | |
| 180 | result = quality_gate.main(["--input", str(scored_path), "--config", str(config_path)]) |
| 181 | assert result == 0 |
| 182 | |
| 183 | def test_topic_override(self, tmp_path, monkeypatch): |
| 184 | monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t) |
| 185 | scored_path = tmp_path / "scored.json" |
| 186 | scored_path.write_text(json.dumps(make_scored_repos(10, score=50.0))) |
| 187 | |
| 188 | config_path = tmp_path / "config.yml" |
| 189 | config_path.write_text("topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n") |
| 190 | |
| 191 | result = quality_gate.main( |
| 192 | [ |
| 193 | "--input", |
| 194 | str(scored_path), |
| 195 | "--config", |
| 196 | str(config_path), |
| 197 | "--topic", |
| 198 | "custom-topic", |
| 199 | ] |
| 200 | ) |
| 201 | assert result == 0 |
| 202 | # Verify metric written with correct topic |
| 203 | files = list((tmp_path / "metrics" / "custom-topic").glob("quality-*.json")) |
| 204 | assert len(files) == 1 |
| 205 | data = json.loads(files[0].read_text()) |
| 206 | assert data["topic"] == "custom-topic" |
| 207 | |
| 208 | |
| 209 | class TestWeekSlug: |
| 210 | def test_format(self): |
| 211 | from datetime import datetime, timezone |
| 212 | |
| 213 | dt = datetime(2026, 5, 18, tzinfo=timezone.utc) |
| 214 | result = quality_gate.week_slug(dt) |
| 215 | assert result == "2026-W21" |