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

1"""Unit tests for output_writers module.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7from unittest.mock import MagicMock 

8 

9import pytest 

10from assertpy import assert_that 

11 

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 

16 

17# --- sanitize_csv_value tests --- 

18 

19 

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. 

36 

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) 

43 

44 

45# --- write_output_file tests --- 

46 

47 

48@pytest.fixture 

49def sample_results() -> list[ToolResult]: 

50 """Create sample ToolResult objects for testing. 

51 

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 = "" 

61 

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] 

76 

77 

78def test_write_json_output(tmp_path: Path, sample_results: list[ToolResult]) -> None: 

79 """Test writing JSON output format. 

80 

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" 

86 

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 ) 

95 

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) 

103 

104 

105def test_write_csv_output(tmp_path: Path, sample_results: list[ToolResult]) -> None: 

106 """Test writing CSV output format. 

107 

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" 

113 

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 ) 

122 

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") 

128 

129 

130def test_write_markdown_output( 

131 tmp_path: Path, 

132 sample_results: list[ToolResult], 

133) -> None: 

134 """Test writing Markdown output format. 

135 

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" 

141 

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 ) 

150 

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.") 

159 

160 

161def test_write_html_output(tmp_path: Path, sample_results: list[ToolResult]) -> None: 

162 """Test writing HTML output format. 

163 

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" 

169 

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 ) 

178 

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>") 

186 

187 

188def test_write_plain_output(tmp_path: Path, sample_results: list[ToolResult]) -> None: 

189 """Test writing plain text output format. 

190 

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" 

196 

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 ) 

205 

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") 

211 

212 

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. 

218 

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" 

224 

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 ) 

233 

234 content = output_path.read_text() 

235 assert_that(content).contains("Lintro Fix Report") 

236 assert_that(content).contains("Total Fixed: 1") 

237 

238 

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. 

244 

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" 

250 

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 ) 

259 

260 assert_that(output_path.exists()).is_true() 

261 

262 

263def test_json_with_issues_details(tmp_path: Path) -> None: 

264 """Test JSON output includes issue details. 

265 

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 = "" 

275 

276 result = ToolResult( 

277 name="pylint", 

278 success=False, 

279 output="Issues found", 

280 issues_count=1, 

281 issues=[mock_issue], 

282 ) 

283 

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 ) 

293 

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") 

300 

301 

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. 

304 

305 Args: 

306 tmp_path: Temporary directory path for testing. 

307 """ 

308 doc_link = "https://example.com/rule/E501" 

309 

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 

316 

317 result = ToolResult( 

318 name="ruff", 

319 success=False, 

320 output="Issues found", 

321 issues_count=1, 

322 issues=[mock_issue], 

323 ) 

324 

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) 

337 

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})") 

350 

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>') 

363 

364 

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. 

367 

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 = "" 

377 

378 result = ToolResult( 

379 name="test-tool", 

380 success=False, 

381 output="Issues found", 

382 issues_count=1, 

383 issues=[mock_issue], 

384 ) 

385 

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") 

398 

399 

400def test_html_escapes_special_characters(tmp_path: Path) -> None: 

401 """Test HTML output escapes special characters. 

402 

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 = "" 

412 

413 result = ToolResult( 

414 name="<tool>", 

415 success=False, 

416 output="", 

417 issues_count=1, 

418 issues=[mock_issue], 

419 ) 

420 

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 ) 

430 

431 content = output_path.read_text() 

432 assert_that(content).contains("&lt;tool&gt;") 

433 assert_that(content).contains("&lt;script&gt;") 

434 assert_that(content).does_not_contain("<script>alert") 

435 

436 

437def test_markdown_escapes_pipe_characters(tmp_path: Path) -> None: 

438 """Test Markdown output escapes pipe characters. 

439 

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 = "" 

449 

450 result = ToolResult( 

451 name="ruff", 

452 success=False, 

453 output="", 

454 issues_count=1, 

455 issues=[mock_issue], 

456 ) 

457 

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 ) 

467 

468 content = output_path.read_text() 

469 assert_that(content).contains(r"test\|file.py") 

470 assert_that(content).contains(r"E\|001") 

471 

472 

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. 

478 

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" 

484 

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 ) 

493 

494 content = output_path.read_text() 

495 assert_that(content).contains("Lintro Check Report") 

496 

497 

498def test_doc_url_rendered_in_csv(tmp_path: Path) -> None: 

499 """Test doc_url appears in CSV output when set. 

500 

501 Args: 

502 tmp_path: Temporary directory path for testing. 

503 """ 

504 doc_link = "https://docs.astral.sh/ruff/rules/line-too-long/" 

505 

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 

512 

513 result = ToolResult( 

514 name="ruff", 

515 success=False, 

516 output="Issues found", 

517 issues_count=1, 

518 issues=[mock_issue], 

519 ) 

520 

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) 

533 

534 

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'. 

537 

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 = "" 

547 

548 result = ToolResult( 

549 name="test-tool", 

550 success=False, 

551 output="Issues found", 

552 issues_count=1, 

553 issues=[mock_issue], 

554 ) 

555 

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")