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