gabriel / muse public
test_core_doc_renderer.py python
422 lines 13.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Unit tests for ``muse.core.doc_renderer``.
2
3 Coverage:
4 - :func:`render_json` produces valid JSON matching :class:`DocReport` shape.
5 - :func:`render_text` includes health bar, stale flag, and summary stats.
6 - :func:`render_markdown` produces valid Markdown with TOC, headings, sections.
7 - :func:`render_html` produces valid HTML with summary stats and symbol sections.
8 - :func:`render` dispatcher routes to correct renderer.
9 - XSS safety: dangerous strings in docstrings/names are HTML-escaped.
10 - Empty reports render gracefully.
11 """
12
13 from __future__ import annotations
14
15 import json
16
17 import pytest
18
19 from muse.core.doc_extractor import (
20 DocReport,
21 DocSummary,
22 MissingDocEntry,
23 StaleDocEntry,
24 SymbolDoc,
25 )
26 from muse.core.doc_renderer import (
27 RenderFormat,
28 _health_bar,
29 render,
30 render_html,
31 render_json,
32 render_markdown,
33 render_text,
34 )
35
36
37 # ---------------------------------------------------------------------------
38 # Fixtures
39 # ---------------------------------------------------------------------------
40
41
42 def _make_symbol_doc(
43 name: str = "my_function",
44 docstring: str | None = "Does something useful with good documentation.",
45 health: float = 0.85,
46 reasons: list[str] | None = None,
47 callers: list[str] | None = None,
48 callees: list[str] | None = None,
49 linked_tests: list[str] | None = None,
50 since_version: str | None = "v1.0.0",
51 breaking_changes: list[str] | None = None,
52 ) -> SymbolDoc:
53 return SymbolDoc(
54 address=f"muse/core/foo.py::{name}",
55 name=name,
56 qualified_name=name,
57 kind="function",
58 file="muse/core/foo.py",
59 lineno=10,
60 end_lineno=20,
61 signature=f"def {name}(x: int) -> str:",
62 docstring=docstring,
63 callers=callers or [],
64 callees=callees or [],
65 since_commit="abc123",
66 since_version=since_version,
67 last_changed_commit="def456",
68 last_changed_version="v1.1.0",
69 breaking_changes=breaking_changes or [],
70 linked_tests=linked_tests or ["tests/test_foo.py::test_fn"],
71 doc_health=health,
72 doc_health_reasons=reasons or [],
73 )
74
75
76 def _make_summary(
77 total: int = 2,
78 public: int = 2,
79 documented: int = 1,
80 undocumented: int = 1,
81 stale: int = 0,
82 avg_health: float = 0.65,
83 debt: float = 0.35,
84 ) -> DocSummary:
85 return DocSummary(
86 total_symbols=total,
87 public_symbols=public,
88 documented=documented,
89 undocumented=undocumented,
90 stale_count=stale,
91 avg_health=avg_health,
92 doc_debt_score=debt,
93 )
94
95
96 def _make_report(
97 symbols: list[SymbolDoc] | None = None,
98 missing: list[MissingDocEntry] | None = None,
99 stale: list[StaleDocEntry] | None = None,
100 ) -> DocReport:
101 syms = symbols if symbols is not None else [_make_symbol_doc()]
102 return DocReport(
103 commit_id="abcdef0123456789" * 4,
104 generated_at="2026-03-26T12:00:00+00:00",
105 symbols=syms,
106 missing=missing or [],
107 stale=stale or [],
108 summary=_make_summary(total=len(syms)),
109 )
110
111
112 def _empty_report() -> DocReport:
113 return DocReport(
114 commit_id="0" * 64,
115 generated_at="2026-01-01T00:00:00+00:00",
116 symbols=[],
117 missing=[],
118 stale=[],
119 summary=DocSummary(
120 total_symbols=0,
121 public_symbols=0,
122 documented=0,
123 undocumented=0,
124 stale_count=0,
125 avg_health=0.0,
126 doc_debt_score=1.0,
127 ),
128 )
129
130
131 # ---------------------------------------------------------------------------
132 # Tests: render_json
133 # ---------------------------------------------------------------------------
134
135
136 class TestRenderJson:
137 def test_valid_json(self) -> None:
138 output = render_json(_make_report())
139 data = json.loads(output)
140 assert isinstance(data, dict)
141
142 def test_commit_id_present(self) -> None:
143 report = _make_report()
144 data = json.loads(render_json(report))
145 assert data["commit_id"] == report["commit_id"]
146
147 def test_symbols_list(self) -> None:
148 data = json.loads(render_json(_make_report()))
149 assert isinstance(data["symbols"], list)
150 assert len(data["symbols"]) == 1
151
152 def test_summary_keys(self) -> None:
153 data = json.loads(render_json(_make_report()))
154 summary = data["summary"]
155 assert "total_symbols" in summary
156 assert "avg_health" in summary
157 assert "doc_debt_score" in summary
158
159 def test_empty_report(self) -> None:
160 data = json.loads(render_json(_empty_report()))
161 assert data["symbols"] == []
162
163 def test_unicode_preserved(self) -> None:
164 sym = _make_symbol_doc(docstring="Ünïcödé dïäcritïcs™")
165 report = _make_report(symbols=[sym])
166 data = json.loads(render_json(report))
167 assert "Ünïcödé" in data["symbols"][0]["docstring"]
168
169 def test_all_symbol_doc_fields_present(self) -> None:
170 data = json.loads(render_json(_make_report()))
171 sym = data["symbols"][0]
172 for field in [
173 "address", "name", "qualified_name", "kind", "file",
174 "lineno", "end_lineno", "signature", "docstring",
175 "callers", "callees", "since_commit", "since_version",
176 "last_changed_commit", "last_changed_version",
177 "breaking_changes", "linked_tests",
178 "doc_health", "doc_health_reasons",
179 ]:
180 assert field in sym, f"Missing field: {field}"
181
182
183 # ---------------------------------------------------------------------------
184 # Tests: render_text
185 # ---------------------------------------------------------------------------
186
187
188 class TestRenderText:
189 def test_contains_header(self) -> None:
190 output = render_text(_make_report())
191 assert "Muse docs" in output
192
193 def test_contains_commit_id(self) -> None:
194 report = _make_report()
195 output = render_text(report)
196 assert report["commit_id"][:8] in output
197
198 def test_contains_symbol_name(self) -> None:
199 output = render_text(_make_report())
200 assert "my_function" in output
201
202 def test_empty_report_graceful(self) -> None:
203 output = render_text(_empty_report())
204 assert "no symbols" in output or "0" in output
205
206 def test_stale_flag_present(self) -> None:
207 sym = _make_symbol_doc(reasons=["stale_impl"])
208 report = _make_report(symbols=[sym])
209 output = render_text(report)
210 assert "⚠" in output
211
212 def test_missing_section(self) -> None:
213 m: MissingDocEntry = MissingDocEntry(
214 address="a.py::fn",
215 name="fn",
216 kind="function",
217 file="a.py",
218 caller_count=5,
219 )
220 report = _make_report(missing=[m])
221 output = render_text(report)
222 assert "Missing docstrings" in output
223 assert "a.py::fn" in output
224
225 def test_health_bar(self) -> None:
226 bar = _health_bar(0.0)
227 assert bar.startswith("[")
228 assert bar.endswith("]")
229 assert "█" not in bar or bar.count("█") == 0
230
231 bar_full = _health_bar(1.0)
232 assert "░" not in bar_full
233
234 def test_summary_stats_present(self) -> None:
235 output = render_text(_make_report())
236 assert "symbols=" in output
237 assert "avg_health=" in output
238
239 def test_many_missing_truncated(self) -> None:
240 many: list[MissingDocEntry] = [
241 MissingDocEntry(address=f"a.py::fn{i}", name=f"fn{i}", kind="function",
242 file="a.py", caller_count=i)
243 for i in range(30)
244 ]
245 report = _make_report(missing=many)
246 output = render_text(report)
247 assert "more" in output
248
249
250 # ---------------------------------------------------------------------------
251 # Tests: render_markdown
252 # ---------------------------------------------------------------------------
253
254
255 class TestRenderMarkdown:
256 def test_h1_header(self) -> None:
257 output = render_markdown(_make_report())
258 assert output.startswith("# Muse Documentation Report")
259
260 def test_toc_present(self) -> None:
261 output = render_markdown(_make_report())
262 assert "## Table of Contents" in output
263
264 def test_symbol_heading(self) -> None:
265 output = render_markdown(_make_report())
266 assert "## `my_function`" in output
267
268 def test_docstring_in_output(self) -> None:
269 output = render_markdown(_make_report())
270 assert "Does something useful" in output
271
272 def test_no_docstring_placeholder(self) -> None:
273 sym = _make_symbol_doc(docstring=None)
274 output = render_markdown(_make_report(symbols=[sym]))
275 assert "*No docstring.*" in output
276
277 def test_since_version_shown(self) -> None:
278 output = render_markdown(_make_report())
279 assert "v1.0.0" in output
280
281 def test_callers_section(self) -> None:
282 sym = _make_symbol_doc(callers=["other.py::caller"])
283 output = render_markdown(_make_report(symbols=[sym]))
284 assert "Called by" in output
285 assert "other.py::caller" in output
286
287 def test_linked_tests_section(self) -> None:
288 output = render_markdown(_make_report())
289 assert "Tests" in output
290 assert "tests/test_foo.py" in output
291
292 def test_missing_section(self) -> None:
293 m: MissingDocEntry = MissingDocEntry(
294 address="x.py::fn",
295 name="fn",
296 kind="function",
297 file="x.py",
298 caller_count=3,
299 )
300 output = render_markdown(_make_report(missing=[m]))
301 assert "## Missing Docstrings" in output
302
303 def test_breaking_changes_section(self) -> None:
304 sym = _make_symbol_doc(breaking_changes=["Removed old_param"])
305 output = render_markdown(_make_report(symbols=[sym]))
306 assert "Breaking changes" in output
307 assert "Removed old_param" in output
308
309 def test_empty_report(self) -> None:
310 output = render_markdown(_empty_report())
311 assert "# Muse Documentation Report" in output
312
313
314 # ---------------------------------------------------------------------------
315 # Tests: render_html
316 # ---------------------------------------------------------------------------
317
318
319 class TestRenderHtml:
320 def test_doctype_present(self) -> None:
321 output = render_html(_make_report())
322 assert "<!DOCTYPE html>" in output
323
324 def test_commit_id_in_title(self) -> None:
325 report = _make_report()
326 output = render_html(report)
327 assert report["commit_id"][:8] in output
328
329 def test_symbol_name_present(self) -> None:
330 output = render_html(_make_report())
331 assert "my_function" in output
332
333 def test_docstring_present(self) -> None:
334 output = render_html(_make_report())
335 assert "Does something useful" in output
336
337 def test_no_docstring_shown(self) -> None:
338 sym = _make_symbol_doc(docstring=None)
339 output = render_html(_make_report(symbols=[sym]))
340 assert "No docstring" in output
341
342 def test_xss_safety_in_name(self) -> None:
343 sym = _make_symbol_doc(name="<script>alert(1)</script>")
344 output = render_html(_make_report(symbols=[sym]))
345 assert "<script>" not in output
346 assert "&lt;script&gt;" in output
347
348 def test_xss_safety_in_docstring(self) -> None:
349 sym = _make_symbol_doc(docstring='<img src=x onerror="alert(1)">')
350 output = render_html(_make_report(symbols=[sym]))
351 # The raw string must not appear — it should be HTML-escaped.
352 assert '<img src=x onerror="alert(1)">' not in output
353 # The escaped form should be present.
354 assert "&lt;img" in output or "onerror=&quot;" in output or "&lt;img" in output
355
356 def test_summary_stats_in_html(self) -> None:
357 output = render_html(_make_report())
358 assert "stat-value" in output
359
360 def test_missing_table_present(self) -> None:
361 m: MissingDocEntry = MissingDocEntry(
362 address="y.py::fn",
363 name="fn",
364 kind="function",
365 file="y.py",
366 caller_count=2,
367 )
368 output = render_html(_make_report(missing=[m]))
369 assert "missing-table" in output
370
371 def test_health_bar_in_html(self) -> None:
372 output = render_html(_make_report())
373 assert "health-fill" in output
374
375 def test_no_external_resources(self) -> None:
376 output = render_html(_make_report())
377 # No <link> to external stylesheets, no external script src.
378 assert 'src="http' not in output
379 assert 'href="http' not in output
380
381 def test_nav_sidebar_links(self) -> None:
382 output = render_html(_make_report())
383 assert "<nav>" in output
384 assert "<a href=" in output
385
386 def test_version_badge(self) -> None:
387 output = render_html(_make_report())
388 assert "since" in output # version badge contains "since"
389
390 def test_empty_report(self) -> None:
391 output = render_html(_empty_report())
392 assert "<!DOCTYPE html>" in output
393
394
395 # ---------------------------------------------------------------------------
396 # Tests: render dispatcher
397 # ---------------------------------------------------------------------------
398
399
400 class TestRenderDispatcher:
401 def test_json_format(self) -> None:
402 output = render(_make_report(), "json")
403 data = json.loads(output)
404 assert "symbols" in data
405
406 def test_html_format(self) -> None:
407 output = render(_make_report(), "html")
408 assert "<!DOCTYPE html>" in output
409
410 def test_markdown_format(self) -> None:
411 output = render(_make_report(), "markdown")
412 assert output.startswith("# Muse")
413
414 def test_text_format(self) -> None:
415 output = render(_make_report(), "text")
416 assert "Muse docs" in output
417
418 def test_unknown_format_fallback_in_cmd(self) -> None:
419 """_to_render_format gracefully handles unknown strings by returning 'text'."""
420 from muse.cli.commands.docs_cmd import _to_render_format
421 assert _to_render_format("xml") == "text"
422 assert _to_render_format("rst") == "text"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago