Files

333 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the unified-review HTML/Markdown report tool."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from code_review_graph.scoring import ( # noqa: E402
build_report_data,
render_markdown_report,
)
from code_review_graph.tools.scoring_tools import ( # noqa: E402
_load_report_template,
generate_report_func,
)
def _review_data() -> dict:
return {
"scope": "change-level",
"tier": "standard",
"timestamp": "2026-08-05T00:00:00Z",
"files": "app.py, main.py",
"baseline": "generic",
"verdict": "\u2705 PASS",
"quality_score": 7.0,
"counts": {"critical": 1, "informational": 2},
"metrics": {
"sql_risk": {"value": 0, "grade": "good", "note": "heuristic"},
"exception_coverage": {"value": 33.33, "grade": "warn", "note": "heuristic"},
},
"findings": [
{
"severity": "MAJOR",
"category": "Data",
"message": "Possible N+1 query",
"path": "src/app.py",
"line": 42,
"confidence": 7,
"fix": "Add eager loading",
},
],
"manual_review": ["Payment callback idempotency"],
"llm_judged": ["requirement_coverage", "logic_alignment"],
}
def _mkroot(tmp_path: Path) -> Path:
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
return tmp_path
class TestBuildReportData:
def test_normalises_review_data(self):
data = build_report_data(_review_data())
assert data["verdict"] == "\u2705 PASS"
assert data["tier"] == "standard"
assert data["scope"] == "change-level"
assert data["metrics"]["sql_risk"]["grade"] == "good"
assert len(data["issues"]) == 1
assert data["issues"][0]["location"] == "src/app.py:42"
assert "requirement_coverage" in data["llm_judged"]
def test_quality_score_and_counts_carried(self):
data = build_report_data(_review_data())
assert data["quality_score"] == 7.0
assert data["counts"] == {"critical": 1, "informational": 2}
def test_empty_findings(self):
rd = _review_data()
rd["findings"] = []
data = build_report_data(rd)
assert data["issues"] == []
def test_defaults(self):
data = build_report_data({})
assert data["scope"] == "change-level"
assert data["tier"] == "standard"
assert data["verdict"] == "\u274c FAIL"
assert data["issues"] == []
def test_reviewed_files_carried(self):
rd = _review_data()
rd["reviewed_files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.py", "src/b.py"]
def test_reviewed_files_default_empty(self):
data = build_report_data(_review_data())
assert data["reviewed_files"] == []
def test_files_list_normalised_to_string(self):
rd = _review_data()
rd["files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["files"] == "src/a.py, src/b.py"
def test_reviewed_files_fallback_from_files_list(self):
rd = _review_data()
rd["files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.py", "src/b.py"]
# explicit reviewed_files wins over the files-list fallback
rd["reviewed_files"] = ["src/a.py"]
assert build_report_data(rd)["reviewed_files"] == ["src/a.py"]
def test_reviewed_files_string_split(self):
"""A comma-separated STRING reviewed_files must be split into an array
(agents pass both formats). Guards against iterating the string
char-by-char (which rendered 's','e','r'... as fake files)."""
rd = _review_data()
rd["reviewed_files"] = "src/a.rs, src/b.rs, "
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.rs", "src/b.rs"]
md = render_markdown_report(rd)
assert "src/a.rs" in md
assert "src/b.rs" in md
# no single-char file entries
assert "- `s`" not in md
assert "265" not in md
def test_metrics_filters_non_objective_keys(self):
rd = _review_data()
rd["metrics"]["blast_radius"] = {
"direct_changed_nodes": 101,
"impacted_nodes": 13,
}
rd["metrics"]["objective_grade"] = "fail"
data = build_report_data(rd)
assert "blast_radius" not in data["metrics"]
assert "objective_grade" not in data["metrics"]
assert set(data["metrics"]) == {
"sql_risk",
"exception_coverage",
}
class TestLoadTemplate:
def test_loads_package_asset(self):
template = _load_report_template()
assert "{{REPORT_DATA}}" in template
assert template.startswith("<!DOCTYPE html>")
class TestRenderMarkdownReport:
def test_renders_chinese_structure(self):
md = render_markdown_report(_review_data())
assert "# 代码审查报告" in md
assert "结论" in md
assert "PR 质量分" in md and "7.0/10" in md
assert "客观指标" in md
assert "SQL 注入风险" in md
assert "异常分支覆盖" in md
assert "问题清单" in md
assert "🟡 主要" in md
assert "置信度 7/10" in md
assert "修复建议" in md
assert "需要人工审查" in md
assert "Payment callback idempotency" in md
assert "需 LLM 判断的指标" in md
def test_grade_localisation(self):
rd = _review_data()
rd["metrics"] = {
"sql_risk": {"value": 0, "grade": "good"},
"vulnerability_risk": {"value": 2, "grade": "fail"},
"high_risk_density": {"value": None, "grade": "na"},
}
md = render_markdown_report(rd)
assert "良好" in md
assert "不合格" in md
assert "不适用" in md
def test_empty_report(self):
md = render_markdown_report({})
assert "# 代码审查报告" in md
assert "未发现问题" in md
def test_line_unit_coverage_skips_file_count_rows(self):
"""gate=\"line+unit\" (feature) must NOT render the 全库/高风险 rows."""
rd = {
"scope": "feature",
"tier": "standard",
"reviewed_files": ["src/a.py", "src/b.py"],
"coverage": {
"coverage_pct": None,
"high_risk_coverage_pct": None,
"grade": "good",
"target_reached": True,
"gate": "line+unit",
"line_coverage_pct": 100.0,
"unit_coverage_pct": 100.0,
"line_gap_files": [],
"unit_gap_files": [],
},
}
md = render_markdown_report(rd)
assert "## 覆盖度" in md
assert "**状态**" in md
assert "100.0%" in md
assert "覆盖度(全库)" not in md
assert "覆盖度(高风险)" not in md
def test_reviewed_files_collapsible(self):
rd = {
"scope": "feature",
"tier": "standard",
"reviewed_files": ["src/a.py", "src/b.py"],
}
md = render_markdown_report(rd)
assert "审查文件(2 个)" in md
assert "<details>" in md
assert "点击展开 / 收起" in md
assert "src/a.py" in md
# flat "文件" meta line is skipped when reviewed_files is present
assert "- **文件**" not in md
class TestGenerateReport:
def test_both_writes_html_and_md(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(
_review_data(),
output_path="sub/report",
repo_root=str(root),
)
assert result["status"] == "ok"
assert isinstance(result["output_path"], list)
assert len(result["files"]) == 2
html = root / "sub" / "report.html"
md = root / "sub" / "report.md"
assert html.is_file()
assert md.is_file()
html_text = html.read_text(encoding="utf-8")
md_text = md.read_text(encoding="utf-8")
assert "{{REPORT_DATA}}" not in html_text
assert "代码审查报告" in html_text
assert "\u2705 PASS" in html_text
assert "N+1" in html_text
assert "# 代码审查报告" in md_text
def test_script_closing_sequence_is_escaped(self, tmp_path):
"""Literal "</script>" in finding text must not break the HTML.
A literal ``</script>`` inside the injected JSON data would close the
surrounding <script> tag early, corrupting the report. The JSON payload
must escape every "<" as "\\u003c" so the browser parses it as a plain
string while the JS template re-renders it as "<".
"""
root = _mkroot(tmp_path)
rd = _review_data()
rd["findings"] = [
{
"severity": "major",
"category": "security",
"message": 'literal </script> opener',
"path": "src/app.py",
"line": 42,
"confidence": 8,
"fix": "escape </script> before embedding",
},
]
result = generate_report_func(
rd,
output_path="script-safe",
repo_root=str(root),
)
assert result["status"] == "ok"
html_text = (root / "script-safe.html").read_text(encoding="utf-8")
# The injected JSON payload must not contain a bare closing tag.
data_block = html_text.split("const data = ", 1)[1].split(";", 1)[0]
assert "</script" not in data_block
# It must keep the JSON escape so the value round-trips as "<".
assert "\\u003c/script" in data_block
# The real template closing tag must still be present exactly once.
assert html_text.count("</script>") == 1
# Finding text is intact after JSON decoding.
import json as _json
decoded = _json.loads(data_block)
assert "literal </script> opener" in decoded["issues"][0]["message"]
def test_markdown_only(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(
_review_data(),
output_path="md-only",
repo_root=str(root),
format="markdown",
)
assert result["status"] == "ok"
assert len(result["files"]) == 1
assert str(result["output_path"]).endswith("md-only.md")
assert (root / "md-only.md").is_file()
assert not (root / "md-only.html").exists()
def test_html_only(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(
_review_data(),
output_path="html-only",
repo_root=str(root),
format="html",
)
assert result["status"] == "ok"
assert len(result["files"]) == 1
assert str(result["output_path"]).endswith("html-only.html")
assert (root / "html-only.html").is_file()
assert not (root / "html-only.md").exists()
def test_default_output_path_is_repo_root(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(_review_data(), repo_root=str(root))
assert result["status"] == "ok"
assert (root / "code-review-report.html").is_file()
assert (root / "code-review-report.md").is_file()
def test_invalid_format_errors(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(
_review_data(),
repo_root=str(root),
format="xml",
)
assert result["status"] == "error"
assert "xml" in result["error"]
def test_handles_missing_repo_root(self, tmp_path):
with pytest.raises(ValueError):
generate_report_func(_review_data(), repo_root=str(tmp_path / "missing"))