Files
rich-print/tests/test_render.py
Yunxiao Xu 93c777cf6a Add opt-in debug logging across the CLI pipeline
Thread RP_DEBUG-driven logging through input, detection, rendering, and pager decisions so CLI behavior is inspectable without polluting stdout. Add focused coverage for debug wiring, env parsing, and stderr-only output.
2026-04-03 19:09:24 -07:00

274 lines
8.6 KiB
Python

"""Tests for the rendering module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from rich.json import JSON as RichJSON
from rich.markdown import Markdown
from rich.padding import Padding
from rich.syntax import Syntax
from rp.pager import MousePager
from rp.render import RenderOptions, render
class TestRenderOptions:
"""RenderOptions dataclass defaults."""
def test_default_theme(self) -> None:
opts = RenderOptions()
assert opts.theme == "monokai"
def test_default_line_numbers_is_none(self) -> None:
opts = RenderOptions()
assert opts.line_numbers is None
def test_default_pager_is_none(self) -> None:
opts = RenderOptions()
assert opts.pager is None
def test_custom_values(self) -> None:
opts = RenderOptions(theme="github-dark", line_numbers=True, pager=False)
assert opts.theme == "github-dark"
assert opts.line_numbers is True
assert opts.pager is False
def test_default_debug_is_none(self) -> None:
opts = RenderOptions()
assert opts.debug is None
class TestRenderMarkdown:
"""Markdown rendering."""
@pytest.mark.parametrize("file_type", ["markdown", "md"])
def test_renders_markdown_types(
self,
capture_console,
sample_markdown: str,
file_type: str,
) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Markdown", wraps=Markdown) as mock_markdown:
render(sample_markdown, file_type, capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
mock_markdown.assert_called_once_with(sample_markdown)
class TestRenderJson:
"""JSON rendering."""
@pytest.mark.parametrize("content", ['{"a": 1}', "[1, 2, 3]"])
def test_renders_valid_json(
self,
capture_console,
content: str,
) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.RichJSON", wraps=RichJSON) as mock_json:
render(content, "json", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
mock_json.assert_called_once_with(content)
def test_invalid_json_falls_back_to_syntax(
self,
capture_console,
) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render("{invalid json}", "json", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
mock_syntax.assert_called_once_with(
"{invalid json}",
"json",
theme="monokai",
line_numbers=False,
)
class TestRenderCode:
"""Code rendering with Syntax."""
def test_renders_python(self, capture_console, sample_python: str) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render(sample_python, "python", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
mock_syntax.assert_called_once_with(
sample_python,
"python",
theme="monokai",
line_numbers=True,
)
def test_renders_unknown_type_as_text_fallback(self, capture_console) -> None:
opts = RenderOptions(pager=False)
def syntax_side_effect(*args, **kwargs):
lexer = args[1]
if lexer == "xyz_nonexistent_lexer":
raise Exception("unknown lexer")
return Syntax(*args, **kwargs)
with patch("rp.render.Syntax", side_effect=syntax_side_effect) as mock_syntax:
render("some content", "xyz_nonexistent_lexer", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
assert mock_syntax.call_args_list[0].args == (
"some content",
"xyz_nonexistent_lexer",
)
assert mock_syntax.call_args_list[0].kwargs == {
"theme": "monokai",
"line_numbers": True,
}
assert mock_syntax.call_args_list[1].args == ("some content", "text")
assert mock_syntax.call_args_list[1].kwargs == {
"theme": "monokai",
"line_numbers": True,
}
def test_renders_rust(self, capture_console) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render("fn main() {}", "rust", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
mock_syntax.assert_called_once_with(
"fn main() {}",
"rust",
theme="monokai",
line_numbers=True,
)
class TestLineNumbers:
"""Line number auto-detection logic."""
@pytest.mark.parametrize(
("content", "file_type", "expected"),
[
("x = 1", "python", True),
("# Heading", "markdown", False),
("{}", "json", False),
],
)
def test_default_line_number_behavior(
self,
capture_console,
content: str,
file_type: str,
expected: bool,
) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render(content, file_type, capture_console, opts)
if file_type == "python":
mock_syntax.assert_called_once()
assert mock_syntax.call_args.kwargs["line_numbers"] is expected
else:
assert len(capture_console.file.getvalue()) > 0
@pytest.mark.parametrize("line_numbers", [True, False])
def test_explicit_line_numbers(
self,
capture_console,
sample_python: str,
line_numbers: bool,
) -> None:
opts = RenderOptions(line_numbers=line_numbers, pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render(sample_python, "python", capture_console, opts)
mock_syntax.assert_called_once()
assert mock_syntax.call_args.kwargs["line_numbers"] is line_numbers
class TestPagerBehavior:
"""Pager auto-detection."""
def test_pager_disabled(self, capture_console, sample_python: str) -> None:
opts = RenderOptions(pager=False)
with patch.object(capture_console, "pager") as mock_pager:
render(sample_python, "python", capture_console, opts)
assert len(capture_console.file.getvalue()) > 0
mock_pager.assert_not_called()
def test_pager_auto_tty(self, capture_console, sample_python: str) -> None:
opts = RenderOptions(pager=None)
mock_context = MagicMock()
mock_context.__enter__.return_value = None
mock_context.__exit__.return_value = None
with patch("sys.stdout.isatty", return_value=True):
with patch.object(
capture_console, "pager", return_value=mock_context
) as mock_pager:
render(sample_python, "python", capture_console, opts)
mock_pager.assert_called_once()
assert mock_pager.call_args.kwargs["styles"] is True
assert isinstance(mock_pager.call_args.kwargs["pager"], MousePager)
def test_pager_auto_non_tty(self, capture_console, sample_python: str) -> None:
opts = RenderOptions(pager=None)
with patch("sys.stdout.isatty", return_value=False):
with patch.object(capture_console, "pager") as mock_pager:
render(sample_python, "python", capture_console, opts)
assert len(capture_console.file.getvalue()) > 0
mock_pager.assert_not_called()
def test_logs_auto_pager_decision(
self, capture_console, sample_python: str
) -> None:
messages: list[str] = []
opts = RenderOptions(pager=None, debug=messages.append)
with patch("sys.stdout.isatty", return_value=False):
render(sample_python, "python", capture_console, opts)
assert "pager: auto-detected disabled because stdout is not a TTY" in messages
assert "pager: writing directly to the console" in messages
class TestPadding:
"""Rendered output is wrapped in padding."""
def test_wraps_renderable_in_padding(
self,
capture_console,
sample_python: str,
) -> None:
opts = RenderOptions(pager=False)
with patch.object(capture_console, "print") as mock_print:
render(sample_python, "python", capture_console, opts)
printed = mock_print.call_args.args[0]
assert isinstance(printed, Padding)
assert printed.expand is True