Coverage for tests / utils / test_output_manager.py: 100%
118 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for `OutputManager` write helpers and report generation."""
3import csv
4import json
5import shutil
6import tempfile
7from collections.abc import Generator
8from pathlib import Path
9from types import SimpleNamespace
10from typing import Any
12import pytest
13from assertpy import assert_that
15from lintro.utils.output import OutputManager
18@pytest.fixture
19def temp_output_dir() -> Generator[str, None, None]:
20 """Provide a temporary directory path and clean up afterwards.
22 Yields:
23 str: Path to a temporary directory for test outputs.
24 """
25 d = tempfile.mkdtemp()
26 yield d
27 shutil.rmtree(d)
30def make_tool_result(
31 name: str,
32 issues_count: int = 0,
33 issues: list[Any] | None = None,
34) -> SimpleNamespace:
35 """Factory for tool-like result objects used in report tests.
37 Args:
38 name: Tool name.
39 issues_count: Total issues count.
40 issues: Optional list of issues.
42 Returns:
43 SimpleNamespace with name, issues_count, output, and issues.
44 """
45 return SimpleNamespace(
46 name=name,
47 issues_count=issues_count,
48 output=f"Output for {name}",
49 issues=issues or [],
50 )
53def make_issue(file: str, line: int, code: str, message: str) -> SimpleNamespace:
54 """Factory for issue-like objects used in report tests.
56 Args:
57 file: File path.
58 line: Line number.
59 code: Issue code.
60 message: Description.
62 Returns:
63 SimpleNamespace with file, line, code, and message.
64 """
65 return SimpleNamespace(file=file, line=line, code=code, message=message)
68def test_run_dir_creation(temp_output_dir: str) -> None:
69 """Test that OutputManager creates a timestamped run directory.
71 Args:
72 temp_output_dir: Temporary directory fixture for test output.
73 """
74 om = OutputManager(base_dir=temp_output_dir, keep_last=2)
75 assert_that(om.get_run_dir().exists()).is_true()
76 assert_that(om.get_run_dir().parent).is_equal_to(Path(temp_output_dir))
79def test_write_console_log(temp_output_dir: str) -> None:
80 """Test writing console.log file.
82 Args:
83 temp_output_dir: Temporary directory fixture for test output.
84 """
85 om = OutputManager(base_dir=temp_output_dir)
86 om.write_console_log("hello world")
87 log_path = om.get_run_dir() / "console.log"
88 assert_that(log_path.exists()).is_true()
89 assert_that(log_path.read_text()).is_equal_to("hello world")
92def test_write_json(temp_output_dir: str) -> None:
93 """Test writing results.json file.
95 Args:
96 temp_output_dir: Temporary directory fixture for test output.
97 """
98 om = OutputManager(base_dir=temp_output_dir)
99 data = {"foo": 1, "bar": [2, 3]}
100 om.write_json(data)
101 json_path = om.get_run_dir() / "results.json"
102 assert_that(json_path.exists()).is_true()
103 with open(json_path) as f:
104 loaded = json.load(f)
105 assert_that(loaded).is_equal_to(data)
108def test_write_markdown(temp_output_dir: str) -> None:
109 """Test writing report.md file.
111 Args:
112 temp_output_dir: Temporary directory fixture for test output.
113 """
114 om = OutputManager(base_dir=temp_output_dir)
115 om.write_markdown("# Title\nBody")
116 md_path = om.get_run_dir() / "report.md"
117 assert_that(md_path.exists()).is_true()
118 assert_that(md_path.read_text().startswith("# Title")).is_true()
121def test_write_html(temp_output_dir: str) -> None:
122 """Test writing report.html file.
124 Args:
125 temp_output_dir: Temporary directory fixture for test output.
126 """
127 om = OutputManager(base_dir=temp_output_dir)
128 om.write_html("<h1>Title</h1>")
129 html_path = om.get_run_dir() / "report.html"
130 assert_that(html_path.exists()).is_true()
131 assert_that(html_path.read_text()).contains("<h1>Title</h1>")
134def test_write_csv(temp_output_dir: str) -> None:
135 """Test writing summary.csv file.
137 Args:
138 temp_output_dir: Temporary directory fixture for test output.
139 """
140 om = OutputManager(base_dir=temp_output_dir)
141 rows = [["a", "1"], ["b", "2"]]
142 header = ["col1", "col2"]
143 om.write_csv(rows, header)
144 csv_path = om.get_run_dir() / "summary.csv"
145 assert_that(csv_path.exists()).is_true()
146 with open(csv_path) as f:
147 reader = list(csv.reader(f))
148 assert_that(reader[0]).is_equal_to(header)
149 assert_that(reader[1]).is_equal_to(["a", "1"])
150 assert_that(reader[2]).is_equal_to(["b", "2"])
153def test_write_reports_from_results(temp_output_dir: str) -> None:
154 """Test write_reports_from_results generates all report files with correct content.
156 Args:
157 temp_output_dir: Temporary directory fixture for test output.
158 """
159 om = OutputManager(base_dir=temp_output_dir)
160 issues = [make_issue("foo.py", 1, "E001", "Test error")]
161 results = [make_tool_result("tool1", 1, issues), make_tool_result("tool2", 0, [])]
162 om.write_reports_from_results(results) # type: ignore[arg-type]
163 md = (om.get_run_dir() / "report.md").read_text()
164 assert_that("tool1" in md and "foo.py" in md and ("E001" in md)).is_true()
165 html = (om.get_run_dir() / "report.html").read_text()
166 assert_that("tool1" in html and "foo.py" in html and ("E001" in html)).is_true()
167 csv_path = om.get_run_dir() / "summary.csv"
168 with open(csv_path) as f:
169 reader = list(csv.reader(f))
170 assert_that(reader[0][:2]).is_equal_to(["tool", "issues_count"])
171 assert_that(any("tool1" in row for row in reader)).is_true()
172 assert_that(any("foo.py" in row for row in reader)).is_true()
175def test_write_reports_from_results_with_none_code(temp_output_dir: str) -> None:
176 """write_reports_from_results handles issues where code is None.
178 Ensures the consumer safety net (or "") prevents crashes when a tool
179 parser produces issues with code=None.
181 Args:
182 temp_output_dir: Temporary directory fixture for test output.
183 """
184 om = OutputManager(base_dir=temp_output_dir)
185 issues = [SimpleNamespace(file="bar.py", line=5, code=None, message="Some warning")]
186 results = [make_tool_result("tool1", 1, issues)]
187 om.write_reports_from_results(results) # type: ignore[arg-type]
188 md = (om.get_run_dir() / "report.md").read_text()
189 assert_that(md).contains("bar.py")
190 assert_that(md).contains("Some warning")
191 assert_that(md).does_not_contain("None")
192 html_content = (om.get_run_dir() / "report.html").read_text()
193 assert_that(html_content).contains("bar.py")
194 assert_that(html_content).contains("Some warning")
195 assert_that(html_content).does_not_contain("None")
196 csv_path = om.get_run_dir() / "summary.csv"
197 with open(csv_path) as f:
198 reader = list(csv.reader(f))
199 assert_that(any("bar.py" in row for row in reader)).is_true()
200 # Ensure "None" string doesn't appear in CSV rows
201 for row in reader:
202 for cell in row:
203 assert_that(cell).is_not_equal_to("None")
206def test_permission_fallback_uses_temp_dir(temp_output_dir: str) -> None:
207 """Verify PermissionError falls back to temp directory with warning.
209 When OutputManager cannot write to the base directory, it should fall back
210 to a temp directory and log a warning.
212 Args:
213 temp_output_dir: Temporary directory fixture for test output.
214 """
215 from unittest.mock import patch
217 # Create a restricted directory path
218 restricted_path = Path(temp_output_dir) / "restricted"
220 with patch("lintro.utils.output.manager.logger") as mock_logger:
221 # Patch mkdir to raise PermissionError on first call, succeed on second
222 original_mkdir = Path.mkdir
224 call_count = [0]
226 def mock_mkdir(self: Path, *args: Any, **kwargs: Any) -> None:
227 call_count[0] += 1
228 if call_count[0] == 1:
229 raise PermissionError("Permission denied")
230 return original_mkdir(self, *args, **kwargs)
232 with patch.object(Path, "mkdir", mock_mkdir):
233 om = OutputManager(base_dir=str(restricted_path))
235 # Should have fallen back to temp directory
236 assert_that(str(om.run_dir).startswith(str(restricted_path))).is_false()
237 assert_that(str(om.run_dir).startswith(tempfile.gettempdir())).is_true()
239 # Warning should have been logged
240 mock_logger.warning.assert_called_once()
241 warning_msg = mock_logger.warning.call_args[0][0]
242 assert_that(warning_msg).contains("Cannot write to")
243 assert_that(warning_msg).contains("permission denied")
244 assert_that(warning_msg).contains("using fallback")