GRM-45: fix: generate self-contained SVG badges instead of shields.io JSON

shields.io can't fetch JSON from our self-hosted Gitea instance (not
publicly reachable), so badges showed "unknown". Switched to generating
self-contained SVG badge files that are served directly by Gitea's raw
file API — no external service needed.

Changes:
- generate_badges.py: Added render_svg() to produce shields.io-style
  SVG badges with gradient, rounded corners, and Verdana font
- Replaced xml.sax.saxutils.escape with a simple _xml_escape() to
  avoid bandit B406 warning (no defusedxml dependency needed)
- CI workflow: Push .svg files instead of .json to badges branch
- README.md and docs/index.md: Updated badge URLs to use raw SVG
  from the badges branch instead of shields.io endpoint

Also fixed:
- Coverage regex now handles 100% without decimal (was 100.00%)
- doc_coverage.py: Removed redundant % in pct variable that caused
  double-percent (100%%) in output

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Emil Simeonov
2026-06-22 00:50:22 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 28b4acf323
commit 749b4d025f
5 changed files with 129 additions and 53 deletions
+48 -17
View File
@@ -1,12 +1,12 @@
"""Unit tests for scripts/generate_badges.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from scripts.generate_badges import (
COLOR_HEX,
cli,
coverage_color,
doc_coverage_color,
@@ -16,6 +16,7 @@ from scripts.generate_badges import (
generate_badges,
make_badge,
read_version,
render_svg,
run_command,
)
@@ -42,6 +43,38 @@ class TestMakeBadge:
}
class TestRenderSvg:
def test_generates_valid_svg(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert svg.startswith("<svg")
assert svg.endswith("</svg>\n")
assert "coverage" in svg
assert "100%" in svg
assert COLOR_HEX["brightgreen"] in svg
def test_uses_color_hex_for_known_colors(self) -> None:
svg = render_svg("tests", "573 passing", "brightgreen")
assert "#4c1" in svg
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
svg = render_svg("label", "msg", "#abc123")
assert "#abc123" in svg
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
svg = render_svg("label", "msg", "nonexistent")
assert "#9f9f9f" in svg
def test_escapes_xml_special_chars(self) -> None:
svg = render_svg("label", "<script>", "red")
assert "<script>" not in svg
assert "&lt;script&gt;" in svg
def test_has_correct_dimensions(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert 'width="' in svg
assert 'height="20"' in svg
class TestExtractCoverage:
def test_extracts_from_total_line(self) -> None:
output = (
@@ -54,6 +87,10 @@ class TestExtractCoverage:
output = "TOTAL 100 20 80.00%\n"
assert extract_coverage(output) == 80.0
def test_extracts_without_decimal(self) -> None:
output = "TOTAL 1900 0 100%\n"
assert extract_coverage(output) == 100.0
def test_returns_none_when_no_match(self) -> None:
assert extract_coverage("no coverage here") is None
@@ -135,7 +172,7 @@ class TestGenerateBadges:
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=543)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files(
self,
@@ -146,28 +183,24 @@ class TestGenerateBadges:
mock_run: MagicMock,
tmp_path: Path,
) -> None:
# run_command is called for pytest, doc_coverage, ruff, ruff format, pyright, bandit
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
# Verify all expected badges are generated
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected
# Verify JSON files were written
# Verify SVG files were written
for name in expected:
json_file = tmp_path / f"{name}.json"
assert json_file.exists()
data = json.loads(json_file.read_text())
assert data["schemaVersion"] == 1
assert "label" in data
assert "message" in data
assert "color" in data
svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists()
content = svg_file.read_text()
assert content.startswith("<svg")
assert "</svg>" in content
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=543)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
@@ -186,7 +219,7 @@ class TestGenerateBadges:
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=543)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
@@ -197,8 +230,6 @@ class TestGenerateBadges:
mock_run: MagicMock,
tmp_path: Path,
) -> None:
# First call (pytest) succeeds, second (doc_coverage) succeeds,
# third (ruff check) fails
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
@@ -240,7 +271,7 @@ class TestCli:
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "543 passing", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"),
}
runner = CliRunner()
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])