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

1"""Unit tests for `OutputManager` write helpers and report generation.""" 

2 

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 

11 

12import pytest 

13from assertpy import assert_that 

14 

15from lintro.utils.output import OutputManager 

16 

17 

18@pytest.fixture 

19def temp_output_dir() -> Generator[str, None, None]: 

20 """Provide a temporary directory path and clean up afterwards. 

21 

22 Yields: 

23 str: Path to a temporary directory for test outputs. 

24 """ 

25 d = tempfile.mkdtemp() 

26 yield d 

27 shutil.rmtree(d) 

28 

29 

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. 

36 

37 Args: 

38 name: Tool name. 

39 issues_count: Total issues count. 

40 issues: Optional list of issues. 

41 

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 ) 

51 

52 

53def make_issue(file: str, line: int, code: str, message: str) -> SimpleNamespace: 

54 """Factory for issue-like objects used in report tests. 

55 

56 Args: 

57 file: File path. 

58 line: Line number. 

59 code: Issue code. 

60 message: Description. 

61 

62 Returns: 

63 SimpleNamespace with file, line, code, and message. 

64 """ 

65 return SimpleNamespace(file=file, line=line, code=code, message=message) 

66 

67 

68def test_run_dir_creation(temp_output_dir: str) -> None: 

69 """Test that OutputManager creates a timestamped run directory. 

70 

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

77 

78 

79def test_write_console_log(temp_output_dir: str) -> None: 

80 """Test writing console.log file. 

81 

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

90 

91 

92def test_write_json(temp_output_dir: str) -> None: 

93 """Test writing results.json file. 

94 

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) 

106 

107 

108def test_write_markdown(temp_output_dir: str) -> None: 

109 """Test writing report.md file. 

110 

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

119 

120 

121def test_write_html(temp_output_dir: str) -> None: 

122 """Test writing report.html file. 

123 

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

132 

133 

134def test_write_csv(temp_output_dir: str) -> None: 

135 """Test writing summary.csv file. 

136 

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

151 

152 

153def test_write_reports_from_results(temp_output_dir: str) -> None: 

154 """Test write_reports_from_results generates all report files with correct content. 

155 

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

173 

174 

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. 

177 

178 Ensures the consumer safety net (or "") prevents crashes when a tool 

179 parser produces issues with code=None. 

180 

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

204 

205 

206def test_permission_fallback_uses_temp_dir(temp_output_dir: str) -> None: 

207 """Verify PermissionError falls back to temp directory with warning. 

208 

209 When OutputManager cannot write to the base directory, it should fall back 

210 to a temp directory and log a warning. 

211 

212 Args: 

213 temp_output_dir: Temporary directory fixture for test output. 

214 """ 

215 from unittest.mock import patch 

216 

217 # Create a restricted directory path 

218 restricted_path = Path(temp_output_dir) / "restricted" 

219 

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 

223 

224 call_count = [0] 

225 

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) 

231 

232 with patch.object(Path, "mkdir", mock_mkdir): 

233 om = OutputManager(base_dir=str(restricted_path)) 

234 

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

238 

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