"""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