139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
# encoding:utf-8
|
|
"""
|
|
Regression tests for the Edit tool's fuzzy matching.
|
|
|
|
When the provided oldText does not match byte-for-byte (usually because the
|
|
whitespace differs), the Edit tool falls back to a whitespace-tolerant fuzzy
|
|
match. The fuzzy match must replace only the matched region in the original
|
|
file. It previously rewrote the entire file from a whitespace-normalized copy,
|
|
which collapsed the indentation of every untouched line and corrupted the file
|
|
(e.g. broke Python indentation).
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from agent.tools.edit.edit import Edit
|
|
|
|
|
|
class TestEditFuzzyPreservesWhitespace(unittest.TestCase):
|
|
def setUp(self):
|
|
self.work = tempfile.mkdtemp()
|
|
self.path = os.path.join(self.work, "sample.py")
|
|
self.original = (
|
|
"def foo():\n"
|
|
" x = 1\n"
|
|
" y = 2\n"
|
|
" return x + y\n"
|
|
)
|
|
with open(self.path, "w", encoding="utf-8") as f:
|
|
f.write(self.original)
|
|
self.tool = Edit({"cwd": self.work})
|
|
|
|
def _read(self):
|
|
with open(self.path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
def test_fuzzy_match_does_not_reformat_untouched_lines(self):
|
|
# oldText differs from the file only by extra spaces around '=', so the
|
|
# exact match fails and the fuzzy path is taken. Only the 'x = 1' line
|
|
# should change; the other lines must keep their 4-space indentation.
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": " x = 1",
|
|
"newText": " x = 100",
|
|
})
|
|
self.assertEqual(result.status, "success", result.result)
|
|
|
|
expected = (
|
|
"def foo():\n"
|
|
" x = 100\n"
|
|
" y = 2\n"
|
|
" return x + y\n"
|
|
)
|
|
self.assertEqual(self._read(), expected)
|
|
|
|
def test_exact_match_still_replaces_in_place(self):
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": " y = 2",
|
|
"newText": " y = 20",
|
|
})
|
|
self.assertEqual(result.status, "success", result.result)
|
|
self.assertEqual(
|
|
self._read(),
|
|
"def foo():\n x = 1\n y = 20\n return x + y\n",
|
|
)
|
|
|
|
def test_multiline_fuzzy_match_preserves_surrounding_indentation(self):
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": " x = 1\n y = 2", # extra spaces on 2nd line
|
|
"newText": " x = 9\n y = 8",
|
|
})
|
|
self.assertEqual(result.status, "success", result.result)
|
|
self.assertEqual(
|
|
self._read(),
|
|
"def foo():\n x = 9\n y = 8\n return x + y\n",
|
|
)
|
|
|
|
def test_fuzzy_match_with_unindented_oldtext_preserves_file_indent(self):
|
|
# oldText has NO leading indentation (and loose spacing around '='), so
|
|
# the exact match fails and the fuzzy path runs against an indented file
|
|
# line. The file's indentation must be preserved -- kept OUTSIDE the
|
|
# replaced region -- instead of being swallowed into the match and
|
|
# dropped (which would break the file's indentation). newText is
|
|
# likewise unindented, mirroring exact-substring replacement.
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": "x = 1",
|
|
"newText": "x = 100",
|
|
})
|
|
self.assertEqual(result.status, "success", result.result)
|
|
self.assertEqual(
|
|
self._read(),
|
|
"def foo():\n x = 100\n y = 2\n return x + y\n",
|
|
)
|
|
|
|
def test_exact_match_rejects_multiple_occurrences(self):
|
|
# Two byte-identical statements; the exact-match path applies and the
|
|
# uniqueness guard counts exact occurrences, so the ambiguous edit is
|
|
# rejected instead of silently editing only the first.
|
|
with open(self.path, "w", encoding="utf-8") as f:
|
|
f.write("a = 1\nb = 2\na = 1\n")
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": "a = 1",
|
|
"newText": "a = 9",
|
|
})
|
|
self.assertEqual(result.status, "error", result.result)
|
|
self.assertIn("occurrences", result.result)
|
|
# An ambiguous match must leave the file untouched.
|
|
self.assertEqual(self._read(), "a = 1\nb = 2\na = 1\n")
|
|
|
|
def test_fuzzy_match_rejects_multiple_occurrences(self):
|
|
# oldText uses loose spacing, so the exact match fails and the fuzzy
|
|
# path runs. The uniqueness guard now counts with the SAME regex used
|
|
# to match/replace, so an ambiguous fuzzy match (two hits) is rejected
|
|
# rather than silently editing the first one.
|
|
with open(self.path, "w", encoding="utf-8") as f:
|
|
f.write("def foo():\n x = 1\n y = 2\n x = 1\n")
|
|
result = self.tool.execute({
|
|
"path": self.path,
|
|
"oldText": "x = 1",
|
|
"newText": "x = 99",
|
|
})
|
|
self.assertEqual(result.status, "error", result.result)
|
|
self.assertIn("occurrences", result.result)
|
|
self.assertEqual(
|
|
self._read(),
|
|
"def foo():\n x = 1\n y = 2\n x = 1\n",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|