"""Unit tests for ``muse.core.doc_renderer``.
Coverage:
- :func:`render_json` produces valid JSON matching :class:`DocReport` shape.
- :func:`render_text` includes health bar, stale flag, and summary stats.
- :func:`render_markdown` produces valid Markdown with TOC, headings, sections.
- :func:`render_html` produces valid HTML with summary stats and symbol sections.
- :func:`render` dispatcher routes to correct renderer.
- XSS safety: dangerous strings in docstrings/names are HTML-escaped.
- Empty reports render gracefully.
"""
from __future__ import annotations
import json
import pytest
from muse.core.types import NULL_COMMIT_ID
from muse.core.doc_extractor import (
DocReport,
DocSummary,
MissingDocEntry,
StaleDocEntry,
SymbolDoc,
)
from muse.core.doc_renderer import (
RenderFormat,
_health_bar,
render,
render_html,
render_json,
render_markdown,
render_text,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_symbol_doc(
name: str = "my_function",
docstring: str | None = "Does something useful with good documentation.",
health: float = 0.85,
reasons: list[str] | None = None,
callers: list[str] | None = None,
callees: list[str] | None = None,
linked_tests: list[str] | None = None,
since_version: str | None = "v1.0.0",
breaking_changes: list[str] | None = None,
) -> SymbolDoc:
return SymbolDoc(
address=f"muse/core/foo.py::{name}",
name=name,
qualified_name=name,
kind="function",
file="muse/core/foo.py",
lineno=10,
end_lineno=20,
signature=f"def {name}(x: int) -> str:",
docstring=docstring,
callers=callers or [],
callees=callees or [],
since_commit="abc123",
since_version=since_version,
last_changed_commit="def456",
last_changed_version="v1.1.0",
breaking_changes=breaking_changes or [],
linked_tests=linked_tests or ["tests/test_foo.py::test_fn"],
doc_health=health,
doc_health_reasons=reasons or [],
)
def _make_summary(
total: int = 2,
public: int = 2,
documented: int = 1,
undocumented: int = 1,
stale: int = 0,
avg_health: float = 0.65,
debt: float = 0.35,
) -> DocSummary:
return DocSummary(
total_symbols=total,
public_symbols=public,
documented=documented,
undocumented=undocumented,
stale_count=stale,
avg_health=avg_health,
doc_debt_score=debt,
)
def _make_report(
symbols: list[SymbolDoc] | None = None,
missing: list[MissingDocEntry] | None = None,
stale: list[StaleDocEntry] | None = None,
) -> DocReport:
syms = symbols if symbols is not None else [_make_symbol_doc()]
return DocReport(
commit_id="abcdef0123456789" * 4,
generated_at="2026-03-26T12:00:00+00:00",
symbols=syms,
missing=missing or [],
stale=stale or [],
summary=_make_summary(total=len(syms)),
)
def _empty_report() -> DocReport:
return DocReport(
commit_id=NULL_COMMIT_ID,
generated_at="2026-01-01T00:00:00+00:00",
symbols=[],
missing=[],
stale=[],
summary=DocSummary(
total_symbols=0,
public_symbols=0,
documented=0,
undocumented=0,
stale_count=0,
avg_health=0.0,
doc_debt_score=1.0,
),
)
# ---------------------------------------------------------------------------
# Tests: render_json
# ---------------------------------------------------------------------------
class TestRenderJson:
def test_valid_json(self) -> None:
output = render_json(_make_report())
data = json.loads(output)
assert isinstance(data, dict)
def test_commit_id_present(self) -> None:
report = _make_report()
data = json.loads(render_json(report))
assert data["commit_id"] == report["commit_id"]
def test_symbols_list(self) -> None:
data = json.loads(render_json(_make_report()))
assert isinstance(data["symbols"], list)
assert len(data["symbols"]) == 1
def test_summary_keys(self) -> None:
data = json.loads(render_json(_make_report()))
summary = data["summary"]
assert "total_symbols" in summary
assert "avg_health" in summary
assert "doc_debt_score" in summary
def test_empty_report(self) -> None:
data = json.loads(render_json(_empty_report()))
assert data["symbols"] == []
def test_unicode_preserved(self) -> None:
sym = _make_symbol_doc(docstring="Ünïcödé dïäcritïcs™")
report = _make_report(symbols=[sym])
data = json.loads(render_json(report))
assert "Ünïcödé" in data["symbols"][0]["docstring"]
def test_all_symbol_doc_fields_present(self) -> None:
data = json.loads(render_json(_make_report()))
sym = data["symbols"][0]
for field in [
"address", "name", "qualified_name", "kind", "file",
"lineno", "end_lineno", "signature", "docstring",
"callers", "callees", "since_commit", "since_version",
"last_changed_commit", "last_changed_version",
"breaking_changes", "linked_tests",
"doc_health", "doc_health_reasons",
]:
assert field in sym, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# Tests: render_text
# ---------------------------------------------------------------------------
class TestRenderText:
def test_contains_header(self) -> None:
output = render_text(_make_report())
assert "Muse docs" in output
def test_contains_commit_id(self) -> None:
report = _make_report()
output = render_text(report)
assert report["commit_id"][:8] in output
def test_contains_symbol_name(self) -> None:
output = render_text(_make_report())
assert "my_function" in output
def test_empty_report_graceful(self) -> None:
output = render_text(_empty_report())
assert "no symbols" in output or "0" in output
def test_stale_flag_present(self) -> None:
sym = _make_symbol_doc(reasons=["stale_impl"])
report = _make_report(symbols=[sym])
output = render_text(report)
assert "⚠" in output
def test_missing_section(self) -> None:
m: MissingDocEntry = MissingDocEntry(
address="a.py::fn",
name="fn",
kind="function",
file="a.py",
caller_count=5,
)
report = _make_report(missing=[m])
output = render_text(report)
assert "Missing docstrings" in output
assert "a.py::fn" in output
def test_health_bar(self) -> None:
bar = _health_bar(0.0)
assert bar.startswith("[")
assert bar.endswith("]")
assert "█" not in bar or bar.count("█") == 0
bar_full = _health_bar(1.0)
assert "░" not in bar_full
def test_summary_stats_present(self) -> None:
output = render_text(_make_report())
assert "symbols=" in output
assert "avg_health=" in output
def test_many_missing_truncated(self) -> None:
many: list[MissingDocEntry] = [
MissingDocEntry(address=f"a.py::fn{i}", name=f"fn{i}", kind="function",
file="a.py", caller_count=i)
for i in range(30)
]
report = _make_report(missing=many)
output = render_text(report)
assert "more" in output
# ---------------------------------------------------------------------------
# Tests: render_markdown
# ---------------------------------------------------------------------------
class TestRenderMarkdown:
def test_h1_header(self) -> None:
output = render_markdown(_make_report())
assert output.startswith("# Muse Documentation Report")
def test_toc_present(self) -> None:
output = render_markdown(_make_report())
assert "## Table of Contents" in output
def test_symbol_heading(self) -> None:
output = render_markdown(_make_report())
assert "## `my_function`" in output
def test_docstring_in_output(self) -> None:
output = render_markdown(_make_report())
assert "Does something useful" in output
def test_no_docstring_placeholder(self) -> None:
sym = _make_symbol_doc(docstring=None)
output = render_markdown(_make_report(symbols=[sym]))
assert "*No docstring.*" in output
def test_since_version_shown(self) -> None:
output = render_markdown(_make_report())
assert "v1.0.0" in output
def test_callers_section(self) -> None:
sym = _make_symbol_doc(callers=["other.py::caller"])
output = render_markdown(_make_report(symbols=[sym]))
assert "Called by" in output
assert "other.py::caller" in output
def test_linked_tests_section(self) -> None:
output = render_markdown(_make_report())
assert "Tests" in output
assert "tests/test_foo.py" in output
def test_missing_section(self) -> None:
m: MissingDocEntry = MissingDocEntry(
address="x.py::fn",
name="fn",
kind="function",
file="x.py",
caller_count=3,
)
output = render_markdown(_make_report(missing=[m]))
assert "## Missing Docstrings" in output
def test_breaking_changes_section(self) -> None:
sym = _make_symbol_doc(breaking_changes=["Removed old_param"])
output = render_markdown(_make_report(symbols=[sym]))
assert "Breaking changes" in output
assert "Removed old_param" in output
def test_empty_report(self) -> None:
output = render_markdown(_empty_report())
assert "# Muse Documentation Report" in output
# ---------------------------------------------------------------------------
# Tests: render_html
# ---------------------------------------------------------------------------
class TestRenderHtml:
def test_doctype_present(self) -> None:
output = render_html(_make_report())
assert "" in output
def test_commit_id_in_title(self) -> None:
report = _make_report()
output = render_html(report)
assert report["commit_id"][:8] in output
def test_symbol_name_present(self) -> None:
output = render_html(_make_report())
assert "my_function" in output
def test_docstring_present(self) -> None:
output = render_html(_make_report())
assert "Does something useful" in output
def test_no_docstring_shown(self) -> None:
sym = _make_symbol_doc(docstring=None)
output = render_html(_make_report(symbols=[sym]))
assert "No docstring" in output
def test_xss_safety_in_name(self) -> None:
sym = _make_symbol_doc(name="")
output = render_html(_make_report(symbols=[sym]))
assert "