/ tests / tools / test_patch_parser.py
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