Coverage for tests / unit / ai / test_interactive.py: 100%

164 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tests for interactive fix review.""" 

2 

3from __future__ import annotations 

4 

5from unittest.mock import patch 

6 

7from assertpy import assert_that 

8 

9from lintro.ai.apply import _apply_fix, apply_fixes 

10from lintro.ai.interactive import ( 

11 _group_by_code, 

12 _render_prompt, 

13 review_fixes_interactive, 

14) 

15from lintro.ai.models import AIFixSuggestion 

16 

17# -- _apply_fix no fallback ---------------------------------------------------- 

18 

19 

20def test_apply_fix_no_fallback_when_line_targeting_misses(tmp_path): 

21 """When line-targeted replacement misses, fix fails (no fallback).""" 

22 f = tmp_path / "test.py" 

23 # File must be long enough that clamped target_idx (last line) 

24 # plus search_radius (default 5) cannot reach line 0. 

25 filler = "".join(f"line {i}\n" for i in range(2, 22)) 

26 f.write_text(f"old code\n{filler}") 

27 

28 fix = AIFixSuggestion( 

29 file=str(f), 

30 line=99, # Way off -- no match near this line 

31 original_code="old code", 

32 suggested_code="new code", 

33 ) 

34 

35 result = _apply_fix(fix, workspace_root=tmp_path) 

36 assert_that(result).is_false() 

37 # File should be unchanged -- no fallback replacement 

38 assert_that(f.read_text()).contains("old code") 

39 

40 

41@patch("lintro.ai.apply.logger") 

42def test_apply_fix_line_targeted_does_not_log_warning(mock_logger, tmp_path): 

43 """Successful line-targeted replacement should NOT log a warning.""" 

44 f = tmp_path / "test.py" 

45 f.write_text("x = 1\nprint('ok')\n") 

46 

47 fix = AIFixSuggestion( 

48 file=str(f), 

49 line=1, 

50 original_code="x = 1", 

51 suggested_code="x = 2", 

52 ) 

53 

54 result = _apply_fix(fix, workspace_root=tmp_path) 

55 assert_that(result).is_true() 

56 mock_logger.warning.assert_not_called() 

57 

58 

59# -- _apply_fix ---------------------------------------------------------------- 

60 

61 

62def test_apply_fix_applies_fix(tmp_path): 

63 """Verify that a valid fix replaces the original code in the file.""" 

64 f = tmp_path / "test.py" 

65 f.write_text("assert x > 0\nprint('ok')\n") 

66 

67 fix = AIFixSuggestion( 

68 file=str(f), 

69 line=1, 

70 original_code="assert x > 0", 

71 suggested_code="if x <= 0:\n raise ValueError", 

72 ) 

73 

74 result = _apply_fix(fix, workspace_root=tmp_path) 

75 assert_that(result).is_true() 

76 

77 content = f.read_text() 

78 assert_that(content).contains("if x <= 0:") 

79 assert_that(content).does_not_contain("assert x > 0") 

80 

81 

82def test_apply_fix_skips_when_original_not_found(tmp_path): 

83 """_apply_fix returns False when original code is not found.""" 

84 f = tmp_path / "test.py" 

85 f.write_text("x = 1\n") 

86 

87 fix = AIFixSuggestion( 

88 file=str(f), 

89 line=1, 

90 original_code="nonexistent code", 

91 suggested_code="new code", 

92 ) 

93 

94 result = _apply_fix(fix, workspace_root=tmp_path) 

95 assert_that(result).is_false() 

96 

97 

98def test_apply_fix_handles_missing_file(tmp_path): 

99 """Verify that _apply_fix returns False for a nonexistent file path.""" 

100 fix = AIFixSuggestion( 

101 file=str(tmp_path / "nonexistent" / "file.py"), 

102 original_code="x", 

103 suggested_code="y", 

104 ) 

105 result = _apply_fix(fix, workspace_root=tmp_path) 

106 assert_that(result).is_false() 

107 

108 

109def test_apply_fix_line_targeted_replacement(tmp_path): 

110 """Fix applies near the target line, not an earlier occurrence.""" 

111 f = tmp_path / "test.py" 

112 f.write_text("x = 1\nprint('a')\nx = 1\nprint('b')\n") 

113 

114 fix = AIFixSuggestion( 

115 file=str(f), 

116 line=3, 

117 original_code="x = 1", 

118 suggested_code="x = 2", 

119 ) 

120 

121 result = _apply_fix(fix, workspace_root=tmp_path) 

122 assert_that(result).is_true() 

123 

124 content = f.read_text() 

125 lines = content.splitlines() 

126 # First occurrence should remain unchanged 

127 assert_that(lines[0]).is_equal_to("x = 1") 

128 # Third line (line 3) should be changed 

129 assert_that(lines[2]).is_equal_to("x = 2") 

130 

131 

132def test_apply_fix_fails_when_line_targeting_misses(tmp_path): 

133 """Returns False when line targeting misses (no fallback).""" 

134 f = tmp_path / "test.py" 

135 # File must be long enough that clamped target_idx (last line) 

136 # plus default search_radius (5) cannot reach line 0. 

137 filler = "".join(f"line {i}\n" for i in range(2, 22)) 

138 f.write_text(f"old code\n{filler}") 

139 

140 fix = AIFixSuggestion( 

141 file=str(f), 

142 line=99, # Way off -- no match near this line 

143 original_code="old code", 

144 suggested_code="new code", 

145 ) 

146 

147 result = _apply_fix(fix, workspace_root=tmp_path) 

148 assert_that(result).is_false() 

149 # File should remain unchanged 

150 assert_that(f.read_text()).contains("old code") 

151 

152 

153def test_apply_fix_empty_original_code(tmp_path): 

154 """Verify that an empty original_code string causes _apply_fix to return False.""" 

155 f = tmp_path / "test.py" 

156 f.write_text("x = 1\n") 

157 

158 fix = AIFixSuggestion( 

159 file=str(f), 

160 original_code="", 

161 suggested_code="y = 2", 

162 ) 

163 

164 result = _apply_fix(fix, workspace_root=tmp_path) 

165 assert_that(result).is_false() 

166 

167 

168def test_apply_fix_blocks_writes_outside_workspace_root(tmp_path): 

169 """Verify that fixes targeting files outside workspace_root are rejected.""" 

170 workspace_root = tmp_path / "workspace" 

171 workspace_root.mkdir(parents=True) 

172 outside_file = tmp_path / "outside.py" 

173 outside_file.write_text("x = 1\n", encoding="utf-8") 

174 

175 fix = AIFixSuggestion( 

176 file=str(outside_file), 

177 original_code="x = 1", 

178 suggested_code="x = 2", 

179 ) 

180 

181 result = _apply_fix(fix, workspace_root=workspace_root) 

182 assert_that(result).is_false() 

183 assert_that(outside_file.read_text(encoding="utf-8")).is_equal_to("x = 1\n") 

184 

185 

186def test_apply_fix_fails_when_line_misses_with_flag(tmp_path): 

187 """Fix fails when line targeting misses (auto_apply flag passed but unused).""" 

188 f = tmp_path / "test.py" 

189 # File must be long enough that clamped target_idx (last line) 

190 # plus default search_radius (5) cannot reach line 0. 

191 filler = "".join(f"line {i}\n" for i in range(2, 22)) 

192 f.write_text(f"old code\n{filler}") 

193 

194 fix = AIFixSuggestion( 

195 file=str(f), 

196 line=99, # Way off -- no match near this line 

197 original_code="old code", 

198 suggested_code="new code", 

199 ) 

200 

201 result = _apply_fix(fix, auto_apply=True, workspace_root=tmp_path) 

202 assert_that(result).is_false() 

203 # File should be unchanged 

204 assert_that(f.read_text()).contains("old code") 

205 

206 

207def test_apply_fix_search_radius_limits_search(tmp_path): 

208 """A narrow search_radius can miss a match outside the radius.""" 

209 f = tmp_path / "test.py" 

210 # Place target code far from line hint 

211 lines = ["filler\n"] * 20 + ["target code\n"] 

212 f.write_text("".join(lines)) 

213 

214 fix = AIFixSuggestion( 

215 file=str(f), 

216 line=1, # Hint at line 1, target is at line 21 

217 original_code="target code", 

218 suggested_code="replaced code", 

219 ) 

220 

221 # With radius=2, line-targeted search won't reach line 21 

222 result = _apply_fix(fix, auto_apply=True, search_radius=2, workspace_root=tmp_path) 

223 assert_that(result).is_false() 

224 assert_that(f.read_text()).contains("target code") 

225 

226 

227# -- apply_fixes --------------------------------------------------------------- 

228 

229 

230def test_apply_fixes_returns_only_successful(tmp_path): 

231 """Verify that apply_fixes returns only successfully applied suggestions.""" 

232 f = tmp_path / "test.py" 

233 f.write_text("x = 1\n") 

234 

235 applied = apply_fixes( 

236 [ 

237 AIFixSuggestion( 

238 file=str(f), 

239 line=1, 

240 original_code="x = 1", 

241 suggested_code="x = 2", 

242 ), 

243 AIFixSuggestion( 

244 file=str(f), 

245 line=1, 

246 original_code="missing", 

247 suggested_code="x = 3", 

248 ), 

249 ], 

250 workspace_root=tmp_path, 

251 ) 

252 assert_that(applied).is_length(1) 

253 assert_that(applied[0].suggested_code).is_equal_to("x = 2") 

254 

255 

256def test_apply_fixes_with_auto_apply_flag_fails_when_line_misses(tmp_path): 

257 """apply_fixes with auto_apply=True fails when line targeting misses.""" 

258 f = tmp_path / "test.py" 

259 # File must be long enough that clamped target_idx (last line) 

260 # plus default search_radius (5) cannot reach line 0. 

261 filler = "".join(f"line {i}\n" for i in range(2, 22)) 

262 f.write_text(f"old code\n{filler}") 

263 

264 applied = apply_fixes( 

265 [ 

266 AIFixSuggestion( 

267 file=str(f), 

268 line=99, 

269 original_code="old code", 

270 suggested_code="new code", 

271 ), 

272 ], 

273 auto_apply=True, 

274 workspace_root=tmp_path, 

275 ) 

276 # auto_apply=True prevents fallback, so nothing should be applied 

277 assert_that(applied).is_empty() 

278 assert_that(f.read_text()).contains("old code") 

279 

280 

281# -- _group_by_code ------------------------------------------------------------ 

282 

283 

284def test_group_by_code_groups_by_code(): 

285 """Verify that fixes are grouped into separate lists by their rule code.""" 

286 fixes = [ 

287 AIFixSuggestion(file="a.py", code="B101"), 

288 AIFixSuggestion(file="b.py", code="B101"), 

289 AIFixSuggestion(file="c.py", code="E501"), 

290 ] 

291 groups = _group_by_code(fixes) 

292 assert_that(groups).contains_key("B101") 

293 assert_that(groups).contains_key("E501") 

294 assert_that(groups["B101"]).is_length(2) 

295 assert_that(groups["E501"]).is_length(1) 

296 

297 

298def test_group_by_code_empty_code_uses_unknown(): 

299 """Verify that an empty code string is grouped under the 'unknown' key.""" 

300 fixes = [AIFixSuggestion(file="a.py", code="")] 

301 groups = _group_by_code(fixes) 

302 assert_that(groups).contains_key("unknown") 

303 

304 

305def test_group_by_code_empty_list(): 

306 """Verify that an empty fix list produces an empty grouping.""" 

307 groups = _group_by_code([]) 

308 assert_that(groups).is_empty() 

309 

310 

311# -- review_fixes_interactive -------------------------------------------------- 

312 

313 

314def test_review_fixes_interactive_empty_suggestions(tmp_path): 

315 """Verify that empty suggestions result in zero accepted, rejected, and applied.""" 

316 accepted, rejected, applied = review_fixes_interactive( 

317 [], 

318 workspace_root=tmp_path, 

319 ) 

320 assert_that(accepted).is_equal_to(0) 

321 assert_that(rejected).is_equal_to(0) 

322 assert_that(applied).is_empty() 

323 

324 

325def test_review_fixes_interactive_non_interactive_skips(tmp_path): 

326 """Verify that non-interactive stdin causes the review to be skipped.""" 

327 fixes = [ 

328 AIFixSuggestion( 

329 file="test.py", 

330 original_code="x", 

331 suggested_code="y", 

332 ), 

333 ] 

334 with patch("sys.stdin") as mock_stdin: 

335 mock_stdin.isatty.return_value = False 

336 accepted, _rejected, applied = review_fixes_interactive( 

337 fixes, 

338 workspace_root=tmp_path, 

339 ) 

340 assert_that(accepted).is_equal_to(0) 

341 assert_that(applied).is_empty() 

342 

343 

344def test_review_fixes_interactive_prompt_text_clarifies_scope(): 

345 """Verify that the rendered prompt includes scope clarification text.""" 

346 prompt = _render_prompt(validate_mode=False, safe_default=False) 

347 assert_that(prompt).contains("accept group + remaining") 

348 assert_that(prompt).contains("verify fixes") 

349 

350 

351@patch("lintro.ai.interactive.sys.stdin") 

352@patch("lintro.ai.interactive.click.getchar") 

353def test_review_fixes_interactive_accept_via_keyboard( 

354 mock_getchar, 

355 mock_stdin, 

356 tmp_path, 

357): 

358 """Pressing 'y' accepts a group and applies fixes.""" 

359 mock_stdin.isatty.return_value = True 

360 mock_getchar.return_value = "y" 

361 

362 f = tmp_path / "test.py" 

363 f.write_text("old_code\n") 

364 

365 fixes = [ 

366 AIFixSuggestion( 

367 file=str(f), 

368 line=1, 

369 code="E501", 

370 original_code="old_code", 

371 suggested_code="new_code", 

372 ), 

373 ] 

374 

375 accepted, rejected, applied = review_fixes_interactive( 

376 fixes, 

377 workspace_root=tmp_path, 

378 ) 

379 

380 assert_that(accepted).is_equal_to(1) 

381 assert_that(rejected).is_equal_to(0) 

382 assert_that(applied).is_length(1) 

383 

384 

385@patch("lintro.ai.interactive.sys.stdin") 

386@patch("lintro.ai.interactive.click.getchar") 

387def test_review_fixes_interactive_reject_via_keyboard( 

388 mock_getchar, 

389 mock_stdin, 

390 tmp_path, 

391): 

392 """Pressing 'r' rejects a group.""" 

393 mock_stdin.isatty.return_value = True 

394 mock_getchar.return_value = "r" 

395 

396 fixes = [ 

397 AIFixSuggestion( 

398 file=str(tmp_path / "test.py"), 

399 code="B101", 

400 original_code="x", 

401 suggested_code="y", 

402 ), 

403 ] 

404 

405 accepted, rejected, applied = review_fixes_interactive( 

406 fixes, 

407 workspace_root=tmp_path, 

408 ) 

409 

410 assert_that(accepted).is_equal_to(0) 

411 assert_that(rejected).is_equal_to(1) 

412 assert_that(applied).is_empty() 

413 

414 

415@patch("lintro.ai.interactive.sys.stdin") 

416@patch("lintro.ai.interactive.click.getchar") 

417def test_review_fixes_interactive_quit_via_keyboard( 

418 mock_getchar, 

419 mock_stdin, 

420 tmp_path, 

421): 

422 """Pressing 'q' quits the review early.""" 

423 mock_stdin.isatty.return_value = True 

424 mock_getchar.return_value = "q" 

425 

426 fixes = [ 

427 AIFixSuggestion( 

428 file=str(tmp_path / "a.py"), 

429 code="B101", 

430 original_code="x", 

431 suggested_code="y", 

432 ), 

433 AIFixSuggestion( 

434 file=str(tmp_path / "b.py"), 

435 code="E501", 

436 original_code="a", 

437 suggested_code="b", 

438 ), 

439 ] 

440 

441 accepted, _rejected, applied = review_fixes_interactive( 

442 fixes, 

443 workspace_root=tmp_path, 

444 ) 

445 

446 assert_that(accepted).is_equal_to(0) 

447 # Only the first group was seen before quit 

448 assert_that(applied).is_empty()