Coverage for tests / unit / utils / test_output_writers.py: 100%
191 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 output_writers module."""
3from __future__ import annotations
5import json
6from pathlib import Path
7from unittest.mock import MagicMock
9import pytest
10from assertpy import assert_that
12from lintro.enums.action import Action
13from lintro.enums.output_format import OutputFormat
14from lintro.models.core.tool_result import ToolResult
15from lintro.utils.output import sanitize_csv_value, write_output_file
17# --- sanitize_csv_value tests ---
20@pytest.mark.parametrize(
21 "input_value,expected",
22 [
23 ("normal text", "normal text"),
24 ("", ""),
25 ("=SUM(A1:A10)", "'=SUM(A1:A10)"),
26 ("+1234", "'+1234"),
27 ("-500", "'-500"),
28 ("@mention", "'@mention"),
29 ("A normal value", "A normal value"),
30 ("test=value", "test=value"),
31 ],
32 ids=["normal", "empty", "equals", "plus", "minus", "at", "normal_space", "mid_eq"],
33)
34def test_sanitize_csv_value(input_value: str, expected: str) -> None:
35 """Test CSV value sanitization for formula injection prevention.
37 Args:
38 input_value: The input value to sanitize.
39 expected: The expected sanitized output.
40 """
41 result = sanitize_csv_value(input_value)
42 assert_that(result).is_equal_to(expected)
45# --- write_output_file tests ---
48@pytest.fixture
49def sample_results() -> list[ToolResult]:
50 """Create sample ToolResult objects for testing.
52 Returns:
53 List of sample ToolResult objects.
54 """
55 mock_issue = MagicMock()
56 mock_issue.file = "test.py"
57 mock_issue.line = 10
58 mock_issue.code = "E001"
59 mock_issue.message = "Test error message"
60 mock_issue.doc_url = ""
62 result_with_issues = ToolResult(
63 name="ruff",
64 success=False,
65 output="Found issues",
66 issues_count=1,
67 issues=[mock_issue],
68 )
69 result_no_issues = ToolResult(
70 name="mypy",
71 success=True,
72 output="No issues",
73 issues_count=0,
74 )
75 return [result_with_issues, result_no_issues]
78def test_write_json_output(tmp_path: Path, sample_results: list[ToolResult]) -> None:
79 """Test writing JSON output format.
81 Args:
82 tmp_path: Temporary directory path for testing.
83 sample_results: Sample tool results for testing.
84 """
85 output_path = tmp_path / "report.json"
87 write_output_file(
88 output_path=str(output_path),
89 output_format=OutputFormat.JSON,
90 all_results=sample_results,
91 action=Action.CHECK,
92 total_issues=1,
93 total_fixed=0,
94 )
96 assert_that(output_path.exists()).is_true()
97 content = json.loads(output_path.read_text())
98 assert_that(content).contains_key("timestamp")
99 assert_that(content["action"]).is_equal_to("check")
100 assert_that(content["summary"]["total_issues"]).is_equal_to(1)
101 assert_that(content["summary"]["tools_run"]).is_equal_to(2)
102 assert_that(len(content["results"])).is_equal_to(2)
105def test_write_csv_output(tmp_path: Path, sample_results: list[ToolResult]) -> None:
106 """Test writing CSV output format.
108 Args:
109 tmp_path: Temporary directory path for testing.
110 sample_results: Sample tool results for testing.
111 """
112 output_path = tmp_path / "report.csv"
114 write_output_file(
115 output_path=str(output_path),
116 output_format=OutputFormat.CSV,
117 all_results=sample_results,
118 action=Action.CHECK,
119 total_issues=1,
120 total_fixed=0,
121 )
123 assert_that(output_path.exists()).is_true()
124 content = output_path.read_text()
125 assert_that(content).contains("tool,issues_count,file,line,code,message")
126 assert_that(content).contains("ruff")
127 assert_that(content).contains("test.py")
130def test_write_markdown_output(
131 tmp_path: Path,
132 sample_results: list[ToolResult],
133) -> None:
134 """Test writing Markdown output format.
136 Args:
137 tmp_path: Temporary directory path for testing.
138 sample_results: Sample tool results for testing.
139 """
140 output_path = tmp_path / "report.md"
142 write_output_file(
143 output_path=str(output_path),
144 output_format=OutputFormat.MARKDOWN,
145 all_results=sample_results,
146 action=Action.CHECK,
147 total_issues=1,
148 total_fixed=0,
149 )
151 assert_that(output_path.exists()).is_true()
152 content = output_path.read_text()
153 assert_that(content).contains("# Lintro Report")
154 assert_that(content).contains("## Summary")
155 assert_that(content).contains("| Tool | Issues |")
156 assert_that(content).contains("| ruff | 1 |")
157 assert_that(content).contains("### ruff (1 issues)")
158 assert_that(content).contains("No issues found.")
161def test_write_html_output(tmp_path: Path, sample_results: list[ToolResult]) -> None:
162 """Test writing HTML output format.
164 Args:
165 tmp_path: Temporary directory path for testing.
166 sample_results: Sample tool results for testing.
167 """
168 output_path = tmp_path / "report.html"
170 write_output_file(
171 output_path=str(output_path),
172 output_format=OutputFormat.HTML,
173 all_results=sample_results,
174 action=Action.CHECK,
175 total_issues=1,
176 total_fixed=0,
177 )
179 assert_that(output_path.exists()).is_true()
180 content = output_path.read_text()
181 assert_that(content).contains("<html>")
182 assert_that(content).contains("<h1>Lintro Report</h1>")
183 assert_that(content).contains("<th>Tool</th>")
184 assert_that(content).contains("<td>ruff</td>")
185 assert_that(content).contains("</html>")
188def test_write_plain_output(tmp_path: Path, sample_results: list[ToolResult]) -> None:
189 """Test writing plain text output format.
191 Args:
192 tmp_path: Temporary directory path for testing.
193 sample_results: Sample tool results for testing.
194 """
195 output_path = tmp_path / "report.txt"
197 write_output_file(
198 output_path=str(output_path),
199 output_format=OutputFormat.PLAIN,
200 all_results=sample_results,
201 action=Action.CHECK,
202 total_issues=1,
203 total_fixed=0,
204 )
206 assert_that(output_path.exists()).is_true()
207 content = output_path.read_text()
208 assert_that(content).contains("Lintro Check Report")
209 assert_that(content).contains("ruff: 1 issues")
210 assert_that(content).contains("Total Issues: 1")
213def test_write_plain_output_fix_action(
214 tmp_path: Path,
215 sample_results: list[ToolResult],
216) -> None:
217 """Test plain output includes fixed count for fix action.
219 Args:
220 tmp_path: Temporary directory path for testing.
221 sample_results: Sample tool results for testing.
222 """
223 output_path = tmp_path / "report.txt"
225 write_output_file(
226 output_path=str(output_path),
227 output_format=OutputFormat.PLAIN,
228 all_results=sample_results,
229 action=Action.FIX,
230 total_issues=1,
231 total_fixed=1,
232 )
234 content = output_path.read_text()
235 assert_that(content).contains("Lintro Fix Report")
236 assert_that(content).contains("Total Fixed: 1")
239def test_creates_parent_directories(
240 tmp_path: Path,
241 sample_results: list[ToolResult],
242) -> None:
243 """Test that parent directories are created if they don't exist.
245 Args:
246 tmp_path: Temporary directory path for testing.
247 sample_results: Sample tool results for testing.
248 """
249 output_path = tmp_path / "nested" / "dir" / "report.json"
251 write_output_file(
252 output_path=str(output_path),
253 output_format=OutputFormat.JSON,
254 all_results=sample_results,
255 action=Action.CHECK,
256 total_issues=0,
257 total_fixed=0,
258 )
260 assert_that(output_path.exists()).is_true()
263def test_json_with_issues_details(tmp_path: Path) -> None:
264 """Test JSON output includes issue details.
266 Args:
267 tmp_path: Temporary directory path for testing.
268 """
269 mock_issue = MagicMock()
270 mock_issue.file = "error.py"
271 mock_issue.line = 42
272 mock_issue.code = "W999"
273 mock_issue.message = "Warning message"
274 mock_issue.doc_url = ""
276 result = ToolResult(
277 name="pylint",
278 success=False,
279 output="Issues found",
280 issues_count=1,
281 issues=[mock_issue],
282 )
284 output_path = tmp_path / "report.json"
285 write_output_file(
286 output_path=str(output_path),
287 output_format=OutputFormat.JSON,
288 all_results=[result],
289 action=Action.CHECK,
290 total_issues=1,
291 total_fixed=0,
292 )
294 content = json.loads(output_path.read_text())
295 issues = content["results"][0]["issues"]
296 assert_that(len(issues)).is_equal_to(1)
297 assert_that(issues[0]["file"]).is_equal_to("error.py")
298 assert_that(issues[0]["line"]).is_equal_to(42)
299 assert_that(issues[0]["code"]).is_equal_to("W999")
302def test_doc_url_rendered_in_json_markdown_html(tmp_path: Path) -> None:
303 """Test doc_url is rendered correctly in JSON, Markdown, and HTML output.
305 Args:
306 tmp_path: Temporary directory path for testing.
307 """
308 doc_link = "https://example.com/rule/E501"
310 mock_issue = MagicMock()
311 mock_issue.file = "foo.py"
312 mock_issue.line = 7
313 mock_issue.code = "E501"
314 mock_issue.message = "Line too long"
315 mock_issue.doc_url = doc_link
317 result = ToolResult(
318 name="ruff",
319 success=False,
320 output="Issues found",
321 issues_count=1,
322 issues=[mock_issue],
323 )
325 # JSON: doc_url key present with correct value
326 json_path = tmp_path / "report.json"
327 write_output_file(
328 output_path=str(json_path),
329 output_format=OutputFormat.JSON,
330 all_results=[result],
331 action=Action.CHECK,
332 total_issues=1,
333 total_fixed=0,
334 )
335 content = json.loads(json_path.read_text())
336 assert_that(content["results"][0]["issues"][0]["doc_url"]).is_equal_to(doc_link)
338 # Markdown: rendered as clickable link
339 md_path = tmp_path / "report.md"
340 write_output_file(
341 output_path=str(md_path),
342 output_format=OutputFormat.MARKDOWN,
343 all_results=[result],
344 action=Action.CHECK,
345 total_issues=1,
346 total_fixed=0,
347 )
348 md_content = md_path.read_text()
349 assert_that(md_content).contains(f"[docs]({doc_link})")
351 # HTML: rendered as <a> tag
352 html_path = tmp_path / "report.html"
353 write_output_file(
354 output_path=str(html_path),
355 output_format=OutputFormat.HTML,
356 all_results=[result],
357 action=Action.CHECK,
358 total_issues=1,
359 total_fixed=0,
360 )
361 html_content = html_path.read_text()
362 assert_that(html_content).contains(f'<a href="{doc_link}">docs</a>')
365def test_empty_doc_url_omitted_from_json(tmp_path: Path) -> None:
366 """Test that an empty doc_url is not included in JSON output.
368 Args:
369 tmp_path: Temporary directory path for testing.
370 """
371 mock_issue = MagicMock()
372 mock_issue.file = "bar.py"
373 mock_issue.line = 10
374 mock_issue.code = "W001"
375 mock_issue.message = "Some warning"
376 mock_issue.doc_url = ""
378 result = ToolResult(
379 name="test-tool",
380 success=False,
381 output="Issues found",
382 issues_count=1,
383 issues=[mock_issue],
384 )
386 json_path = tmp_path / "no_doc_url.json"
387 write_output_file(
388 output_path=str(json_path),
389 output_format=OutputFormat.JSON,
390 all_results=[result],
391 action=Action.CHECK,
392 total_issues=1,
393 total_fixed=0,
394 )
395 content = json.loads(json_path.read_text())
396 issue_data = content["results"][0]["issues"][0]
397 assert_that(issue_data).does_not_contain_key("doc_url")
400def test_html_escapes_special_characters(tmp_path: Path) -> None:
401 """Test HTML output escapes special characters.
403 Args:
404 tmp_path: Temporary directory path for testing.
405 """
406 mock_issue = MagicMock()
407 mock_issue.file = "test.py"
408 mock_issue.line = 1
409 mock_issue.code = "E001"
410 mock_issue.message = "<script>alert('xss')</script>"
411 mock_issue.doc_url = ""
413 result = ToolResult(
414 name="<tool>",
415 success=False,
416 output="",
417 issues_count=1,
418 issues=[mock_issue],
419 )
421 output_path = tmp_path / "report.html"
422 write_output_file(
423 output_path=str(output_path),
424 output_format=OutputFormat.HTML,
425 all_results=[result],
426 action=Action.CHECK,
427 total_issues=1,
428 total_fixed=0,
429 )
431 content = output_path.read_text()
432 assert_that(content).contains("<tool>")
433 assert_that(content).contains("<script>")
434 assert_that(content).does_not_contain("<script>alert")
437def test_markdown_escapes_pipe_characters(tmp_path: Path) -> None:
438 """Test Markdown output escapes pipe characters.
440 Args:
441 tmp_path: Temporary directory path for testing.
442 """
443 mock_issue = MagicMock()
444 mock_issue.file = "test|file.py"
445 mock_issue.line = 1
446 mock_issue.code = "E|001"
447 mock_issue.message = "Message with | pipe"
448 mock_issue.doc_url = ""
450 result = ToolResult(
451 name="ruff",
452 success=False,
453 output="",
454 issues_count=1,
455 issues=[mock_issue],
456 )
458 output_path = tmp_path / "report.md"
459 write_output_file(
460 output_path=str(output_path),
461 output_format=OutputFormat.MARKDOWN,
462 all_results=[result],
463 action=Action.CHECK,
464 total_issues=1,
465 total_fixed=0,
466 )
468 content = output_path.read_text()
469 assert_that(content).contains(r"test\|file.py")
470 assert_that(content).contains(r"E\|001")
473def test_grid_format_same_as_plain(
474 tmp_path: Path,
475 sample_results: list[ToolResult],
476) -> None:
477 """Test GRID format uses same output as PLAIN.
479 Args:
480 tmp_path: Temporary directory path for testing.
481 sample_results: Sample tool results for testing.
482 """
483 output_path = tmp_path / "report.txt"
485 write_output_file(
486 output_path=str(output_path),
487 output_format=OutputFormat.GRID,
488 all_results=sample_results,
489 action=Action.CHECK,
490 total_issues=1,
491 total_fixed=0,
492 )
494 content = output_path.read_text()
495 assert_that(content).contains("Lintro Check Report")
498def test_doc_url_rendered_in_csv(tmp_path: Path) -> None:
499 """Test doc_url appears in CSV output when set.
501 Args:
502 tmp_path: Temporary directory path for testing.
503 """
504 doc_link = "https://docs.astral.sh/ruff/rules/line-too-long/"
506 mock_issue = MagicMock()
507 mock_issue.file = "foo.py"
508 mock_issue.line = 7
509 mock_issue.code = "E501"
510 mock_issue.message = "Line too long"
511 mock_issue.doc_url = doc_link
513 result = ToolResult(
514 name="ruff",
515 success=False,
516 output="Issues found",
517 issues_count=1,
518 issues=[mock_issue],
519 )
521 csv_path = tmp_path / "report.csv"
522 write_output_file(
523 output_path=str(csv_path),
524 output_format=OutputFormat.CSV,
525 all_results=[result],
526 action=Action.CHECK,
527 total_issues=1,
528 total_fixed=0,
529 )
530 content = csv_path.read_text()
531 assert_that(content).contains("doc_url")
532 assert_that(content).contains(doc_link)
535def test_empty_doc_url_is_empty_string_in_csv(tmp_path: Path) -> None:
536 """Test empty doc_url appears as empty cell in CSV, not 'None'.
538 Args:
539 tmp_path: Temporary directory path for testing.
540 """
541 mock_issue = MagicMock()
542 mock_issue.file = "bar.py"
543 mock_issue.line = 10
544 mock_issue.code = "W001"
545 mock_issue.message = "Some warning"
546 mock_issue.doc_url = ""
548 result = ToolResult(
549 name="test-tool",
550 success=False,
551 output="Issues found",
552 issues_count=1,
553 issues=[mock_issue],
554 )
556 csv_path = tmp_path / "no_doc.csv"
557 write_output_file(
558 output_path=str(csv_path),
559 output_format=OutputFormat.CSV,
560 all_results=[result],
561 action=Action.CHECK,
562 total_issues=1,
563 total_fixed=0,
564 )
565 content = csv_path.read_text()
566 assert_that(content).contains("doc_url")
567 assert_that(content).does_not_contain("None")