"""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 "