Add pytest coverage for CLI and rendering behavior
This commit is contained in:
57
tests/conftest.py
Normal file
57
tests/conftest.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Shared test fixtures for rp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from rp.render import RenderOptions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_file(tmp_path: Path) -> Callable[..., Path]:
|
||||
"""Factory fixture to create a temporary file with given content and suffix."""
|
||||
|
||||
def _make(content: str, suffix: str = ".txt", name: str | None = None) -> Path:
|
||||
filename = name or f"test{suffix}"
|
||||
p = tmp_path / filename
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_console() -> Iterator[Console]:
|
||||
"""A Rich Console that captures output instead of printing to stdout."""
|
||||
console = Console(file=io.StringIO(), width=120, force_terminal=True)
|
||||
yield console
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_options() -> RenderOptions:
|
||||
"""Default RenderOptions for testing."""
|
||||
return RenderOptions()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_json() -> str:
|
||||
"""Valid JSON string for testing."""
|
||||
return json.dumps({"name": "test", "value": 42, "items": [1, 2, 3]}, indent=2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_markdown() -> str:
|
||||
"""Markdown content for testing."""
|
||||
return "# Hello\n\nThis is **bold** and *italic*.\n\n- item 1\n- item 2\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_python() -> str:
|
||||
"""Python code for testing."""
|
||||
return 'def hello(name: str = "world") -> str:\n return f"Hello, {name}!"\n'
|
||||
254
tests/test_cli.py
Normal file
254
tests/test_cli.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for the CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from rp import __version__
|
||||
from rp.cli import app, main
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestVersionFlag:
|
||||
"""Tests for the version flags."""
|
||||
|
||||
def test_version_long_flag(self) -> None:
|
||||
result = runner.invoke(app, ["--version"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert __version__ in result.output
|
||||
|
||||
def test_version_short_flag(self) -> None:
|
||||
result = runner.invoke(app, ["-v"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert __version__ in result.output
|
||||
|
||||
def test_version_output_format(self) -> None:
|
||||
result = runner.invoke(app, ["--version"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Rich Print (rp)" in result.output
|
||||
assert "License: MIT" in result.output
|
||||
|
||||
|
||||
class TestFileInput:
|
||||
"""Tests for reading from a file argument."""
|
||||
|
||||
def test_render_python_file(self, tmp_file) -> None:
|
||||
file = tmp_file("def hello(): pass\n", suffix=".py")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output
|
||||
|
||||
def test_render_markdown_file(self, tmp_file) -> None:
|
||||
file = tmp_file("# Hello\n\nWorld\n", suffix=".md")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_render_json_file(self, tmp_file, sample_json: str) -> None:
|
||||
file = tmp_file(sample_json, suffix=".json")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_detect_type_called_with_file_path(self, tmp_file) -> None:
|
||||
"""detect_type receives the file path string."""
|
||||
file = tmp_file("x = 1\n", suffix=".py")
|
||||
|
||||
with patch("rp.cli.detect_type") as mock_detect_type:
|
||||
mock_detect_type.return_value = "python"
|
||||
with patch("rp.cli.render"):
|
||||
runner.invoke(app, [str(file), "--no-pager"])
|
||||
|
||||
mock_detect_type.assert_called_once_with(
|
||||
path=str(file),
|
||||
content="x = 1\n",
|
||||
explicit_type=None,
|
||||
)
|
||||
|
||||
def test_render_called_with_correct_options(self, tmp_file) -> None:
|
||||
"""Render receives the right theme, line_numbers, pager flags."""
|
||||
file = tmp_file("x = 1\n", suffix=".py")
|
||||
|
||||
with patch("rp.cli.detect_type", return_value="python"):
|
||||
with patch("rp.cli.render") as mock_render:
|
||||
runner.invoke(
|
||||
app,
|
||||
[
|
||||
str(file),
|
||||
"--no-pager",
|
||||
"--theme",
|
||||
"github-dark",
|
||||
"--line-numbers",
|
||||
],
|
||||
)
|
||||
|
||||
call_args = mock_render.call_args
|
||||
assert call_args is not None
|
||||
assert call_args[0][0] == "x = 1\n"
|
||||
assert call_args[0][1] == "python"
|
||||
options = call_args[0][3]
|
||||
assert options.theme == "github-dark"
|
||||
assert options.line_numbers is True
|
||||
assert options.pager is False
|
||||
|
||||
def test_file_not_found(self) -> None:
|
||||
result = runner.invoke(app, ["/nonexistent/file.py"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error" in result.output
|
||||
|
||||
def test_directory_error(self, tmp_path: Path) -> None:
|
||||
result = runner.invoke(app, [str(tmp_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "directory" in result.output.lower() or "Error" in result.output
|
||||
|
||||
def test_empty_file(self, tmp_file) -> None:
|
||||
file = tmp_file("", suffix=".txt")
|
||||
|
||||
result = runner.invoke(app, [str(file)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Empty" in result.output or "empty" in result.output.lower()
|
||||
|
||||
def test_binary_file_error(self, tmp_file) -> None:
|
||||
file = tmp_file("placeholder", suffix=".bin")
|
||||
|
||||
with patch.object(
|
||||
Path,
|
||||
"read_text",
|
||||
side_effect=UnicodeDecodeError("utf-8", b"x", 0, 1, "boom"),
|
||||
):
|
||||
result = runner.invoke(app, [str(file)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Cannot read binary file" in result.output
|
||||
|
||||
def test_os_error_when_reading_file(self, tmp_file) -> None:
|
||||
file = tmp_file("placeholder", suffix=".txt")
|
||||
|
||||
with patch.object(Path, "read_text", side_effect=OSError("read failed")):
|
||||
result = runner.invoke(app, [str(file)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "read failed" in result.output
|
||||
|
||||
|
||||
class TestStdinInput:
|
||||
"""Tests for reading from stdin."""
|
||||
|
||||
def test_stdin_python(self) -> None:
|
||||
result = runner.invoke(app, ["--no-pager"], input="x = 1\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_stdin_json(self, sample_json: str) -> None:
|
||||
result = runner.invoke(app, ["--no-pager"], input=sample_json)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_stdin_empty(self) -> None:
|
||||
result = runner.invoke(app, [], input="")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Empty" in result.output or "empty" in result.output.lower()
|
||||
|
||||
def test_stdin_with_dash(self) -> None:
|
||||
result = runner.invoke(app, ["-", "--no-pager"], input="hello\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_stdin_passes_none_path(self) -> None:
|
||||
"""When reading from stdin, detect_type should receive path=None."""
|
||||
with patch("rp.cli.detect_type") as mock_detect_type:
|
||||
mock_detect_type.return_value = "text"
|
||||
with patch("rp.cli.render"):
|
||||
runner.invoke(app, ["--no-pager"], input="hello\n")
|
||||
|
||||
mock_detect_type.assert_called_once_with(
|
||||
path=None,
|
||||
content="hello\n",
|
||||
explicit_type=None,
|
||||
)
|
||||
|
||||
def test_stdin_shows_tty_prompt(self) -> None:
|
||||
"""When stdin is a TTY and no input is piped, show the reading hint."""
|
||||
with patch("rp.cli.sys.stdin.isatty", return_value=True):
|
||||
with patch("rp.cli.sys.stdin.read", return_value="hello\n"):
|
||||
with patch("rp.cli.render"):
|
||||
with patch("rp.cli.Console.print") as mock_print:
|
||||
main(pager=False)
|
||||
|
||||
mock_print.assert_any_call(
|
||||
"[dim]Reading from stdin. Press Ctrl+D to end.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
class TestTypeOverride:
|
||||
"""Tests for the type override flags."""
|
||||
|
||||
def test_explicit_type(self, tmp_file) -> None:
|
||||
file = tmp_file('{"key": "value"}\n', suffix=".txt")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--type", "json", "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_explicit_type_is_forwarded(self, tmp_file) -> None:
|
||||
"""The --type flag value is passed as explicit_type to detect_type."""
|
||||
file = tmp_file('{"key": "value"}\n', suffix=".txt")
|
||||
|
||||
with patch("rp.cli.detect_type") as mock_detect_type:
|
||||
mock_detect_type.return_value = "json"
|
||||
with patch("rp.cli.render"):
|
||||
runner.invoke(app, [str(file), "--type", "json", "--no-pager"])
|
||||
|
||||
_, kwargs = mock_detect_type.call_args
|
||||
assert kwargs["explicit_type"] == "json"
|
||||
|
||||
def test_explicit_type_short_flag(self, tmp_file) -> None:
|
||||
file = tmp_file("def foo(): pass\n", suffix=".txt")
|
||||
|
||||
result = runner.invoke(app, [str(file), "-t", "python", "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestThemeOption:
|
||||
"""Tests for the theme option."""
|
||||
|
||||
def test_custom_theme(self, tmp_file) -> None:
|
||||
file = tmp_file("x = 1\n", suffix=".py")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--theme", "github-dark", "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestLineNumbersOption:
|
||||
"""Tests for the line number flags."""
|
||||
|
||||
def test_line_numbers_enabled(self, tmp_file) -> None:
|
||||
file = tmp_file("x = 1\n", suffix=".py")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--line-numbers", "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_line_numbers_disabled(self, tmp_file) -> None:
|
||||
file = tmp_file("x = 1\n", suffix=".py")
|
||||
|
||||
result = runner.invoke(app, [str(file), "--no-line-numbers", "--no-pager"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
142
tests/test_detect.py
Normal file
142
tests/test_detect.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Tests for file type detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rp.detect import EXTENSION_MAP, _FILENAME_MAP, detect_type
|
||||
|
||||
|
||||
class TestExplicitType:
|
||||
"""Priority 1: explicit_type always wins."""
|
||||
|
||||
def test_explicit_overrides_everything(self) -> None:
|
||||
assert detect_type(path="foo.py", content="...", explicit_type="json") == "json"
|
||||
|
||||
def test_explicit_with_none_path_and_content(self) -> None:
|
||||
assert detect_type(explicit_type="rust") == "rust"
|
||||
|
||||
def test_explicit_empty_string(self) -> None:
|
||||
# Empty string is falsy but not None, so it should still win.
|
||||
assert detect_type(explicit_type="") == ""
|
||||
|
||||
|
||||
class TestFilenameMap:
|
||||
"""Priority 2: special filenames like Dockerfile."""
|
||||
|
||||
def test_dockerfile(self) -> None:
|
||||
assert detect_type(path="Dockerfile") == "docker"
|
||||
|
||||
def test_dockerfile_with_path(self) -> None:
|
||||
assert detect_type(path="/some/dir/Dockerfile") == "docker"
|
||||
|
||||
def test_makefile(self) -> None:
|
||||
assert detect_type(path="Makefile") == "make"
|
||||
|
||||
def test_makefile_with_path(self) -> None:
|
||||
assert detect_type(path="src/Makefile") == "make"
|
||||
|
||||
|
||||
class TestExtensionMap:
|
||||
"""Priority 3: file extension lookup."""
|
||||
|
||||
def test_python(self) -> None:
|
||||
assert detect_type(path="script.py") == "python"
|
||||
|
||||
def test_python_case_insensitive(self) -> None:
|
||||
assert detect_type(path="SCRIPT.PY") == "python"
|
||||
|
||||
def test_json_extension(self) -> None:
|
||||
assert detect_type(path="data.json") == "json"
|
||||
|
||||
def test_markdown(self) -> None:
|
||||
assert detect_type(path="README.md") == "markdown"
|
||||
|
||||
def test_rust(self) -> None:
|
||||
assert detect_type(path="main.rs") == "rust"
|
||||
|
||||
def test_go(self) -> None:
|
||||
assert detect_type(path="main.go") == "go"
|
||||
|
||||
def test_unknown_extension(self) -> None:
|
||||
assert detect_type(path="file.xyz") == "text"
|
||||
|
||||
def test_no_extension(self) -> None:
|
||||
assert detect_type(path="Makefile") == "make"
|
||||
|
||||
def test_double_extension(self) -> None:
|
||||
assert detect_type(path="archive.tar.gz") == "text"
|
||||
|
||||
def test_hidden_file(self) -> None:
|
||||
assert detect_type(path=".bashrc") == "text"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "expected"),
|
||||
sorted(EXTENSION_MAP.items()),
|
||||
)
|
||||
def test_all_known_extensions_detect(self, suffix: str, expected: str) -> None:
|
||||
assert detect_type(path=f"example{suffix}") == expected
|
||||
|
||||
|
||||
class TestContentJsonDetection:
|
||||
"""Priority 4: detect JSON from content when path gives no clue."""
|
||||
|
||||
def test_json_object(self, sample_json: str) -> None:
|
||||
assert detect_type(content=sample_json) == "json"
|
||||
|
||||
def test_json_array(self) -> None:
|
||||
assert detect_type(content="[1, 2, 3]") == "json"
|
||||
|
||||
def test_json_with_whitespace(self) -> None:
|
||||
assert detect_type(content=' \n {"a": 1} \n ') == "json"
|
||||
|
||||
def test_invalid_json_braces(self) -> None:
|
||||
assert detect_type(content="{invalid json}") == "text"
|
||||
|
||||
def test_non_json_content(self) -> None:
|
||||
assert detect_type(content="hello world") == "text"
|
||||
|
||||
def test_empty_content(self) -> None:
|
||||
assert detect_type(content="") == "text"
|
||||
|
||||
def test_content_with_valid_path_takes_priority(self) -> None:
|
||||
assert detect_type(path="script.py", content='{"a": 1}') == "python"
|
||||
|
||||
|
||||
class TestFallback:
|
||||
"""Priority 5: fallback to 'text'."""
|
||||
|
||||
def test_no_args(self) -> None:
|
||||
assert detect_type() == "text"
|
||||
|
||||
def test_none_everything(self) -> None:
|
||||
assert detect_type(path=None, content=None, explicit_type=None) == "text"
|
||||
|
||||
|
||||
class TestExtensionMapCompleteness:
|
||||
"""Verify EXTENSION_MAP entries are valid."""
|
||||
|
||||
def test_all_extensions_are_strings(self) -> None:
|
||||
for ext, lexer in EXTENSION_MAP.items():
|
||||
assert isinstance(ext, str), f"Key {ext!r} is not str"
|
||||
assert isinstance(lexer, str), f"Value for {ext!r} is not str"
|
||||
|
||||
def test_all_extensions_start_with_dot(self) -> None:
|
||||
for ext in EXTENSION_MAP:
|
||||
assert ext.startswith("."), f"Extension {ext!r} doesn't start with '.'"
|
||||
|
||||
def test_all_filenames_in_filename_map(self) -> None:
|
||||
for name in _FILENAME_MAP:
|
||||
assert isinstance(name, str)
|
||||
|
||||
def test_filename_map_matches_path_name_lookup(self, tmp_file) -> None:
|
||||
for name, expected in _FILENAME_MAP.items():
|
||||
path = tmp_file("content", name=name)
|
||||
assert detect_type(path=str(path)) == expected
|
||||
|
||||
|
||||
def test_path_stringified_from_pathlib(tmp_file) -> None:
|
||||
path = tmp_file('print("hi")\n', suffix=".py")
|
||||
assert detect_type(path=str(Path(path))) == "python"
|
||||
167
tests/test_pager.py
Normal file
167
tests/test_pager.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Tests for pager integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from rp.pager import MousePager, _mouse_capable_less
|
||||
|
||||
|
||||
class TestMouseCapableLess:
|
||||
"""Tests for _mouse_capable_less()."""
|
||||
|
||||
def test_returns_path_when_less_supports_mouse(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = "less 551 (POSIX regular expressions)"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() == "/usr/bin/less"
|
||||
|
||||
def test_returns_none_when_less_not_found(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value=None):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_returns_none_when_less_too_old(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = "less 444"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_returns_path_when_version_is_in_stderr(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "less 600"
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() == "/usr/bin/less"
|
||||
|
||||
def test_returns_none_on_timeout(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
with patch(
|
||||
"rp.pager.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="less", timeout=5),
|
||||
):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_returns_none_on_os_error(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", side_effect=OSError("nope")):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_returns_none_when_version_not_parseable(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = "some random output"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_boundary_version_543(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = "less 543"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() == "/usr/bin/less"
|
||||
|
||||
def test_boundary_version_542(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = "less 542"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
|
||||
class TestMousePager:
|
||||
"""Tests for MousePager.show()."""
|
||||
|
||||
def test_falls_back_to_system_pager_when_no_less(self) -> None:
|
||||
pager = MousePager()
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value=None):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
mock_instance = MagicMock()
|
||||
mock_system_pager.return_value = mock_instance
|
||||
|
||||
pager.show("content")
|
||||
|
||||
mock_instance.show.assert_called_once_with("content")
|
||||
|
||||
def test_uses_less_when_available(self) -> None:
|
||||
pager = MousePager()
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result) as mock_run:
|
||||
pager.show("hello")
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["/usr/bin/less", "-R", "--mouse"],
|
||||
input="hello",
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_sigint_does_not_trigger_fallback_for_negative_code(self) -> None:
|
||||
pager = MousePager()
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = -signal.SIGINT
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
pager.show("content")
|
||||
|
||||
mock_system_pager.assert_not_called()
|
||||
|
||||
def test_sigint_does_not_trigger_fallback_for_128_plus_code(self) -> None:
|
||||
pager = MousePager()
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 128 + signal.SIGINT
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
pager.show("content")
|
||||
|
||||
mock_system_pager.assert_not_called()
|
||||
|
||||
def test_nonzero_exit_triggers_fallback(self) -> None:
|
||||
pager = MousePager()
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", return_value=mock_result):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
mock_instance = MagicMock()
|
||||
mock_system_pager.return_value = mock_instance
|
||||
|
||||
pager.show("content")
|
||||
|
||||
mock_instance.show.assert_called_once_with("content")
|
||||
|
||||
def test_oserror_triggers_fallback(self) -> None:
|
||||
pager = MousePager()
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value="/usr/bin/less"):
|
||||
with patch("rp.pager.subprocess.run", side_effect=OSError("fail")):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
mock_instance = MagicMock()
|
||||
mock_system_pager.return_value = mock_instance
|
||||
|
||||
pager.show("content")
|
||||
|
||||
mock_instance.show.assert_called_once_with("content")
|
||||
257
tests/test_render.py
Normal file
257
tests/test_render.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user