292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""Tests for script_apply_edits.py local helper functions.
|
|
|
|
Focuses on _apply_edits_locally, _find_best_closing_brace_match,
|
|
and _is_in_string_context — especially around C# string variants
|
|
(verbatim, interpolated, raw) that can fool brace/anchor matching.
|
|
"""
|
|
import re
|
|
import pytest
|
|
|
|
from services.tools.script_apply_edits import (
|
|
_apply_edits_locally,
|
|
_find_best_closing_brace_match,
|
|
_find_best_anchor_match,
|
|
_is_in_string_context,
|
|
)
|
|
|
|
|
|
# ── _is_in_string_context ────────────────────────────────────────────
|
|
|
|
class TestIsInStringContext:
|
|
def test_plain_code_not_in_string(self):
|
|
text = 'int x = 42;'
|
|
assert not _is_in_string_context(text, 4)
|
|
|
|
def test_inside_regular_string(self):
|
|
text = 'string s = "hello world";'
|
|
# Position inside "hello world"
|
|
pos = text.index("hello")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_inside_verbatim_string(self):
|
|
text = 'string s = @"C:\\Users\\file";'
|
|
pos = text.index("C:")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_inside_interpolated_string(self):
|
|
text = 'string s = $"Value: {x}";'
|
|
# The "Value" part is inside the string
|
|
pos = text.index("Value")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_interpolation_hole_is_not_string(self):
|
|
text = 'string s = $"Value: {x}";'
|
|
# The x inside {x} is in an interpolation hole — it's code, not string
|
|
brace_pos = text.index("{x}") + 1 # the 'x'
|
|
assert not _is_in_string_context(text, brace_pos)
|
|
|
|
def test_inside_single_line_comment(self):
|
|
text = 'int x = 1; // this is a comment'
|
|
pos = text.index("this")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_inside_multi_line_comment(self):
|
|
text = 'int x = 1; /* block { } */ int y = 2;'
|
|
pos = text.index("block")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_after_comment_is_code(self):
|
|
text = '// comment\nint x = 1;'
|
|
pos = text.index("int")
|
|
assert not _is_in_string_context(text, pos)
|
|
|
|
def test_verbatim_string_doubled_quotes(self):
|
|
text = 'string s = @"He said ""hello""";'
|
|
# The whole thing is one string ending at the final ";
|
|
pos = text.index("hello")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_interpolated_verbatim_combined(self):
|
|
text = 'string s = $@"Path: {dir}\\file";'
|
|
# "Path" is inside the string
|
|
pos = text.index("Path")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_raw_string_literal(self):
|
|
text = 'string s = """\n{ }\n""";'
|
|
pos = text.index("{ }")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_interpolated_raw_string_content(self):
|
|
text = 'string s = $"""\n Hello {name}\n """;'
|
|
# "Hello" is string content (non-code)
|
|
pos = text.index("Hello")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_interpolated_raw_string_hole_is_code(self):
|
|
text = 'string s = $"""\n Hello {name}\n """;'
|
|
# "name" inside {name} is in an interpolation hole — code
|
|
pos = text.index("name")
|
|
assert not _is_in_string_context(text, pos)
|
|
|
|
def test_multi_dollar_raw_string_content(self):
|
|
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
|
|
# {literal} has only 1 brace — it's literal string content
|
|
pos = text.index("literal")
|
|
assert _is_in_string_context(text, pos)
|
|
|
|
def test_multi_dollar_raw_string_hole_is_code(self):
|
|
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
|
|
# {{interp}} has 2 braces matching $$ — it's an interpolation hole
|
|
pos = text.index("interp")
|
|
assert not _is_in_string_context(text, pos)
|
|
|
|
def test_interpolated_raw_string_closing(self):
|
|
text = 'string s = $"""\n body\n """; int x = 1;'
|
|
# "x" after the closing """ is code
|
|
pos = text.index("x = 1")
|
|
assert not _is_in_string_context(text, pos)
|
|
|
|
|
|
# ── _find_best_closing_brace_match ───────────────────────────────────
|
|
|
|
class TestFindBestClosingBraceMatch:
|
|
def test_skips_braces_in_interpolated_strings(self):
|
|
"""Braces inside $"...{x}..." should not be scored as class-end."""
|
|
code = (
|
|
'public class Foo {\n'
|
|
' void M() {\n'
|
|
' string s = $"Score: {score}";\n'
|
|
' }\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
|
# There should be matches for the method close and class close
|
|
assert len(matches) >= 1
|
|
best = _find_best_closing_brace_match(matches, code)
|
|
# The best match should be the class-closing brace, not one inside a string
|
|
assert best is not None
|
|
line_num = code[:best.start()].count('\n')
|
|
# Class close is the last "}" line
|
|
assert line_num == 4 # 0-indexed, line 5 is "}"
|
|
|
|
def test_skips_braces_in_verbatim_strings(self):
|
|
"""@"{ }" should not confuse the scorer."""
|
|
code = (
|
|
'public class Foo {\n'
|
|
' string s = @"{ }";\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
|
best = _find_best_closing_brace_match(matches, code)
|
|
assert best is not None
|
|
|
|
def test_prefers_class_brace_over_method_brace(self):
|
|
"""Should pick class-closing } (depth 1) over method-closing } (depth 2)."""
|
|
code = (
|
|
'public class Foo : MonoBehaviour\n'
|
|
'{\n'
|
|
' private int score = 42;\n'
|
|
'\n'
|
|
' void Start()\n'
|
|
' {\n'
|
|
' Debug.Log($"Score: {score}");\n'
|
|
' }\n'
|
|
'\n'
|
|
' void OnGUI()\n'
|
|
' {\n'
|
|
' GUI.Label(new Rect(10, 10, 200, 20), $"Score: {score}");\n'
|
|
' }\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
|
# Should have 3 matches: Start close, OnGUI close, class close
|
|
assert len(matches) == 3
|
|
best = _find_best_closing_brace_match(matches, code)
|
|
assert best is not None
|
|
best_line = code[:best.start()].count('\n')
|
|
# Class close is the last "}" — line 13 (0-indexed)
|
|
assert best_line == 13
|
|
|
|
def test_skips_braces_in_interpolated_raw_strings(self):
|
|
"""$\"\"\"{x}\"\"\" braces should not confuse the scorer."""
|
|
code = (
|
|
'public class Foo {\n'
|
|
' string s = $"""\n'
|
|
' { literal }\n'
|
|
' {interp}\n'
|
|
' """;\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
|
best = _find_best_closing_brace_match(matches, code)
|
|
assert best is not None
|
|
best_line = code[:best.start()].count('\n')
|
|
assert best_line == 5 # class-closing brace
|
|
|
|
def test_closing_brace_scorer_with_interpolated_code(self):
|
|
"""Realistic C# with multiple $"" strings should still find class-end."""
|
|
code = (
|
|
'using UnityEngine;\n'
|
|
'public class HUD : MonoBehaviour {\n'
|
|
' void OnGUI() {\n'
|
|
' Debug.Log($"Score: {score}");\n'
|
|
' Debug.Log($@"Path: {path}\\save");\n'
|
|
' }\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
|
best = _find_best_closing_brace_match(matches, code)
|
|
assert best is not None
|
|
# Should pick the class-closing brace (last one)
|
|
best_line = code[:best.start()].count('\n')
|
|
assert best_line == 6 # 0-indexed
|
|
|
|
|
|
# ── _apply_edits_locally regression guards ───────────────────────────
|
|
|
|
class TestApplyEditsLocally:
|
|
@pytest.mark.asyncio
|
|
async def test_replace_range_basic(self):
|
|
original = "line1\nline2\nline3\n"
|
|
edits = [{
|
|
"op": "replace_range",
|
|
"startLine": 2,
|
|
"startCol": 1,
|
|
"endLine": 2,
|
|
"endCol": 6,
|
|
"text": "REPLACED",
|
|
}]
|
|
result = await _apply_edits_locally(original, edits)
|
|
assert "REPLACED" in result
|
|
assert "line1" in result
|
|
assert "line3" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prepend_and_append(self):
|
|
original = "middle\n"
|
|
edits = [
|
|
{"op": "prepend", "text": "top\n"},
|
|
{"op": "append", "text": "bottom\n"},
|
|
]
|
|
result = await _apply_edits_locally(original, edits)
|
|
assert result.startswith("top\n")
|
|
assert "bottom" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_regex_replace_near_interpolated_strings(self):
|
|
"""regex_replace should work even when interpolated strings are in the code."""
|
|
original = (
|
|
'void M() {\n'
|
|
' Debug.Log($"x={x}");\n'
|
|
' int OLD = 1;\n'
|
|
'}\n'
|
|
)
|
|
edits = [{
|
|
"op": "regex_replace",
|
|
"pattern": r"OLD",
|
|
"replacement": "NEW",
|
|
"text": "NEW",
|
|
}]
|
|
result = await _apply_edits_locally(original, edits)
|
|
assert "NEW" in result
|
|
assert "OLD" not in result
|
|
|
|
|
|
# ── _find_best_anchor_match with string-aware filtering ──────────────
|
|
|
|
class TestAnchorMatchFiltering:
|
|
def test_anchor_skips_braces_in_interpolated_strings(self):
|
|
"""$"...{x}..." brace should not be picked as anchor match."""
|
|
code = (
|
|
'class Foo {\n'
|
|
' string s = $"val: {x}";\n'
|
|
' void M() { }\n'
|
|
'}\n'
|
|
)
|
|
# Pattern looking for closing brace at end of line
|
|
pattern = r'^\s*}\s*$'
|
|
flags = re.MULTILINE
|
|
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
|
|
assert match is not None
|
|
# Should match the class-closing brace, not anything inside the string
|
|
best_line = code[:match.start()].count('\n')
|
|
assert best_line == 3 # 0-indexed, class close
|
|
|
|
def test_anchor_skips_braces_in_verbatim_strings(self):
|
|
"""@"{ }" should not confuse anchor matching."""
|
|
code = (
|
|
'class Foo {\n'
|
|
' string s = @"{ }";\n'
|
|
'}\n'
|
|
)
|
|
pattern = r'^\s*}\s*$'
|
|
flags = re.MULTILINE
|
|
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
|
|
assert match is not None
|