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

207 statements  

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

1"""Tests for AI fix application logic (lintro.ai.apply).""" 

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.models import AIFixSuggestion 

11 

12# --------------------------------------------------------------------------- 

13# Line-targeted replacement — exact line 

14# --------------------------------------------------------------------------- 

15 

16 

17def test_apply_fix_exact_line_match(tmp_path): 

18 """Fix applies when original_code is on exactly the reported line.""" 

19 f = tmp_path / "test.py" 

20 f.write_text("a = 1\nb = 2\nc = 3\n") 

21 

22 fix = AIFixSuggestion( 

23 file=str(f), 

24 line=2, 

25 original_code="b = 2", 

26 suggested_code="b = 42", 

27 ) 

28 

29 result = _apply_fix(fix, workspace_root=tmp_path) 

30 assert_that(result).is_true() 

31 assert_that(f.read_text()).is_equal_to("a = 1\nb = 42\nc = 3\n") 

32 

33 

34# --------------------------------------------------------------------------- 

35# Line-targeted replacement — adjacent lines within radius 

36# --------------------------------------------------------------------------- 

37 

38 

39def test_apply_fix_adjacent_line_within_radius(tmp_path): 

40 """Fix succeeds when original_code is a few lines off the hint.""" 

41 f = tmp_path / "test.py" 

42 content = "line1\nline2\ntarget\nline4\nline5\n" 

43 f.write_text(content) 

44 

45 fix = AIFixSuggestion( 

46 file=str(f), 

47 line=5, # hint is off by 2 

48 original_code="target", 

49 suggested_code="replaced", 

50 ) 

51 

52 result = _apply_fix(fix, workspace_root=tmp_path) 

53 assert_that(result).is_true() 

54 assert_that(f.read_text()).contains("replaced") 

55 assert_that(f.read_text()).does_not_contain("target") 

56 

57 

58def test_apply_fix_prefers_closest_occurrence(tmp_path): 

59 """When duplicate code exists, the occurrence closest to hint wins.""" 

60 f = tmp_path / "test.py" 

61 f.write_text("x = 1\nfiller\nfiller\nfiller\nx = 1\n") 

62 

63 fix = AIFixSuggestion( 

64 file=str(f), 

65 line=5, 

66 original_code="x = 1", 

67 suggested_code="x = 99", 

68 ) 

69 

70 result = _apply_fix(fix, workspace_root=tmp_path) 

71 assert_that(result).is_true() 

72 

73 lines = f.read_text().splitlines() 

74 # First occurrence untouched, last one replaced 

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

76 assert_that(lines[4]).is_equal_to("x = 99") 

77 

78 

79# --------------------------------------------------------------------------- 

80# Search radius limiting 

81# --------------------------------------------------------------------------- 

82 

83 

84def test_apply_fix_search_radius_1_limits_search(tmp_path): 

85 """Radius=1 only checks target line and one line above/below.""" 

86 f = tmp_path / "test.py" 

87 f.write_text("a\nb\nc\nd\ne\n") 

88 

89 fix = AIFixSuggestion( 

90 file=str(f), 

91 line=1, # hint at line 1, target at line 5 

92 original_code="e", 

93 suggested_code="E", 

94 ) 

95 

96 result = _apply_fix(fix, workspace_root=tmp_path, search_radius=1) 

97 assert_that(result).is_false() 

98 assert_that(f.read_text()).contains("e") 

99 

100 

101def test_apply_fix_large_radius_finds_distant_match(tmp_path): 

102 """A large radius can reach code far from the hint.""" 

103 lines = ["filler\n"] * 10 + ["target\n"] 

104 f = tmp_path / "test.py" 

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

106 

107 fix = AIFixSuggestion( 

108 file=str(f), 

109 line=5, 

110 original_code="target", 

111 suggested_code="replaced", 

112 ) 

113 

114 result = _apply_fix(fix, workspace_root=tmp_path, search_radius=10) 

115 assert_that(result).is_true() 

116 assert_that(f.read_text()).contains("replaced") 

117 

118 

119# --------------------------------------------------------------------------- 

120# Workspace boundary enforcement 

121# --------------------------------------------------------------------------- 

122 

123 

124def test_apply_fix_rejects_symlink_escape(tmp_path): 

125 """Fix targeting a symlink that resolves outside workspace is rejected.""" 

126 workspace = tmp_path / "workspace" 

127 workspace.mkdir() 

128 outside = tmp_path / "outside.py" 

129 outside.write_text("x = 1\n") 

130 

131 link = workspace / "link.py" 

132 link.symlink_to(outside) 

133 

134 fix = AIFixSuggestion( 

135 file=str(link), 

136 line=1, 

137 original_code="x = 1", 

138 suggested_code="x = 2", 

139 ) 

140 

141 result = _apply_fix(fix, workspace_root=workspace) 

142 assert_that(result).is_false() 

143 assert_that(outside.read_text()).is_equal_to("x = 1\n") 

144 

145 

146def test_apply_fix_rejects_parent_traversal(tmp_path): 

147 """Fix with '../' traversal outside workspace is rejected.""" 

148 workspace = tmp_path / "workspace" 

149 workspace.mkdir() 

150 outside = tmp_path / "outside.py" 

151 outside.write_text("x = 1\n") 

152 

153 fix = AIFixSuggestion( 

154 file=str(workspace / ".." / "outside.py"), 

155 line=1, 

156 original_code="x = 1", 

157 suggested_code="x = 2", 

158 ) 

159 

160 result = _apply_fix(fix, workspace_root=workspace) 

161 assert_that(result).is_false() 

162 assert_that(outside.read_text()).is_equal_to("x = 1\n") 

163 

164 

165def test_apply_fix_accepts_file_inside_workspace(tmp_path): 

166 """Fix targeting a file inside workspace succeeds normally.""" 

167 workspace = tmp_path / "workspace" 

168 workspace.mkdir() 

169 f = workspace / "ok.py" 

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

171 

172 fix = AIFixSuggestion( 

173 file=str(f), 

174 line=1, 

175 original_code="x = 1", 

176 suggested_code="x = 2", 

177 ) 

178 

179 result = _apply_fix(fix, workspace_root=workspace) 

180 assert_that(result).is_true() 

181 assert_that(f.read_text()).is_equal_to("x = 2\n") 

182 

183 

184# --------------------------------------------------------------------------- 

185# Empty original_code handling 

186# --------------------------------------------------------------------------- 

187 

188 

189def test_apply_fix_empty_original_code_returns_false(tmp_path): 

190 """An empty original_code causes _apply_fix to return False.""" 

191 f = tmp_path / "test.py" 

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

193 

194 fix = AIFixSuggestion( 

195 file=str(f), 

196 line=1, 

197 original_code="", 

198 suggested_code="y = 2", 

199 ) 

200 

201 result = _apply_fix(fix, workspace_root=tmp_path) 

202 assert_that(result).is_false() 

203 assert_that(f.read_text()).is_equal_to("x = 1\n") 

204 

205 

206def test_apply_fix_whitespace_only_original_code(tmp_path): 

207 """Whitespace-only original_code does not match typical code lines.""" 

208 f = tmp_path / "test.py" 

209 f.write_text("x = 1\ny = 2\n") 

210 

211 fix = AIFixSuggestion( 

212 file=str(f), 

213 line=1, 

214 original_code=" ", 

215 suggested_code="z = 3", 

216 ) 

217 

218 result = _apply_fix(fix, workspace_root=tmp_path) 

219 assert_that(result).is_false() 

220 

221 

222# --------------------------------------------------------------------------- 

223# Missing file handling 

224# --------------------------------------------------------------------------- 

225 

226 

227def test_apply_fix_missing_file_returns_false(tmp_path): 

228 """_apply_fix returns False for a nonexistent file path.""" 

229 fix = AIFixSuggestion( 

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

231 line=1, 

232 original_code="x", 

233 suggested_code="y", 

234 ) 

235 

236 result = _apply_fix(fix, workspace_root=tmp_path) 

237 assert_that(result).is_false() 

238 

239 

240def test_apply_fix_empty_file_path_returns_false(tmp_path): 

241 """_apply_fix returns False when file path is empty string.""" 

242 fix = AIFixSuggestion( 

243 file="", 

244 line=1, 

245 original_code="x", 

246 suggested_code="y", 

247 ) 

248 

249 result = _apply_fix(fix, workspace_root=tmp_path) 

250 assert_that(result).is_false() 

251 

252 

253# --------------------------------------------------------------------------- 

254# Multi-line replacement 

255# --------------------------------------------------------------------------- 

256 

257 

258def test_apply_fix_multi_line_original_and_suggested(tmp_path): 

259 """Multi-line original is replaced by multi-line suggested code.""" 

260 f = tmp_path / "test.py" 

261 f.write_text("if True:\n x = 1\n y = 2\nprint('done')\n") 

262 

263 fix = AIFixSuggestion( 

264 file=str(f), 

265 line=2, 

266 original_code=" x = 1\n y = 2", 

267 suggested_code=" x = 10\n y = 20\n z = 30", 

268 ) 

269 

270 result = _apply_fix(fix, workspace_root=tmp_path) 

271 assert_that(result).is_true() 

272 

273 content = f.read_text() 

274 assert_that(content).contains("x = 10") 

275 assert_that(content).contains("y = 20") 

276 assert_that(content).contains("z = 30") 

277 assert_that(content).does_not_contain("x = 1\n") 

278 assert_that(content).contains("print('done')") 

279 

280 

281def test_apply_fix_multi_line_to_single_line(tmp_path): 

282 """Multi-line original replaced by single line (fewer lines).""" 

283 f = tmp_path / "test.py" 

284 f.write_text("a = 1\nb = 2\nc = 3\nd = 4\n") 

285 

286 fix = AIFixSuggestion( 

287 file=str(f), 

288 line=2, 

289 original_code="b = 2\nc = 3", 

290 suggested_code="bc = 23", 

291 ) 

292 

293 result = _apply_fix(fix, workspace_root=tmp_path) 

294 assert_that(result).is_true() 

295 

296 content = f.read_text() 

297 assert_that(content).contains("bc = 23") 

298 assert_that(content).contains("a = 1") 

299 assert_that(content).contains("d = 4") 

300 

301 

302def test_apply_fix_single_line_to_multi_line(tmp_path): 

303 """Single-line original expands to multiple lines.""" 

304 f = tmp_path / "test.py" 

305 f.write_text("x = compute()\n") 

306 

307 fix = AIFixSuggestion( 

308 file=str(f), 

309 line=1, 

310 original_code="x = compute()", 

311 suggested_code="try:\n x = compute()\nexcept Exception:\n x = None", 

312 ) 

313 

314 result = _apply_fix(fix, workspace_root=tmp_path) 

315 assert_that(result).is_true() 

316 

317 content = f.read_text() 

318 assert_that(content).contains("try:") 

319 assert_that(content).contains("except Exception:") 

320 

321 

322# --------------------------------------------------------------------------- 

323# Newline handling edge cases 

324# --------------------------------------------------------------------------- 

325 

326 

327def test_apply_fix_file_without_trailing_newline(tmp_path): 

328 """Fix works on files that do not end with a trailing newline.""" 

329 f = tmp_path / "test.py" 

330 f.write_text("x = 1") # no trailing newline 

331 

332 fix = AIFixSuggestion( 

333 file=str(f), 

334 line=1, 

335 original_code="x = 1", 

336 suggested_code="x = 2", 

337 ) 

338 

339 result = _apply_fix(fix, workspace_root=tmp_path) 

340 assert_that(result).is_true() 

341 assert_that(f.read_text()).contains("x = 2") 

342 

343 

344def test_apply_fix_preserves_other_lines_newlines(tmp_path): 

345 """Lines not involved in the fix retain their newlines.""" 

346 f = tmp_path / "test.py" 

347 f.write_text("a = 1\nb = 2\nc = 3\n") 

348 

349 fix = AIFixSuggestion( 

350 file=str(f), 

351 line=2, 

352 original_code="b = 2", 

353 suggested_code="b = 99", 

354 ) 

355 

356 result = _apply_fix(fix, workspace_root=tmp_path) 

357 assert_that(result).is_true() 

358 assert_that(f.read_text()).is_equal_to("a = 1\nb = 99\nc = 3\n") 

359 

360 

361# --------------------------------------------------------------------------- 

362# Invalid / negative line numbers 

363# --------------------------------------------------------------------------- 

364 

365 

366def test_apply_fix_negative_line_returns_false(tmp_path): 

367 """Negative line number returns False.""" 

368 f = tmp_path / "test.py" 

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

370 

371 fix = AIFixSuggestion( 

372 file=str(f), 

373 line=-1, 

374 original_code="x = 1", 

375 suggested_code="x = 2", 

376 ) 

377 

378 result = _apply_fix(fix, workspace_root=tmp_path) 

379 assert_that(result).is_false() 

380 

381 

382def test_apply_fix_line_zero_returns_false(tmp_path): 

383 """Line 0 means 'unspecified' — search_order is empty, returns False.""" 

384 f = tmp_path / "test.py" 

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

386 

387 fix = AIFixSuggestion( 

388 file=str(f), 

389 line=0, 

390 original_code="x = 1", 

391 suggested_code="x = 2", 

392 ) 

393 

394 result = _apply_fix(fix, workspace_root=tmp_path) 

395 assert_that(result).is_false() 

396 

397 

398# --------------------------------------------------------------------------- 

399# Line number far beyond file length (clamping) 

400# --------------------------------------------------------------------------- 

401 

402 

403def test_apply_fix_line_beyond_file_length_clamps(tmp_path): 

404 """Line number beyond EOF is clamped; code near end is still found.""" 

405 f = tmp_path / "test.py" 

406 f.write_text("a\nb\ntarget\n") 

407 

408 fix = AIFixSuggestion( 

409 file=str(f), 

410 line=999, 

411 original_code="target", 

412 suggested_code="replaced", 

413 ) 

414 

415 # default radius=5 should cover the 3-line file from the clamped position 

416 result = _apply_fix(fix, workspace_root=tmp_path) 

417 assert_that(result).is_true() 

418 assert_that(f.read_text()).contains("replaced") 

419 

420 

421# --------------------------------------------------------------------------- 

422# apply_fixes — batch behaviour 

423# --------------------------------------------------------------------------- 

424 

425 

426def test_apply_fixes_returns_only_successful(tmp_path): 

427 """apply_fixes returns only successfully applied suggestions.""" 

428 f = tmp_path / "test.py" 

429 f.write_text("x = 1\ny = 2\n") 

430 

431 fixes = [ 

432 AIFixSuggestion( 

433 file=str(f), 

434 line=1, 

435 original_code="x = 1", 

436 suggested_code="x = 10", 

437 ), 

438 AIFixSuggestion( 

439 file=str(f), 

440 line=2, 

441 original_code="MISSING", 

442 suggested_code="z = 3", 

443 ), 

444 ] 

445 

446 applied = apply_fixes(fixes, workspace_root=tmp_path) 

447 assert_that(applied).is_length(1) 

448 assert_that(applied[0].suggested_code).is_equal_to("x = 10") 

449 

450 

451def test_apply_fixes_empty_list(tmp_path): 

452 """apply_fixes with an empty list returns an empty list.""" 

453 applied = apply_fixes([], workspace_root=tmp_path) 

454 assert_that(applied).is_empty() 

455 

456 

457def test_apply_fixes_all_fail(tmp_path): 

458 """apply_fixes returns empty when all fixes fail.""" 

459 f = tmp_path / "test.py" 

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

461 

462 fixes = [ 

463 AIFixSuggestion( 

464 file=str(f), 

465 line=1, 

466 original_code="NOPE", 

467 suggested_code="y", 

468 ), 

469 AIFixSuggestion( 

470 file=str(f), 

471 line=1, 

472 original_code="ALSO_NOPE", 

473 suggested_code="z", 

474 ), 

475 ] 

476 

477 applied = apply_fixes(fixes, workspace_root=tmp_path) 

478 assert_that(applied).is_empty() 

479 

480 

481def test_apply_fixes_forwards_search_radius(tmp_path): 

482 """apply_fixes passes search_radius through to _apply_fix.""" 

483 f = tmp_path / "test.py" 

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

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

486 

487 fixes = [ 

488 AIFixSuggestion( 

489 file=str(f), 

490 line=1, 

491 original_code="target", 

492 suggested_code="replaced", 

493 ), 

494 ] 

495 

496 # radius=2 won't reach line 21 from line 1 

497 applied = apply_fixes(fixes, workspace_root=tmp_path, search_radius=2) 

498 assert_that(applied).is_empty() 

499 assert_that(f.read_text()).contains("target") 

500 

501 

502def test_apply_fixes_forwards_auto_apply(tmp_path): 

503 """apply_fixes passes auto_apply through to _apply_fix.""" 

504 f = tmp_path / "test.py" 

505 f.write_text("old code\nline 2\n") 

506 

507 fixes = [ 

508 AIFixSuggestion( 

509 file=str(f), 

510 line=1, 

511 original_code="old code", 

512 suggested_code="new code", 

513 ), 

514 ] 

515 

516 with patch("lintro.ai.apply._apply_fix", return_value=True) as mock: 

517 apply_fixes(fixes, auto_apply=True, workspace_root=tmp_path) 

518 mock.assert_called_once() 

519 assert_that(mock.call_args.kwargs["auto_apply"]).is_true() 

520 

521 

522# --------------------------------------------------------------------------- 

523# Logging behaviour 

524# --------------------------------------------------------------------------- 

525 

526 

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

528def test_apply_fix_logs_debug_for_invalid_line(mock_logger, tmp_path): 

529 """Invalid (non-int-like) line triggers a debug log, not a crash.""" 

530 f = tmp_path / "test.py" 

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

532 

533 fix = AIFixSuggestion( 

534 file=str(f), 

535 line=-5, 

536 original_code="x = 1", 

537 suggested_code="x = 2", 

538 ) 

539 

540 result = _apply_fix(fix, workspace_root=tmp_path) 

541 assert_that(result).is_false() 

542 mock_logger.debug.assert_called_once() 

543 

544 

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

546def test_apply_fix_successful_no_warning(mock_logger, tmp_path): 

547 """Successful line-targeted replacement logs no warning.""" 

548 f = tmp_path / "test.py" 

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

550 

551 fix = AIFixSuggestion( 

552 file=str(f), 

553 line=1, 

554 original_code="x = 1", 

555 suggested_code="x = 2", 

556 ) 

557 

558 result = _apply_fix(fix, workspace_root=tmp_path) 

559 assert_that(result).is_true() 

560 mock_logger.warning.assert_not_called()