test_patch_parser.py
1 """Tests for the V4A patch format parser.""" 2 3 from types import SimpleNamespace 4 5 from tools.patch_parser import ( 6 OperationType, 7 apply_v4a_operations, 8 parse_v4a_patch, 9 ) 10 11 12 class TestParseUpdateFile: 13 def test_basic_update(self): 14 patch = """\ 15 *** Begin Patch 16 *** Update File: src/main.py 17 @@ def greet @@ 18 def greet(): 19 - print("hello") 20 + print("hi") 21 *** End Patch""" 22 ops, err = parse_v4a_patch(patch) 23 assert err is None 24 assert len(ops) == 1 25 26 op = ops[0] 27 assert op.operation == OperationType.UPDATE 28 assert op.file_path == "src/main.py" 29 assert len(op.hunks) == 1 30 31 hunk = op.hunks[0] 32 assert hunk.context_hint == "def greet" 33 prefixes = [l.prefix for l in hunk.lines] 34 assert " " in prefixes 35 assert "-" in prefixes 36 assert "+" in prefixes 37 38 def test_multiple_hunks(self): 39 patch = """\ 40 *** Begin Patch 41 *** Update File: f.py 42 @@ first @@ 43 a 44 -b 45 +c 46 @@ second @@ 47 x 48 -y 49 +z 50 *** End Patch""" 51 ops, err = parse_v4a_patch(patch) 52 assert err is None 53 assert len(ops) == 1 54 assert len(ops[0].hunks) == 2 55 assert ops[0].hunks[0].context_hint == "first" 56 assert ops[0].hunks[1].context_hint == "second" 57 58 59 class TestParseAddFile: 60 def test_add_file(self): 61 patch = """\ 62 *** Begin Patch 63 *** Add File: new/module.py 64 +import os 65 + 66 +print("hello") 67 *** End Patch""" 68 ops, err = parse_v4a_patch(patch) 69 assert err is None 70 assert len(ops) == 1 71 72 op = ops[0] 73 assert op.operation == OperationType.ADD 74 assert op.file_path == "new/module.py" 75 assert len(op.hunks) == 1 76 77 contents = [l.content for l in op.hunks[0].lines if l.prefix == "+"] 78 assert contents[0] == "import os" 79 assert contents[2] == 'print("hello")' 80 81 82 class TestParseDeleteFile: 83 def test_delete_file(self): 84 patch = """\ 85 *** Begin Patch 86 *** Delete File: old/stuff.py 87 *** End Patch""" 88 ops, err = parse_v4a_patch(patch) 89 assert err is None 90 assert len(ops) == 1 91 assert ops[0].operation == OperationType.DELETE 92 assert ops[0].file_path == "old/stuff.py" 93 94 95 class TestParseMoveFile: 96 def test_move_file(self): 97 patch = """\ 98 *** Begin Patch 99 *** Move File: old/path.py -> new/path.py 100 *** End Patch""" 101 ops, err = parse_v4a_patch(patch) 102 assert err is None 103 assert len(ops) == 1 104 assert ops[0].operation == OperationType.MOVE 105 assert ops[0].file_path == "old/path.py" 106 assert ops[0].new_path == "new/path.py" 107 108 109 class TestParseInvalidPatch: 110 def test_empty_patch_returns_empty_ops(self): 111 ops, err = parse_v4a_patch("") 112 assert err is None 113 assert ops == [] 114 115 def test_no_begin_marker_still_parses(self): 116 patch = """\ 117 *** Update File: f.py 118 line1 119 -old 120 +new 121 *** End Patch""" 122 ops, err = parse_v4a_patch(patch) 123 assert err is None 124 assert len(ops) == 1 125 126 def test_multiple_operations(self): 127 patch = """\ 128 *** Begin Patch 129 *** Add File: a.py 130 +content_a 131 *** Delete File: b.py 132 *** Update File: c.py 133 keep 134 -remove 135 +add 136 *** End Patch""" 137 ops, err = parse_v4a_patch(patch) 138 assert err is None 139 assert len(ops) == 3 140 assert ops[0].operation == OperationType.ADD 141 assert ops[1].operation == OperationType.DELETE 142 assert ops[2].operation == OperationType.UPDATE 143 144 145 class TestApplyUpdate: 146 def test_preserves_non_prefix_pipe_characters_in_unmodified_lines(self): 147 patch = """\ 148 *** Begin Patch 149 *** Update File: sample.py 150 @@ result @@ 151 result = 1 152 - return result 153 + return result + 1 154 *** End Patch""" 155 operations, err = parse_v4a_patch(patch) 156 assert err is None 157 158 class FakeFileOps: 159 def __init__(self): 160 self.written = None 161 162 def read_file_raw(self, path): 163 return SimpleNamespace( 164 content=( 165 'def run():\n' 166 ' cmd = "echo a | sed s/a/b/"\n' 167 ' result = 1\n' 168 ' return result' 169 ), 170 error=None, 171 ) 172 173 def write_file(self, path, content): 174 self.written = content 175 return SimpleNamespace(error=None) 176 177 file_ops = FakeFileOps() 178 179 result = apply_v4a_operations(operations, file_ops) 180 181 assert result.success is True 182 assert file_ops.written == ( 183 'def run():\n' 184 ' cmd = "echo a | sed s/a/b/"\n' 185 ' result = 1\n' 186 ' return result + 1' 187 ) 188 189 190 class TestAdditionOnlyHunks: 191 """Regression tests for #3081 — addition-only hunks were silently dropped.""" 192 193 def test_addition_only_hunk_with_context_hint(self): 194 """A hunk with only + lines should insert at the context hint location.""" 195 patch = """\ 196 *** Begin Patch 197 *** Update File: src/app.py 198 @@ def main @@ 199 +def helper(): 200 + return 42 201 *** End Patch""" 202 ops, err = parse_v4a_patch(patch) 203 assert err is None 204 assert len(ops) == 1 205 assert len(ops[0].hunks) == 1 206 207 hunk = ops[0].hunks[0] 208 # All lines should be additions 209 assert all(l.prefix == '+' for l in hunk.lines) 210 211 # Apply to a file that contains the context hint 212 class FakeFileOps: 213 written = None 214 def read_file_raw(self, path): 215 return SimpleNamespace( 216 content="def main():\n pass\n", 217 error=None, 218 ) 219 def write_file(self, path, content): 220 self.written = content 221 return SimpleNamespace(error=None) 222 223 file_ops = FakeFileOps() 224 result = apply_v4a_operations(ops, file_ops) 225 assert result.success is True 226 assert "def helper():" in file_ops.written 227 assert "return 42" in file_ops.written 228 229 def test_addition_only_hunk_without_context_hint(self): 230 """A hunk with only + lines and no context hint appends at end of file.""" 231 patch = """\ 232 *** Begin Patch 233 *** Update File: src/app.py 234 +def new_func(): 235 + return True 236 *** End Patch""" 237 ops, err = parse_v4a_patch(patch) 238 assert err is None 239 240 class FakeFileOps: 241 written = None 242 def read_file_raw(self, path): 243 return SimpleNamespace( 244 content="existing = True\n", 245 error=None, 246 ) 247 def write_file(self, path, content): 248 self.written = content 249 return SimpleNamespace(error=None) 250 251 file_ops = FakeFileOps() 252 result = apply_v4a_operations(ops, file_ops) 253 assert result.success is True 254 assert file_ops.written.endswith("def new_func():\n return True\n") 255 assert "existing = True" in file_ops.written 256 257 258 class TestReadFileRaw: 259 """Bug 1 regression tests — files > 2000 lines and lines > 2000 chars.""" 260 261 def test_apply_update_file_over_2000_lines(self): 262 """A hunk targeting line 2200 must not truncate the file to 2000 lines.""" 263 patch = """\ 264 *** Begin Patch 265 *** Update File: big.py 266 @@ marker_at_2200 @@ 267 line_2200 268 -old_value 269 +new_value 270 *** End Patch""" 271 ops, err = parse_v4a_patch(patch) 272 assert err is None 273 274 # Build a 2500-line file; the hunk targets a region at line 2200 275 lines = [f"line_{i}" for i in range(1, 2501)] 276 lines[2199] = "line_2200" # index 2199 = line 2200 277 lines[2200] = "old_value" 278 file_content = "\n".join(lines) 279 280 class FakeFileOps: 281 written = None 282 def read_file_raw(self, path): 283 return SimpleNamespace(content=file_content, error=None) 284 def write_file(self, path, content): 285 self.written = content 286 return SimpleNamespace(error=None) 287 288 file_ops = FakeFileOps() 289 result = apply_v4a_operations(ops, file_ops) 290 assert result.success is True 291 written_lines = file_ops.written.split("\n") 292 assert len(written_lines) == 2500, ( 293 f"Expected 2500 lines, got {len(written_lines)}" 294 ) 295 assert "new_value" in file_ops.written 296 assert "old_value" not in file_ops.written 297 298 def test_apply_update_preserves_long_lines(self): 299 """A line > 2000 chars must be preserved verbatim after an unrelated hunk.""" 300 long_line = "x" * 3000 301 patch = """\ 302 *** Begin Patch 303 *** Update File: wide.py 304 @@ short_func @@ 305 def short_func(): 306 - return 1 307 + return 2 308 *** End Patch""" 309 ops, err = parse_v4a_patch(patch) 310 assert err is None 311 312 file_content = f"def short_func():\n return 1\n{long_line}\n" 313 314 class FakeFileOps: 315 written = None 316 def read_file_raw(self, path): 317 return SimpleNamespace(content=file_content, error=None) 318 def write_file(self, path, content): 319 self.written = content 320 return SimpleNamespace(error=None) 321 322 file_ops = FakeFileOps() 323 result = apply_v4a_operations(ops, file_ops) 324 assert result.success is True 325 assert long_line in file_ops.written, "Long line was truncated" 326 assert "... [truncated]" not in file_ops.written 327 328 329 class TestValidationPhase: 330 """Bug 2 regression tests — validation prevents partial apply.""" 331 332 def test_validation_failure_writes_nothing(self): 333 """If one hunk is invalid, no files should be written.""" 334 patch = """\ 335 *** Begin Patch 336 *** Update File: a.py 337 def good(): 338 - return 1 339 + return 2 340 *** Update File: b.py 341 THIS LINE DOES NOT EXIST 342 - old 343 + new 344 *** End Patch""" 345 ops, err = parse_v4a_patch(patch) 346 assert err is None 347 348 written = {} 349 350 class FakeFileOps: 351 def read_file_raw(self, path): 352 files = { 353 "a.py": "def good():\n return 1\n", 354 "b.py": "completely different content\n", 355 } 356 content = files.get(path) 357 if content is None: 358 return SimpleNamespace(content=None, error=f"File not found: {path}") 359 return SimpleNamespace(content=content, error=None) 360 361 def write_file(self, path, content): 362 written[path] = content 363 return SimpleNamespace(error=None) 364 365 result = apply_v4a_operations(ops, FakeFileOps()) 366 assert result.success is False 367 assert written == {}, f"No files should have been written, got: {list(written.keys())}" 368 assert "validation failed" in result.error.lower() 369 370 def test_all_valid_operations_applied(self): 371 """When all operations are valid, all files are written.""" 372 patch = """\ 373 *** Begin Patch 374 *** Update File: a.py 375 def foo(): 376 - return 1 377 + return 2 378 *** Update File: b.py 379 def bar(): 380 - pass 381 + return True 382 *** End Patch""" 383 ops, err = parse_v4a_patch(patch) 384 assert err is None 385 386 written = {} 387 388 class FakeFileOps: 389 def read_file_raw(self, path): 390 files = { 391 "a.py": "def foo():\n return 1\n", 392 "b.py": "def bar():\n pass\n", 393 } 394 return SimpleNamespace(content=files[path], error=None) 395 396 def write_file(self, path, content): 397 written[path] = content 398 return SimpleNamespace(error=None) 399 400 result = apply_v4a_operations(ops, FakeFileOps()) 401 assert result.success is True 402 assert set(written.keys()) == {"a.py", "b.py"} 403 404 405 class TestApplyDelete: 406 """Tests for _apply_delete producing a real unified diff.""" 407 408 def test_delete_diff_contains_removed_lines(self): 409 """_apply_delete must embed the actual file content in the diff, not a placeholder.""" 410 patch = """\ 411 *** Begin Patch 412 *** Delete File: old/stuff.py 413 *** End Patch""" 414 ops, err = parse_v4a_patch(patch) 415 assert err is None 416 417 class FakeFileOps: 418 deleted = False 419 420 def read_file_raw(self, path): 421 return SimpleNamespace( 422 content="def old_func():\n return 42\n", 423 error=None, 424 ) 425 426 def delete_file(self, path): 427 self.deleted = True 428 return SimpleNamespace(error=None) 429 430 file_ops = FakeFileOps() 431 result = apply_v4a_operations(ops, file_ops) 432 433 assert result.success is True 434 assert file_ops.deleted is True 435 # Diff must contain the actual removed lines, not a bare comment 436 assert "-def old_func():" in result.diff 437 assert "- return 42" in result.diff 438 assert "/dev/null" in result.diff 439 440 def test_delete_diff_fallback_on_empty_file(self): 441 """An empty file should produce the fallback comment diff.""" 442 patch = """\ 443 *** Begin Patch 444 *** Delete File: empty.py 445 *** End Patch""" 446 ops, err = parse_v4a_patch(patch) 447 assert err is None 448 449 class FakeFileOps: 450 def read_file_raw(self, path): 451 return SimpleNamespace(content="", error=None) 452 453 def delete_file(self, path): 454 return SimpleNamespace(error=None) 455 456 result = apply_v4a_operations(ops, FakeFileOps()) 457 assert result.success is True 458 # unified_diff produces nothing for two empty inputs — fallback comment expected 459 assert "Deleted" in result.diff or result.diff.strip() == "" 460 461 462 class TestCountOccurrences: 463 def test_basic(self): 464 from tools.patch_parser import _count_occurrences 465 assert _count_occurrences("aaa", "a") == 3 466 assert _count_occurrences("aaa", "aa") == 2 467 assert _count_occurrences("hello world", "xyz") == 0 468 assert _count_occurrences("", "x") == 0 469 470 471 class TestParseErrorSignalling: 472 """Bug 3 regression tests — parse_v4a_patch must signal errors, not swallow them.""" 473 474 def test_update_with_no_hunks_returns_error(self): 475 """An UPDATE with no hunk lines is a malformed patch and should error.""" 476 patch = """\ 477 *** Begin Patch 478 *** Update File: foo.py 479 *** End Patch""" 480 ops, err = parse_v4a_patch(patch) 481 assert err is not None, "Expected a parse error for hunk-less UPDATE" 482 assert ops == [] 483 484 def test_move_without_destination_returns_error(self): 485 """A MOVE without '->' syntax should not silently produce a broken operation.""" 486 # The move regex requires '->' so this will be treated as an unrecognised 487 # line and the op is never created. Confirm nothing crashes and ops is empty. 488 patch = """\ 489 *** Begin Patch 490 *** Move File: src/foo.py 491 *** End Patch""" 492 ops, err = parse_v4a_patch(patch) 493 # Either parse sees zero ops (fine) or returns an error (also fine). 494 # What is NOT acceptable is ops=[MOVE op with empty new_path] + err=None. 495 if ops: 496 assert err is not None, ( 497 "MOVE with missing destination must either produce empty ops or an error" 498 ) 499 500 def test_valid_patch_returns_no_error(self): 501 """A well-formed patch must still return err=None.""" 502 patch = """\ 503 *** Begin Patch 504 *** Update File: f.py 505 ctx 506 -old 507 +new 508 *** End Patch""" 509 ops, err = parse_v4a_patch(patch) 510 assert err is None 511 assert len(ops) == 1