mirror of
https://github.com/codeflash-ai/codeflash-agent.git
synced 2026-05-04 18:25:19 +00:00
Add 182 new tests across optimize, V4A diff, CST utils, and postprocess modules. Key coverage improvements: - optimize/_pipeline.py: 29% → 97% - optimize/_router.py: 40% → 93% - diff/_v4a.py: 40% → 97% - languages/python/_cst_utils.py: 67% → 96% - languages/python/_postprocess.py: 67% → 87% Also apply ruff format to 5 files that had formatting drift.
1169 lines
32 KiB
Python
1169 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from codeflash_api.diff._base import DiffError, DiffMethod
|
|
from codeflash_api.diff._search_replace import (
|
|
SearchAndReplaceDiff,
|
|
SearchReplaceBlock,
|
|
apply_patches,
|
|
extract_patches,
|
|
find_with_whitespace_flexibility,
|
|
parse_diff,
|
|
)
|
|
from codeflash_api.diff._v4a import (
|
|
V4ADiff,
|
|
_norm,
|
|
apply_update,
|
|
extract_update_sections,
|
|
find_context,
|
|
find_context_core,
|
|
)
|
|
|
|
|
|
class TestDiffMethod:
|
|
"""Tests for DiffMethod enum."""
|
|
|
|
def test_values(self) -> None:
|
|
"""
|
|
All three diff methods exist.
|
|
"""
|
|
assert "no_diff" == DiffMethod.NO_DIFF.value
|
|
assert "v4a" == DiffMethod.V4A.value
|
|
assert "search_and_replace" == DiffMethod.SEARCH_AND_REPLACE.value
|
|
|
|
|
|
class TestSearchReplaceParseDiff:
|
|
"""Tests for parse_diff."""
|
|
|
|
def test_single_block(self) -> None:
|
|
"""
|
|
Single SEARCH/REPLACE block is parsed correctly.
|
|
"""
|
|
diff = "<<<<<<< SEARCH\nold code\n=======\nnew code\n>>>>>>> REPLACE\n"
|
|
blocks = parse_diff(diff)
|
|
|
|
assert 1 == len(blocks)
|
|
assert "old code" == blocks[0].search
|
|
assert "new code" == blocks[0].replace
|
|
|
|
def test_multiple_blocks(self) -> None:
|
|
"""
|
|
Multiple SEARCH/REPLACE blocks are all parsed.
|
|
"""
|
|
diff = (
|
|
"<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE\n"
|
|
"<<<<<<< SEARCH\nbaz\n=======\nqux\n>>>>>>> REPLACE\n"
|
|
)
|
|
blocks = parse_diff(diff)
|
|
|
|
assert 2 == len(blocks)
|
|
|
|
def test_empty_search(self) -> None:
|
|
"""
|
|
Empty search content (append mode) is valid.
|
|
"""
|
|
diff = "<<<<<<< SEARCH\n=======\nnew stuff\n>>>>>>> REPLACE\n"
|
|
blocks = parse_diff(diff)
|
|
|
|
assert "" == blocks[0].search
|
|
assert "new stuff" == blocks[0].replace
|
|
|
|
def test_empty_input_raises(self) -> None:
|
|
"""
|
|
Empty string raises ValueError.
|
|
"""
|
|
with pytest.raises(ValueError, match="Empty"):
|
|
parse_diff("")
|
|
|
|
def test_missing_delimiter_raises(self) -> None:
|
|
"""
|
|
Missing ======= marker raises ValueError.
|
|
"""
|
|
with pytest.raises(ValueError, match="======="):
|
|
parse_diff("<<<<<<< SEARCH\nfoo\n>>>>>>> REPLACE\n")
|
|
|
|
def test_missing_replace_marker_raises(self) -> None:
|
|
"""
|
|
Missing >>>>>>> REPLACE marker raises ValueError.
|
|
"""
|
|
with pytest.raises(ValueError, match="REPLACE"):
|
|
parse_diff("<<<<<<< SEARCH\nfoo\n=======\nbar\n")
|
|
|
|
|
|
class TestFindWithWhitespaceFlexibility:
|
|
"""Tests for find_with_whitespace_flexibility."""
|
|
|
|
def test_exact_match(self) -> None:
|
|
"""
|
|
Exact content is found.
|
|
"""
|
|
result = find_with_whitespace_flexibility(
|
|
"hello world", "prefix hello world suffix"
|
|
)
|
|
|
|
assert result is not None
|
|
assert 7 == result[0]
|
|
|
|
def test_flexible_whitespace(self) -> None:
|
|
"""
|
|
Tabs match spaces.
|
|
"""
|
|
result = find_with_whitespace_flexibility(
|
|
"hello world", "hello\tworld"
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
def test_no_match(self) -> None:
|
|
"""
|
|
Non-existent content returns None.
|
|
"""
|
|
assert find_with_whitespace_flexibility("xyz", "abc") is None
|
|
|
|
|
|
class TestApplyPatches:
|
|
"""Tests for apply_patches."""
|
|
|
|
def test_simple_replace(self) -> None:
|
|
"""
|
|
Exact match is replaced.
|
|
"""
|
|
diff = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n"
|
|
result = apply_patches(diff, "before old after")
|
|
|
|
assert "before new after" == result
|
|
|
|
def test_append_mode(self) -> None:
|
|
"""
|
|
Empty search appends to content.
|
|
"""
|
|
diff = "<<<<<<< SEARCH\n=======\n# appended\n>>>>>>> REPLACE\n"
|
|
result = apply_patches(diff, "existing")
|
|
|
|
assert "existing# appended" == result
|
|
|
|
def test_parse_error_returns_original(self) -> None:
|
|
"""
|
|
Invalid diff returns content unchanged.
|
|
"""
|
|
result = apply_patches("not a diff", "original")
|
|
|
|
assert "original" == result
|
|
|
|
|
|
class TestSearchAndReplaceDiff:
|
|
"""Tests for SearchAndReplaceDiff.run."""
|
|
|
|
def test_single_file_patch(self) -> None:
|
|
"""
|
|
Patch applied to matching file.
|
|
"""
|
|
content = (
|
|
"<replace_in_file>"
|
|
"<path>foo.py</path>"
|
|
"<diff>\n<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n</diff>"
|
|
"</replace_in_file>"
|
|
)
|
|
diff = SearchAndReplaceDiff(
|
|
content=content,
|
|
source_code={"foo.py": "old code"},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "new code" == result["foo.py"]
|
|
|
|
|
|
class TestNorm:
|
|
"""Tests for _norm."""
|
|
|
|
def test_strips_cr(self) -> None:
|
|
"""
|
|
Carriage returns are removed.
|
|
"""
|
|
assert "hello" == _norm("hello\r")
|
|
|
|
def test_no_cr(self) -> None:
|
|
"""
|
|
Lines without CR are unchanged.
|
|
"""
|
|
assert "hello" == _norm("hello")
|
|
|
|
|
|
class TestFindContextCore:
|
|
"""Tests for find_context_core."""
|
|
|
|
def test_exact_match(self) -> None:
|
|
"""
|
|
Exact match returns fuzz=0.
|
|
"""
|
|
lines = ["a", "b", "c", "d"]
|
|
idx, fuzz = find_context_core(lines, ["b", "c"], 0)
|
|
|
|
assert 1 == idx
|
|
assert 0 == fuzz
|
|
|
|
def test_rstrip_match(self) -> None:
|
|
"""
|
|
Trailing whitespace match returns fuzz=1.
|
|
"""
|
|
lines = ["a ", "b "]
|
|
idx, fuzz = find_context_core(lines, ["a", "b"], 0)
|
|
|
|
assert 0 == idx
|
|
assert 1 == fuzz
|
|
|
|
def test_strip_match(self) -> None:
|
|
"""
|
|
Leading+trailing whitespace match returns fuzz=100.
|
|
"""
|
|
lines = [" a ", " b "]
|
|
idx, fuzz = find_context_core(lines, ["a", "b"], 0)
|
|
|
|
assert 0 == idx
|
|
assert 100 == fuzz
|
|
|
|
def test_not_found(self) -> None:
|
|
"""
|
|
Missing context returns -1.
|
|
"""
|
|
idx, fuzz = find_context_core(["a", "b"], ["x", "y"], 0)
|
|
|
|
assert -1 == idx
|
|
|
|
|
|
class TestFindContext:
|
|
"""Tests for find_context with EOF handling."""
|
|
|
|
def test_eof_penalty(self) -> None:
|
|
"""
|
|
EOF match not at end gets +10000 fuzz penalty.
|
|
"""
|
|
lines = ["a", "b", "c", "d"]
|
|
idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)
|
|
|
|
assert 0 == idx
|
|
assert fuzz >= 10_000
|
|
|
|
|
|
class TestExtractUpdateSections:
|
|
"""Tests for extract_update_sections."""
|
|
|
|
def test_single_file(self) -> None:
|
|
"""
|
|
Single UPDATE section is extracted.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: foo.py\n"
|
|
" context\n"
|
|
"-old\n"
|
|
"+new\n"
|
|
"*** End Patch\n"
|
|
)
|
|
sections = extract_update_sections(content)
|
|
|
|
assert "foo.py" in sections
|
|
|
|
def test_no_patch_markers(self) -> None:
|
|
"""
|
|
Missing markers return empty dict.
|
|
"""
|
|
assert {} == extract_update_sections("no patch here")
|
|
|
|
|
|
class TestApplyUpdate:
|
|
"""Tests for apply_update."""
|
|
|
|
def test_simple_replacement(self) -> None:
|
|
"""
|
|
Single chunk replaces lines correctly.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=1,
|
|
del_lines=["old_line"],
|
|
ins_lines=["new_line"],
|
|
)
|
|
],
|
|
)
|
|
result = apply_update("first\nold_line\nlast", action, "test.py")
|
|
|
|
assert "first\nnew_line\nlast\n" == result
|
|
|
|
def test_mismatch_raises(self) -> None:
|
|
"""
|
|
Deleted line mismatch raises DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=0,
|
|
del_lines=["expected"],
|
|
ins_lines=["new"],
|
|
)
|
|
],
|
|
)
|
|
with pytest.raises(DiffError, match="mismatch"):
|
|
apply_update("actual", action, "test.py")
|
|
|
|
|
|
class TestV4ADiff:
|
|
"""Tests for V4ADiff.run."""
|
|
|
|
def test_no_changes(self) -> None:
|
|
"""
|
|
No matching sections returns original code.
|
|
"""
|
|
diff = V4ADiff(
|
|
content="*** Begin Patch\n*** End Patch\n",
|
|
source_code={"foo.py": "original"},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "original" == result["foo.py"]
|
|
|
|
|
|
class TestFindContextCoreNoMatch:
|
|
"""Tests for find_context_core when no match exists."""
|
|
|
|
def test_empty_lines(self) -> None:
|
|
"""
|
|
Empty lines list returns -1.
|
|
"""
|
|
idx, fuzz = find_context_core([], ["a"], 0)
|
|
|
|
assert -1 == idx
|
|
assert 0 == fuzz
|
|
|
|
def test_context_longer_than_lines(self) -> None:
|
|
"""
|
|
Context longer than the lines list returns -1.
|
|
"""
|
|
idx, fuzz = find_context_core(["a"], ["a", "b"], 0)
|
|
|
|
assert -1 == idx
|
|
|
|
def test_start_past_match_range(self) -> None:
|
|
"""
|
|
Start index beyond possible match range returns -1.
|
|
"""
|
|
lines = ["a", "b", "c"]
|
|
idx, fuzz = find_context_core(lines, ["a", "b"], 2)
|
|
|
|
assert -1 == idx
|
|
|
|
def test_exact_match_at_offset(self) -> None:
|
|
"""
|
|
Exact match found when start skips earlier occurrences.
|
|
"""
|
|
lines = ["a", "b", "a", "b"]
|
|
idx, fuzz = find_context_core(lines, ["a", "b"], 2)
|
|
|
|
assert 2 == idx
|
|
assert 0 == fuzz
|
|
|
|
|
|
class TestFindContextEOFAndNonEOF:
|
|
"""Tests for find_context EOF and non-EOF paths."""
|
|
|
|
def test_eof_match_at_end_no_penalty(self) -> None:
|
|
"""
|
|
EOF match at the actual end of file returns fuzz without penalty.
|
|
"""
|
|
lines = ["x", "y", "a", "b"]
|
|
idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)
|
|
|
|
assert 2 == idx
|
|
assert 0 == fuzz
|
|
|
|
def test_eof_no_match_returns_negative(self) -> None:
|
|
"""
|
|
EOF search with no match anywhere returns -1.
|
|
"""
|
|
lines = ["x", "y", "z"]
|
|
idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)
|
|
|
|
assert -1 == idx
|
|
assert 0 == fuzz
|
|
|
|
def test_non_eof_delegates_to_core(self) -> None:
|
|
"""
|
|
Non-EOF call delegates directly to find_context_core.
|
|
"""
|
|
lines = ["a", "b", "c"]
|
|
idx, fuzz = find_context(lines, ["a", "b"], 0, eof=False)
|
|
|
|
assert 0 == idx
|
|
assert 0 == fuzz
|
|
|
|
def test_non_eof_no_match(self) -> None:
|
|
"""
|
|
Non-EOF with no match returns -1.
|
|
"""
|
|
lines = ["x", "y"]
|
|
idx, fuzz = find_context(lines, ["a"], 0, eof=False)
|
|
|
|
assert -1 == idx
|
|
|
|
def test_eof_multiple_candidates_prefers_end(self) -> None:
|
|
"""
|
|
EOF search finds the match closest to the end of file.
|
|
"""
|
|
lines = ["a", "b", "c", "a", "b"]
|
|
idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)
|
|
|
|
assert 3 == idx
|
|
assert 0 == fuzz
|
|
|
|
|
|
class TestPeekNextSection:
|
|
"""Tests for peek_next_section state machine."""
|
|
|
|
def test_keep_lines(self) -> None:
|
|
"""
|
|
Lines starting with space are context (keep) lines.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" hello", " world"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert ["hello", "world"] == ctx
|
|
assert [] == chunks
|
|
assert 2 == next_i
|
|
assert is_eof is False
|
|
|
|
def test_delete_then_add(self) -> None:
|
|
"""
|
|
A delete line followed by an add line produces one chunk.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" ctx", "-old", "+new", " after"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 4 == next_i
|
|
assert 1 == len(chunks)
|
|
assert ["old"] == chunks[0].del_lines
|
|
assert ["new"] == chunks[0].ins_lines
|
|
|
|
def test_add_only(self) -> None:
|
|
"""
|
|
Add lines without preceding deletes produce an insertion chunk.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" before", "+inserted", " after"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == len(chunks)
|
|
assert [] == chunks[0].del_lines
|
|
assert ["inserted"] == chunks[0].ins_lines
|
|
|
|
def test_delete_only(self) -> None:
|
|
"""
|
|
Delete lines without adds produce a deletion chunk.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" before", "-removed", " after"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == len(chunks)
|
|
assert ["removed"] == chunks[0].del_lines
|
|
assert [] == chunks[0].ins_lines
|
|
|
|
def test_stops_at_scope_marker(self) -> None:
|
|
"""
|
|
Parsing stops when an @@ scope marker is encountered.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" context", "@@ next scope @@"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == next_i
|
|
assert ["context"] == ctx
|
|
|
|
def test_stops_at_update_marker(self) -> None:
|
|
"""
|
|
Parsing stops when a *** marker (non-End of File) is encountered.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" context", "*** Update File: bar.py"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == next_i
|
|
assert is_eof is False
|
|
|
|
def test_end_of_file_marker(self) -> None:
|
|
"""
|
|
'*** End of File' sets the is_eof flag and advances past it.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" tail", "*** End of File"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert is_eof is True
|
|
assert 2 == next_i
|
|
|
|
def test_invalid_line_raises(self) -> None:
|
|
"""
|
|
A line that doesn't start with space, +, -, @@, or *** raises DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = ["bad line with no prefix"]
|
|
with pytest.raises(DiffError, match="Invalid patch line"):
|
|
peek_next_section(lines, 0)
|
|
|
|
def test_empty_line_treated_as_context(self) -> None:
|
|
"""
|
|
An empty line (no prefix) is treated as an empty context line.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = ["", " after"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert ["", "after"] == ctx
|
|
assert 2 == next_i
|
|
|
|
def test_add_followed_by_delete_flushes_chunk(self) -> None:
|
|
"""
|
|
A delete line after add lines flushes the current chunk and starts a new one.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" ctx", "+added", "-removed", " end"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 2 == len(chunks)
|
|
assert ["added"] == chunks[0].ins_lines
|
|
assert [] == chunks[0].del_lines
|
|
assert ["removed"] == chunks[1].del_lines
|
|
|
|
def test_trailing_add_flushed_at_end(self) -> None:
|
|
"""
|
|
Pending add/delete lines at end of input are flushed into a chunk.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" ctx", "+trailing"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == len(chunks)
|
|
assert ["trailing"] == chunks[0].ins_lines
|
|
|
|
def test_multiple_deletes_then_adds(self) -> None:
|
|
"""
|
|
Multiple delete lines followed by multiple add lines form one chunk.
|
|
"""
|
|
from codeflash_api.diff._v4a import peek_next_section
|
|
|
|
lines = [" ctx", "-del1", "-del2", "+add1", "+add2", " end"]
|
|
ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)
|
|
|
|
assert 1 == len(chunks)
|
|
assert ["del1", "del2"] == chunks[0].del_lines
|
|
assert ["add1", "add2"] == chunks[0].ins_lines
|
|
|
|
|
|
class TestParseUpdateFileSections:
|
|
"""Tests for _parse_update_file_sections."""
|
|
|
|
def test_simple_section(self) -> None:
|
|
"""
|
|
A single context+change section is parsed into a PatchAction.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
" first",
|
|
"-old_line",
|
|
"+new_line",
|
|
" last",
|
|
]
|
|
file_content = "first\nold_line\nlast"
|
|
|
|
action, next_i, fuzz = _parse_update_file_sections(
|
|
patch_lines, 0, file_content
|
|
)
|
|
|
|
assert 1 == len(action.chunks)
|
|
assert 0 == fuzz
|
|
|
|
def test_scope_marker_navigation(self) -> None:
|
|
"""
|
|
@@ scope @@ markers navigate to the matching line in the file.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
"@@ def foo(): @@",
|
|
" def foo():",
|
|
"- old",
|
|
"+ new",
|
|
]
|
|
file_content = "import os\n\ndef foo():\n old\n rest"
|
|
|
|
action, next_i, fuzz = _parse_update_file_sections(
|
|
patch_lines, 0, file_content
|
|
)
|
|
|
|
assert 1 == len(action.chunks)
|
|
|
|
def test_scope_not_found_raises(self) -> None:
|
|
"""
|
|
A scope that doesn't exist in the file raises DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
"@@ def nonexistent(): @@",
|
|
" some context",
|
|
]
|
|
file_content = "def foo():\n pass"
|
|
|
|
with pytest.raises(DiffError, match="Could not find scope"):
|
|
_parse_update_file_sections(patch_lines, 0, file_content)
|
|
|
|
def test_context_not_found_raises(self) -> None:
|
|
"""
|
|
Context lines that don't match the file raise DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
" nonexistent context",
|
|
"-old",
|
|
"+new",
|
|
]
|
|
file_content = "completely different\ncontent here"
|
|
|
|
with pytest.raises(DiffError, match="Could not find context"):
|
|
_parse_update_file_sections(patch_lines, 0, file_content)
|
|
|
|
def test_skips_blank_lines_between_sections(self) -> None:
|
|
"""
|
|
Blank lines between sections are skipped.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
"",
|
|
" first",
|
|
"-old",
|
|
"+new",
|
|
]
|
|
file_content = "first\nold\nlast"
|
|
|
|
action, next_i, fuzz = _parse_update_file_sections(
|
|
patch_lines, 0, file_content
|
|
)
|
|
|
|
assert 1 == len(action.chunks)
|
|
|
|
def test_stops_at_next_file_marker(self) -> None:
|
|
"""
|
|
Parsing stops at *** Update File marker for the next file.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
" first",
|
|
"-old",
|
|
"+new",
|
|
"*** Update File: other.py",
|
|
]
|
|
file_content = "first\nold\nlast"
|
|
|
|
action, next_i, fuzz = _parse_update_file_sections(
|
|
patch_lines, 0, file_content
|
|
)
|
|
|
|
assert 3 == next_i
|
|
|
|
def test_chunks_without_context(self) -> None:
|
|
"""
|
|
Changes at the start of a section with no preceding context are offset
|
|
by current_file_idx.
|
|
"""
|
|
from codeflash_api.diff._v4a import _parse_update_file_sections
|
|
|
|
patch_lines = [
|
|
"+inserted_line",
|
|
]
|
|
file_content = "existing"
|
|
|
|
action, next_i, fuzz = _parse_update_file_sections(
|
|
patch_lines, 0, file_content
|
|
)
|
|
|
|
assert 1 == len(action.chunks)
|
|
assert 0 == action.chunks[0].orig_index
|
|
|
|
|
|
class TestParsePatchText:
|
|
"""Tests for parse_patch_text."""
|
|
|
|
def test_single_file_update(self) -> None:
|
|
"""
|
|
A patch updating one file is parsed correctly.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = [
|
|
"*** Update File: foo.py",
|
|
" hello",
|
|
"-old",
|
|
"+new",
|
|
" world",
|
|
]
|
|
files = {"foo.py": "hello\nold\nworld"}
|
|
|
|
patch = parse_patch_text(lines, files)
|
|
|
|
assert "foo.py" in patch.actions
|
|
assert 1 == len(patch.actions["foo.py"].chunks)
|
|
|
|
def test_multiple_files(self) -> None:
|
|
"""
|
|
A patch updating multiple files parses all of them.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = [
|
|
"*** Update File: a.py",
|
|
" line_a",
|
|
"-old_a",
|
|
"+new_a",
|
|
"*** Update File: b.py",
|
|
" line_b",
|
|
"-old_b",
|
|
"+new_b",
|
|
]
|
|
files = {"a.py": "line_a\nold_a", "b.py": "line_b\nold_b"}
|
|
|
|
patch = parse_patch_text(lines, files)
|
|
|
|
assert "a.py" in patch.actions
|
|
assert "b.py" in patch.actions
|
|
|
|
def test_file_not_found_raises(self) -> None:
|
|
"""
|
|
Referencing a file not in current_files raises DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = ["*** Update File: missing.py", " ctx"]
|
|
files = {"other.py": "content"}
|
|
|
|
with pytest.raises(DiffError, match="File not found"):
|
|
parse_patch_text(lines, files)
|
|
|
|
def test_unknown_line_raises(self) -> None:
|
|
"""
|
|
A line that isn't an Update File marker raises DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = ["garbage line"]
|
|
files = {"foo.py": "content"}
|
|
|
|
with pytest.raises(DiffError, match="Unknown or misplaced"):
|
|
parse_patch_text(lines, files)
|
|
|
|
def test_blank_lines_skipped(self) -> None:
|
|
"""
|
|
Leading blank lines before a file section are skipped.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = [
|
|
"",
|
|
"*** Update File: foo.py",
|
|
" hello",
|
|
"-old",
|
|
"+new",
|
|
]
|
|
files = {"foo.py": "hello\nold\nrest"}
|
|
|
|
patch = parse_patch_text(lines, files)
|
|
|
|
assert "foo.py" in patch.actions
|
|
|
|
def test_duplicate_file_merges_chunks(self) -> None:
|
|
"""
|
|
Multiple UPDATE sections for the same file merge their chunks.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = [
|
|
"*** Update File: foo.py",
|
|
" aaa",
|
|
"-bbb",
|
|
"+ccc",
|
|
"*** Update File: foo.py",
|
|
" ddd",
|
|
"-eee",
|
|
"+fff",
|
|
]
|
|
files = {"foo.py": "aaa\nbbb\nddd\neee"}
|
|
|
|
patch = parse_patch_text(lines, files)
|
|
|
|
assert 2 == len(patch.actions["foo.py"].chunks)
|
|
|
|
def test_empty_lines_only(self) -> None:
|
|
"""
|
|
Input with only blank lines produces a patch with no actions.
|
|
"""
|
|
from codeflash_api.diff._v4a import parse_patch_text
|
|
|
|
lines = ["", "", ""]
|
|
files = {"foo.py": "content"}
|
|
|
|
patch = parse_patch_text(lines, files)
|
|
|
|
assert {} == patch.actions
|
|
|
|
|
|
class TestExtractUpdateSectionsEdgeCases:
|
|
"""Tests for extract_update_sections edge cases."""
|
|
|
|
def test_multiple_files(self) -> None:
|
|
"""
|
|
Multiple UPDATE sections are all extracted.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: a.py\n"
|
|
" ctx_a\n"
|
|
"-old_a\n"
|
|
"+new_a\n"
|
|
"*** Update File: b.py\n"
|
|
" ctx_b\n"
|
|
"-old_b\n"
|
|
"+new_b\n"
|
|
"*** End Patch\n"
|
|
)
|
|
sections = extract_update_sections(content)
|
|
|
|
assert "a.py" in sections
|
|
assert "b.py" in sections
|
|
|
|
def test_empty_section_skipped(self) -> None:
|
|
"""
|
|
Empty section parts between split markers are skipped.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: \n"
|
|
"*** Update File: real.py\n"
|
|
" ctx\n"
|
|
"*** End Patch\n"
|
|
)
|
|
sections = extract_update_sections(content)
|
|
|
|
assert "real.py" in sections
|
|
|
|
def test_file_path_without_newline_skipped(self) -> None:
|
|
"""
|
|
A section with just a filename and no newline is skipped.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n*** Update File: only_name\n*** End Patch\n"
|
|
)
|
|
sections = extract_update_sections(content)
|
|
|
|
assert {} == sections
|
|
|
|
def test_section_content_preserved(self) -> None:
|
|
"""
|
|
The diff content within a section is reconstructed with the
|
|
UPDATE prefix.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: test.py\n"
|
|
" context\n"
|
|
"-old\n"
|
|
"+new\n"
|
|
"*** End Patch\n"
|
|
)
|
|
sections = extract_update_sections(content)
|
|
|
|
assert sections["test.py"].startswith("*** Update File: test.py")
|
|
assert "-old" in sections["test.py"]
|
|
assert "+new" in sections["test.py"]
|
|
|
|
|
|
class TestApplyUpdateEdgeCases:
|
|
"""Tests for apply_update edge cases."""
|
|
|
|
def test_overlapping_chunks_raises(self) -> None:
|
|
"""
|
|
Chunks with overlapping line ranges raise DiffError.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=0,
|
|
del_lines=["line1", "line2"],
|
|
ins_lines=["new1"],
|
|
),
|
|
Chunk(
|
|
orig_index=1,
|
|
del_lines=["line2"],
|
|
ins_lines=["new2"],
|
|
),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(DiffError, match="Overlapping"):
|
|
apply_update("line1\nline2\nline3", action, "test.py")
|
|
|
|
def test_insertion_only_chunk(self) -> None:
|
|
"""
|
|
A chunk with no deletions only inserts lines.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=1,
|
|
del_lines=[],
|
|
ins_lines=["inserted"],
|
|
)
|
|
],
|
|
)
|
|
result = apply_update("first\nsecond\nthird", action, "test.py")
|
|
|
|
assert "first\ninserted\nsecond\nthird\n" == result
|
|
|
|
def test_deletion_only_chunk(self) -> None:
|
|
"""
|
|
A chunk with no insertions removes lines.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=1,
|
|
del_lines=["second"],
|
|
ins_lines=[],
|
|
)
|
|
],
|
|
)
|
|
result = apply_update("first\nsecond\nthird", action, "test.py")
|
|
|
|
assert "first\nthird\n" == result
|
|
|
|
def test_multiple_non_overlapping_chunks(self) -> None:
|
|
"""
|
|
Multiple sorted, non-overlapping chunks are all applied.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=1,
|
|
del_lines=["b"],
|
|
ins_lines=["B"],
|
|
),
|
|
Chunk(
|
|
orig_index=3,
|
|
del_lines=["d"],
|
|
ins_lines=["D"],
|
|
),
|
|
],
|
|
)
|
|
result = apply_update("a\nb\nc\nd\ne", action, "test.py")
|
|
|
|
assert "a\nB\nc\nD\ne\n" == result
|
|
|
|
def test_empty_file_with_insertion(self) -> None:
|
|
"""
|
|
Inserting into an empty file works.
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=0,
|
|
del_lines=[],
|
|
ins_lines=["new_content"],
|
|
)
|
|
],
|
|
)
|
|
result = apply_update("", action, "test.py")
|
|
|
|
assert "new_content\n" == result
|
|
|
|
def test_whitespace_tolerant_delete_match(self) -> None:
|
|
"""
|
|
Deleted-line matching is whitespace-tolerant (strips both sides).
|
|
"""
|
|
from codeflash_api.diff._v4a import Chunk, PatchAction
|
|
|
|
action = PatchAction(
|
|
path="test.py",
|
|
chunks=[
|
|
Chunk(
|
|
orig_index=0,
|
|
del_lines=[" hello "],
|
|
ins_lines=["goodbye"],
|
|
)
|
|
],
|
|
)
|
|
result = apply_update(" hello", action, "test.py")
|
|
|
|
assert "goodbye\n" == result
|
|
|
|
def test_no_chunks_returns_original(self) -> None:
|
|
"""
|
|
A PatchAction with no chunks returns the original text with trailing newline.
|
|
"""
|
|
from codeflash_api.diff._v4a import PatchAction
|
|
|
|
action = PatchAction(path="test.py", chunks=[])
|
|
result = apply_update("hello\nworld", action, "test.py")
|
|
|
|
assert "hello\nworld\n" == result
|
|
|
|
|
|
class TestV4ADiffApply:
|
|
"""Tests for V4ADiff.run with actual patching."""
|
|
|
|
def test_single_file_patch(self) -> None:
|
|
"""
|
|
A complete V4A patch modifies matching file content.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: main.py\n"
|
|
" def foo():\n"
|
|
"- return 1\n"
|
|
"+ return 2\n"
|
|
"*** End Patch\n"
|
|
)
|
|
diff = V4ADiff(
|
|
content=content,
|
|
source_code={"main.py": "def foo():\n return 1\n"},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "def foo():\n return 2\n" == result["main.py"]
|
|
|
|
def test_unmatched_file_unchanged(self) -> None:
|
|
"""
|
|
Files not mentioned in the patch are returned unchanged.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: a.py\n"
|
|
" line\n"
|
|
"-old\n"
|
|
"+new\n"
|
|
"*** End Patch\n"
|
|
)
|
|
diff = V4ADiff(
|
|
content=content,
|
|
source_code={
|
|
"a.py": "line\nold\nrest",
|
|
"b.py": "untouched",
|
|
},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "untouched" == result["b.py"]
|
|
|
|
def test_multi_file_patch(self) -> None:
|
|
"""
|
|
A patch updating multiple files modifies all of them.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: x.py\n"
|
|
" alpha\n"
|
|
"-beta\n"
|
|
"+BETA\n"
|
|
"*** Update File: y.py\n"
|
|
" gamma\n"
|
|
"-delta\n"
|
|
"+DELTA\n"
|
|
"*** End Patch\n"
|
|
)
|
|
diff = V4ADiff(
|
|
content=content,
|
|
source_code={
|
|
"x.py": "alpha\nbeta\nepsilon",
|
|
"y.py": "gamma\ndelta\nzeta",
|
|
},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "BETA" in result["x.py"]
|
|
assert "DELTA" in result["y.py"]
|
|
|
|
def test_patch_no_actions_for_file(self) -> None:
|
|
"""
|
|
When a section is extracted but parses into no actions, the original
|
|
code is returned.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n*** Update File: empty.py\n\n*** End Patch\n"
|
|
)
|
|
diff = V4ADiff(
|
|
content=content,
|
|
source_code={"empty.py": "original code"},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "original code" == result["empty.py"]
|
|
|
|
def test_multi_hunk_single_file(self) -> None:
|
|
"""
|
|
Multiple changes in different parts of the same file are all applied.
|
|
"""
|
|
content = (
|
|
"*** Begin Patch\n"
|
|
"*** Update File: code.py\n"
|
|
" aaa\n"
|
|
"-bbb\n"
|
|
"+BBB\n"
|
|
" ccc\n"
|
|
" ddd\n"
|
|
"-eee\n"
|
|
"+EEE\n"
|
|
"*** End Patch\n"
|
|
)
|
|
diff = V4ADiff(
|
|
content=content,
|
|
source_code={"code.py": "aaa\nbbb\nccc\nddd\neee\nfff"},
|
|
)
|
|
result = diff.run()
|
|
|
|
assert "aaa\nBBB\nccc\nddd\nEEE\nfff\n" == result["code.py"]
|