MIL-010 tasks 4 and 5: counter, coverage reader and badge renderer

Task: MIL-010#4
Task: MIL-010#5
Refs #158
Refs #159
This commit is contained in:
2026-10-14 13:48:04 +08:00
parent 6c3544837c
commit 5bc14d789d
4 changed files with 1200 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
"""Offline tests for tools/badges.py (ADR-0010): the coverage reader and the SVG renderer."""
import importlib.util
import json
import xml.etree.ElementTree as ElementTree
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
_SPEC = importlib.util.spec_from_file_location(
"badges", REPO_ROOT / "tools" / "badges.py"
)
assert _SPEC is not None and _SPEC.loader is not None
badges = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(badges)
DTD = (
'<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">'
)
def report(
tmp_path: Path, attributes: str, root: str = "coverage", prolog: str = ""
) -> Path:
path = tmp_path / "coverage.xml"
path.write_text(
f'<?xml version="1.0" ?>\n{prolog}<{root} {attributes}><packages/></{root}>\n'
)
return path
class TestReadCoverage:
@pytest.mark.parametrize(
("covered", "valid", "percent"),
[
(0, 10, 0),
(1, 3, 33),
(2, 3, 67),
(5, 8, 63),
(87, 100, 87),
(1, 200, 1),
(10, 10, 100),
],
)
def test_percent_is_covered_over_valid_rounded_half_up(
self, tmp_path, covered, valid, percent
):
path = report(
tmp_path, f'lines-covered="{covered}" lines-valid="{valid}" line-rate="0"'
)
assert badges.read_coverage(path) == percent
def test_the_half_rounds_up(self, tmp_path):
assert (
badges.read_coverage(report(tmp_path, 'lines-covered="1" lines-valid="8"'))
== 13
)
def test_line_rate_is_used_when_the_counts_are_absent(self, tmp_path):
assert badges.read_coverage(report(tmp_path, 'line-rate="0.875"')) == 88
def test_a_document_type_declaration_is_accepted(self, tmp_path):
path = report(tmp_path, 'lines-covered="9" lines-valid="10"', prolog=DTD + "\n")
assert badges.read_coverage(path) == 90
def test_missing_file_names_the_file(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="nope.xml' does not exist"):
badges.read_coverage(tmp_path / "nope.xml")
def test_malformed_xml_names_the_file(self, tmp_path):
path = tmp_path / "bad.xml"
path.write_text("<coverage lines-valid=")
with pytest.raises(
badges.ConfigurationError, match="bad.xml' is not well-formed"
):
badges.read_coverage(path)
def test_other_xml_is_not_a_cobertura_report(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="not a Cobertura report"):
badges.read_coverage(report(tmp_path, 'lines-valid="1"', root="testsuite"))
def test_no_valid_lines_is_a_failure_and_not_zero_percent(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="has no valid lines"):
badges.read_coverage(report(tmp_path, 'lines-covered="0" lines-valid="0"'))
def test_covered_above_valid_is_refused(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="outside 0..lines-valid"):
badges.read_coverage(report(tmp_path, 'lines-covered="5" lines-valid="4"'))
def test_report_without_any_rate_is_refused(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="neither lines-valid"):
badges.read_coverage(report(tmp_path, 'branch-rate="1"'))
def test_line_rate_outside_zero_to_one_is_refused(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="outside 0..1"):
badges.read_coverage(report(tmp_path, 'line-rate="1.5"'))
def test_values_that_are_not_numbers_are_refused(self, tmp_path):
with pytest.raises(badges.ConfigurationError, match="not a number"):
badges.read_coverage(report(tmp_path, 'lines-covered="a" lines-valid="b"'))
with pytest.raises(badges.ConfigurationError, match="not a number"):
badges.read_coverage(report(tmp_path, 'line-rate="high"'))
def test_entity_declarations_are_refused(self, tmp_path):
prolog = '<!DOCTYPE coverage [<!ENTITY x "y">]>\n'
with pytest.raises(badges.ConfigurationError, match="declares entities"):
badges.read_coverage(
report(tmp_path, 'lines-covered="1" lines-valid="2"', prolog=prolog)
)
class TestColor:
@pytest.mark.parametrize(
("percent", "color"),
[
(0, badges.RED),
(59, badges.RED),
(60, badges.YELLOW),
(79, badges.YELLOW),
(80, badges.GREEN),
(100, badges.GREEN),
],
)
def test_thresholds_are_warn_and_good(self, percent, color):
assert badges.coverage_color(percent, 60, 80) == color
def test_thresholds_can_be_moved(self):
assert badges.coverage_color(50, 40, 50) == badges.GREEN
assert badges.coverage_color(39, 40, 50) == badges.RED
class TestRender:
def test_is_valid_xml_with_title_and_text_alternative(self):
svg = badges.render("code files", "1,234", badges.BLUE)
root = ElementTree.fromstring(svg)
ns = "{http://www.w3.org/2000/svg}"
assert root.tag == ns + "svg"
assert root.get("role") == "img"
assert root.get("aria-label") == "code files: 1,234"
assert root.find(ns + "title").text == "code files: 1,234"
def test_same_input_gives_the_same_bytes_and_has_no_script_or_link(self):
first = badges.render("coverage", "87%", badges.GREEN)
assert first == badges.render("coverage", "87%", badges.GREEN)
for forbidden in (
"<script",
"href",
"<image",
"<style",
"http://www.w3.org/1999/xlink",
):
assert forbidden not in first
def test_width_follows_the_number_of_characters(self):
narrow = ElementTree.fromstring(badges.render("a", "1", badges.BLUE)).get(
"width"
)
wide = ElementTree.fromstring(badges.render("a", "1,000", badges.BLUE)).get(
"width"
)
assert int(wide) - int(narrow) == 4 * badges.CHAR_WIDTH
def test_text_is_escaped(self):
svg = badges.render("a<b", 'c&"d', badges.BLUE)
assert "a&lt;b" in svg and "c&amp;&quot;d" in svg
ElementTree.fromstring(svg)
def test_text_colour_has_the_better_contrast(self):
assert badges._text_color("#555555") == "#ffffff"
assert badges._text_color(badges.GREEN) == "#000000"
def metrics_file(tmp_path: Path, files: int = 3, lines: int = 1200) -> Path:
path = tmp_path / "metrics.json"
path.write_text(
json.dumps(
{"files": files, "lines": lines, "comment": 5, "blank": 6, "languages": {}}
)
)
return path
class TestMain:
def test_writes_three_badges_and_the_metrics(self, tmp_path, capsys):
out = tmp_path / "out"
cov = report(tmp_path, 'lines-covered="87" lines-valid="100"')
code = badges.main(
[
"--metrics",
str(metrics_file(tmp_path)),
"--output-dir",
str(out),
"--coverage-report",
str(cov),
]
)
assert code == 0
assert sorted(p.name for p in out.iterdir()) == [
"code-files.svg",
"code-lines.svg",
"code-metrics.json",
"coverage.svg",
]
assert (
'aria-label="lines of code: 1,200"' in (out / "code-lines.svg").read_text()
)
assert 'aria-label="coverage: 87%"' in (out / "coverage.svg").read_text()
assert f'fill="{badges.GREEN}"' in (out / "coverage.svg").read_text()
assert b"\r" not in (out / "code-files.svg").read_bytes()
assert "wrote" in capsys.readouterr().out
def test_output_is_byte_identical_on_a_second_run(self, tmp_path):
args = [
"--metrics",
str(metrics_file(tmp_path)),
"--output-dir",
str(tmp_path / "o"),
]
badges.main(args)
first = {p.name: p.read_bytes() for p in (tmp_path / "o").iterdir()}
badges.main(args)
assert first == {p.name: p.read_bytes() for p in (tmp_path / "o").iterdir()}
def test_without_a_report_no_coverage_badge_and_a_notice(self, tmp_path, capsys):
out = tmp_path / "out"
assert (
badges.main(
["--metrics", str(metrics_file(tmp_path)), "--output-dir", str(out)]
)
== 0
)
assert not (out / "coverage.svg").exists()
assert "::notice::No coverage-report given" in capsys.readouterr().out
def test_a_bad_report_fails_and_writes_nothing(self, tmp_path, capsys):
out = tmp_path / "out"
code = badges.main(
[
"--metrics",
str(metrics_file(tmp_path)),
"--output-dir",
str(out),
"--coverage-report",
str(tmp_path / "missing.xml"),
]
)
assert code == 1
assert "::error::[configuration] coverage-report" in capsys.readouterr().out
assert not out.exists()
@pytest.mark.parametrize(
("warn", "good", "message"),
[
("x", "80", "coverage-warn 'x'"),
("60", "101", "coverage-good '101'"),
("-1", "80", "coverage-warn '-1'"),
("90", "80", "above coverage-good"),
],
)
def test_invalid_thresholds_fail(self, tmp_path, capsys, warn, good, message):
code = badges.main(
[
"--metrics",
str(metrics_file(tmp_path)),
"--output-dir",
str(tmp_path / "o"),
"--coverage-warn",
warn,
"--coverage-good",
good,
]
)
assert code == 1
assert message in capsys.readouterr().out
def test_unreadable_metrics_fail(self, tmp_path, capsys):
bad = tmp_path / "m.json"
bad.write_text('{"files": 1}')
code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")])
assert code == 1
assert "cannot be read" in capsys.readouterr().out
+317
View File
@@ -0,0 +1,317 @@
"""Offline tests for tools/count_loc.py (ADR-0010): hand-counted fixtures per language."""
import importlib.util
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
_SPEC = importlib.util.spec_from_file_location(
"count_loc", REPO_ROOT / "tools" / "count_loc.py"
)
assert _SPEC is not None and _SPEC.loader is not None
count_loc = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(count_loc)
LANGUAGE = {language.name: language for language in count_loc.LANGUAGES}
# name -> (source, code, comment, blank), counted by hand.
FIXTURES = {
"Python": (
"# a comment\n"
"x = 1 # trailing comment\n"
"\n"
's = "a # not a comment"\n'
"t = 'it''s # still a string'\n"
'"""docstring\n'
"# inside a string\n"
"\n"
'"""\n'
"y = 2", # no final newline
7,
1,
2,
),
"Shell": (
"#!/usr/bin/env bash\n"
"# comment\n"
'echo "$#" ${#args[@]} a#b\n'
"\n"
" # indented comment\n"
"echo 'it # stays' # but this is a comment\n"
"ls | wc -l;# after a semicolon\n",
3,
3,
1,
),
"PowerShell": (
'<# block\ncomment #>\nWrite-Host "a # b"\n# line\n$x = 1 # trailing\n',
2,
3,
0,
),
"C": (
"/* header\n"
" * text\n"
" */\n"
"#include <stdio.h>\n"
"\n"
"int main(void) { /* inline */ return 0; }\n"
"char quote = '\"'; // a double quote in a char\n"
'const char *url = "http://example.com";\n'
"// done\n",
4,
4,
1,
),
"C++": (
'int a = 0; // one\n/* block */\nauto s = "/* not a comment */";\n',
2,
1,
0,
),
"C#": (
"// line\n"
"using System;\n"
"/// <summary>doc</summary>\n"
'class A { string s = "// no"; }\n',
2,
2,
0,
),
"Java": (
"/** doc */\nclass A {\n // c\n int x; /* t */\n}\n",
3,
2,
0,
),
"JavaScript": (
"const a = `line one\n"
"// in a template\n"
"line three`;\n"
"// real comment\n"
"const b = 'x // y';\n"
"/* a */ /* b */\n",
4,
2,
0,
),
"TypeScript": (
"let n: number = 1; // c\n\n/*\n * block\n */\n",
1,
3,
1,
),
"Go": (
"package main\n// c\nvar r = '/'\nvar raw = `// not a comment\n`\n",
4,
1,
0,
),
"Rust": (
"/* outer /* nested */ still a comment */\n"
"fn f<'a>(x: &'a str) -> &'a str { x } // lifetimes\n"
"let c = '\\'';\n"
"// end\n",
2,
2,
0,
),
"SQL": (
"-- query\n"
"SELECT '-- not a comment' FROM t; -- but this is\n"
"/* a\n"
" block */\n"
"\n",
1,
3,
1,
),
}
@pytest.mark.parametrize("name", sorted(FIXTURES))
def test_fixture_counts_equal_the_hand_count(name):
source, code, comment, blank = FIXTURES[name]
assert count_loc.classify_text(source, LANGUAGE[name]) == (code, comment, blank)
def test_every_language_has_a_fixture():
assert set(FIXTURES) == set(LANGUAGE)
class TestClassify:
def test_empty_text_has_no_lines(self):
assert count_loc.classify_text("", LANGUAGE["Python"]) == (0, 0, 0)
def test_windows_line_endings_are_one_line_each(self):
assert count_loc.classify_text("a = 1\r\n# c\r\n\r\n", LANGUAGE["Python"]) == (
1,
1,
1,
)
def test_blank_line_inside_a_block_comment_is_blank(self):
assert count_loc.classify_text("/*\n\n*/\n", LANGUAGE["C"]) == (0, 2, 1)
def test_code_after_a_closing_block_comment_is_code(self):
assert count_loc.classify_text("/* a\n b */ x();\n", LANGUAGE["C"]) == (1, 1, 0)
def test_unterminated_string_ends_at_end_of_line(self):
assert count_loc.classify_text('s = "open // x\n// c\n', LANGUAGE["C"]) == (
1,
1,
0,
)
def test_unterminated_backtick_string_continues(self):
assert count_loc.classify_text("a = `x\n// y\n", LANGUAGE["JavaScript"]) == (
2,
0,
0,
)
def test_escaped_quote_does_not_end_the_string(self):
assert count_loc.classify_text('s = "a \\" // b";\n', LANGUAGE["C"]) == (
1,
0,
0,
)
class TestPatterns:
def test_star_stops_at_a_slash_and_double_star_does_not(self):
excludes = count_loc.Excludes(["src/*.py", "docs/**"])
assert excludes.matches("a.py", "src/a.py")
assert not excludes.matches("a.py", "src/sub/a.py")
assert excludes.matches("x.py", "docs/a/b/x.py")
def test_name_pattern_matches_anywhere(self):
excludes = count_loc.Excludes(["gen_*", "?.py"])
assert excludes.matches("gen_a.py", "deep/er/gen_a.py")
assert excludes.matches("a.py", "x/a.py")
assert not excludes.matches("ab.py", "x/ab.py")
def test_empty_patterns_are_ignored(self):
assert not count_loc.Excludes(["", " / "]).matches("a.py", "a.py")
def write(root: Path, name: str, content: str | bytes) -> None:
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
class TestCount:
def test_counts_files_and_lines_per_language(self, tmp_path):
write(tmp_path, "a.py", "x = 1\n# c\n\n")
write(tmp_path, "pkg/b.py", "y = 2\n")
write(tmp_path, "run.sh", "echo hi\n")
write(tmp_path, "README.md", "# not code\n")
result = count_loc.count(tmp_path)
assert result == {
"files": 3,
"lines": 3,
"comment": 1,
"blank": 1,
"languages": {
"Python": {"files": 2, "lines": 2, "comment": 1, "blank": 1},
"Shell": {"files": 1, "lines": 1, "comment": 0, "blank": 0},
},
}
def test_default_excludes_hidden_vendored_and_shared_script_directories(
self, tmp_path
):
write(tmp_path, "keep.py", "a = 1\n")
for directory in (
".git",
".venv",
"node_modules",
"vendor",
"shared-scripts",
"build",
):
write(tmp_path, f"{directory}/skip.py", "b = 2\n")
assert count_loc.count(tmp_path)["files"] == 1
def test_exclude_patterns_add_to_the_defaults(self, tmp_path):
write(tmp_path, "keep.py", "a = 1\n")
write(tmp_path, "gen/out.py", "b = 2\n")
write(tmp_path, "src/gen_x.py", "c = 3\n")
write(tmp_path, "src/keep2.py", "d = 4\n")
result = count_loc.count(tmp_path, ["gen", "gen_*"])
assert result["files"] == 2
def test_binary_file_with_a_code_extension_is_not_counted(self, tmp_path):
write(tmp_path, "blob.py", b"a = 1\n\x00\x01")
assert count_loc.count(tmp_path)["files"] == 0
def test_invalid_utf8_and_a_byte_order_mark_are_read(self, tmp_path):
write(tmp_path, "a.py", b"\xef\xbb\xbfx = 1\n\xff\xfe = 2\n")
assert count_loc.count(tmp_path)["lines"] == 2
def test_empty_code_file_is_a_file_with_no_lines(self, tmp_path):
write(tmp_path, "empty.py", "")
result = count_loc.count(tmp_path)
assert (result["files"], result["lines"]) == (1, 0)
def test_directory_without_code_files_counts_zero(self, tmp_path):
write(tmp_path, "notes.txt", "hello\n")
result = count_loc.count(tmp_path)
assert result == {
"files": 0,
"lines": 0,
"comment": 0,
"blank": 0,
"languages": {},
}
def test_symbolic_link_is_not_followed(self, tmp_path):
real = tmp_path / "real"
write(real, "a.py", "x = 1\n")
link = tmp_path / "link.py"
try:
link.symlink_to(real / "a.py")
except (OSError, NotImplementedError):
pytest.skip("symbolic links are not available here")
assert count_loc.count(real)["files"] == 1
assert count_loc.count(tmp_path)["files"] == 1 # real/a.py only, not link.py
class TestMain:
def test_prints_json_to_standard_output(self, tmp_path, capsys):
write(tmp_path, "a.py", "x = 1\n")
assert count_loc.main(["--directory", str(tmp_path)]) == 0
out = json.loads(capsys.readouterr().out)
assert out["files"] == 1 and out["lines"] == 1
def test_writes_the_output_file_with_lf_endings(self, tmp_path):
write(tmp_path, "src/a.py", "x = 1\n")
target = tmp_path / "result.json"
assert (
count_loc.main(
["--directory", str(tmp_path / "src"), "--output", str(target)]
)
== 0
)
data = target.read_bytes()
assert b"\r" not in data and json.loads(data)["files"] == 1
def test_exclude_option_is_a_comma_list(self, tmp_path, capsys):
write(tmp_path, "a.py", "x = 1\n")
write(tmp_path, "b.py", "y = 1\n")
write(tmp_path, "c.py", "z = 1\n")
assert (
count_loc.main(["--directory", str(tmp_path), "--exclude", "a.py, b.py"])
== 0
)
assert json.loads(capsys.readouterr().out)["files"] == 1
def test_missing_directory_is_a_configuration_error(self, tmp_path, capsys):
assert count_loc.main(["--directory", str(tmp_path / "nope")]) == 1
assert "::error::[configuration]" in capsys.readouterr().out